feat(dashboard-testing): add reference URL baseline workflow and analyst UX
Discover dashboard filters, datasets and metrics from a parsed reference URL; capture and review baseline candidates with source provenance. Improve scenario DAG and run result views, add isolated browser coverage, and align contracts and ADRs.
This commit is contained in:
@@ -42,7 +42,6 @@ Dockerfile*
|
||||
.dockerignore
|
||||
backups
|
||||
semantics
|
||||
specs
|
||||
dist
|
||||
models
|
||||
.huggingface
|
||||
|
||||
@@ -104,8 +104,8 @@ 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` нужен только тестам,
|
||||
Playwright runner на образе `mcr.microsoft.com/playwright:v1.60.0-noble`. Для E2E нужны
|
||||
`AUTH_SECRET_KEY`, `ENCRYPTION_KEY`, `SERVICE_JWT`, `E2E_USERNAME`, `E2E_PASSWORD`; `GITEA_TOKEN` нужен только тестам,
|
||||
которые обращаются к Gitea.
|
||||
|
||||
## Тестовые команды
|
||||
|
||||
@@ -19,6 +19,8 @@ from fastapi import APIRouter
|
||||
from src.api.routes.dashboard_testing.candidates import router as candidates_router
|
||||
from src.api.routes.dashboard_testing.core import router as core_router
|
||||
from src.api.routes.dashboard_testing.inheritance import router as inheritance_router
|
||||
from src.api.routes.dashboard_testing.reference_discovery import router as reference_discovery_router
|
||||
from src.api.routes.dashboard_testing.reference_selection import router as reference_selection_router
|
||||
from src.api.routes.dashboard_testing.scenario import router as scenario_router
|
||||
from src.api.routes.dashboard_testing.scenario_analytics import router as scenario_analytics_router
|
||||
from src.api.routes.dashboard_testing.scenario_automation import router as scenario_automation_router
|
||||
@@ -52,6 +54,8 @@ router.include_router(structure_router)
|
||||
router.include_router(structure_snapshot_router)
|
||||
router.include_router(verification_router)
|
||||
router.include_router(inheritance_router)
|
||||
router.include_router(reference_discovery_router)
|
||||
router.include_router(reference_selection_router)
|
||||
router.include_router(scenario_router)
|
||||
router.include_router(scenarios_router)
|
||||
router.include_router(scenario_runs_router)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -16,6 +18,12 @@ from src.core.database import get_db
|
||||
from src.core.utils.client_registry import get_superset_client
|
||||
from src.dependencies import get_config_manager, has_permission
|
||||
from src.models.auth import User
|
||||
from src.models.agent_run import AgentRun, ApprovalGate, DraftArtifact
|
||||
from src.models.dashboard_release import DashboardRelease
|
||||
from src.models.deployment import DeploymentRecord
|
||||
from src.models.git import DeploymentEnvironment, GitRepository
|
||||
from src.models.scenario_registry import ScenarioRegistryEntry
|
||||
from src.schemas.dashboard_testing.candidates import BaselineOverview, BaselineOverviewEntry, CandidateReview
|
||||
from src.schemas.dashboard_testing import (
|
||||
ApprovalConsumeResponse,
|
||||
ApprovalDecisionRequest,
|
||||
@@ -39,12 +47,166 @@ from src.services.dashboard_testing.candidates import (
|
||||
decide_approval,
|
||||
request_approval,
|
||||
)
|
||||
from src.services.dashboard_testing.baseline_catalog import load_catalog
|
||||
from src.services.dashboard_testing.safe_path import assert_canonical_safe_path
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard-testing", tags=["Dashboard-Testing"])
|
||||
|
||||
_WRITE_PERMISSION = Depends(has_permission("dashboard:testing", "WRITE"))
|
||||
_APPROVE_PERMISSION = Depends(has_permission("dashboard:testing", "APPROVE"))
|
||||
_DB_SESSION = Depends(get_db)
|
||||
_READ_PERMISSION = Depends(has_permission("dashboard:testing", "READ"))
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.BaselineOverview [C:4] [TYPE Function] [SEMANTICS baseline,catalog,release,overview]
|
||||
# @BRIEF Show the latest approved release's baseline catalog for a scenario, including an empty catalog.
|
||||
# @PRE Caller has dashboard-testing READ permission; scenario id is exact and server-resolved.
|
||||
# @POST Returns a bounded business summary; malformed catalogs reject instead of appearing empty.
|
||||
# @INVARIANT Release and catalog paths are derived from server rows, never query-string keys.
|
||||
@router.get("/baseline-overview", response_model=BaselineOverview)
|
||||
async def baseline_overview(
|
||||
scenario_id: str = Query(..., min_length=1),
|
||||
_current_user: User = _READ_PERMISSION,
|
||||
db: Session = _DB_SESSION,
|
||||
) -> BaselineOverview:
|
||||
scenario = db.get(ScenarioRegistryEntry, scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="Scenario not found")
|
||||
repository = db.query(GitRepository).filter(GitRepository.dashboard_id == scenario.dashboard_id).first()
|
||||
if repository is None:
|
||||
return BaselineOverview(state="no_release")
|
||||
release = db.query(DashboardRelease).filter(
|
||||
DashboardRelease.repository_id == repository.id,
|
||||
DashboardRelease.status.in_(("approved", "published")),
|
||||
).order_by(DashboardRelease.created_at.desc(), DashboardRelease.id.desc()).first()
|
||||
if release is None:
|
||||
return BaselineOverview(state="no_release")
|
||||
try:
|
||||
context = _resolve_capture_release(db, release.id)
|
||||
catalog = load_catalog(assert_canonical_safe_path(context["repo_key"], context["dash_key"]))
|
||||
except ValueError as err:
|
||||
raise HTTPException(status_code=409, detail=f"Catalog cannot be reviewed: {err}") from err
|
||||
fatal_warnings = [warning for warning in catalog.warnings if warning.code != "CATALOG_NOT_FOUND"]
|
||||
if fatal_warnings:
|
||||
raise HTTPException(status_code=409, detail=f"Catalog cannot be reviewed: {fatal_warnings[0].code}")
|
||||
deployment = db.get(DeploymentRecord, release.deployment_id)
|
||||
environment = db.get(DeploymentEnvironment, deployment.environment_id) if deployment else None
|
||||
entries = [entry for entry in catalog.entries if entry.dashboard_id == scenario.dashboard_id]
|
||||
projected = [BaselineOverviewEntry(
|
||||
label=entry.label,
|
||||
result_key=entry.result_key,
|
||||
expected=entry.expected,
|
||||
comparison_policy=entry.comparison_policy,
|
||||
release_version=entry.release_version,
|
||||
state="current" if entry.release_version == release.version and entry.release_commit_hash == release.commit_hash else "stale",
|
||||
) for entry in entries[:50]]
|
||||
state = "missing" if not entries else "stale" if any(
|
||||
entry.release_version != release.version or entry.release_commit_hash != release.commit_hash
|
||||
for entry in entries
|
||||
) else "ready"
|
||||
return BaselineOverview(state=state, release_version=release.version,
|
||||
environment_name=environment.name if environment else None,
|
||||
entries=projected, total=len(entries))
|
||||
# #endregion Api.DashboardTesting.BaselineOverview
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.SameSourceHash [C:2] [TYPE Function] [SEMANTICS baseline,catalog,hash,identity]
|
||||
# @BRIEF Compare the exact SHA-256 digest across catalog's prefixed and capture's bare representation.
|
||||
# @INVARIANT Malformed digests never match, even if both strings are identical.
|
||||
def _same_source_hash(catalog_hash: str, candidate_hash: str) -> bool:
|
||||
catalog_digest = catalog_hash.removeprefix("sha256:")
|
||||
candidate_digest = candidate_hash.removeprefix("sha256:")
|
||||
return bool(re.fullmatch(r"[a-f0-9]{64}", catalog_digest)) and catalog_digest == candidate_digest
|
||||
# #endregion Api.DashboardTesting.SameSourceHash
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.ListCandidateReviews [C:4] [TYPE Function] [SEMANTICS baseline,candidate,review,ownership]
|
||||
# @BRIEF List reviewable candidates for a scenario dashboard, limited to the actor's AgentRuns.
|
||||
# @INVARIANT Candidate metadata is never returned across AgentRun ownership boundaries.
|
||||
# @RATIONALE Scenario registry gives the authoritative dashboard binding; release pin is looked
|
||||
# up from DashboardRelease, not supplied or inferred from client-side values.
|
||||
# @REJECTED Returning all candidates by dashboard was rejected because capture_meta contains
|
||||
# source values and provenance from runs owned by other users.
|
||||
@router.get("/baseline-candidates", response_model=list[CandidateReview])
|
||||
async def list_baseline_candidates(
|
||||
scenario_id: str = Query(..., min_length=1),
|
||||
current_user: User = _READ_PERMISSION,
|
||||
db: Session = _DB_SESSION,
|
||||
) -> list[CandidateReview]:
|
||||
scenario = db.query(ScenarioRegistryEntry).filter(
|
||||
(ScenarioRegistryEntry.scenario_id == scenario_id)
|
||||
| (ScenarioRegistryEntry.scenario_key == scenario_id)
|
||||
).first()
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scenario not found")
|
||||
drafts = (
|
||||
db.query(DraftArtifact)
|
||||
.join(AgentRun, AgentRun.id == DraftArtifact.run_id)
|
||||
.filter(
|
||||
DraftArtifact.kind == "baseline_candidate",
|
||||
AgentRun.user_id == current_user.id,
|
||||
AgentRun.dashboard_id == str(scenario.dashboard_id),
|
||||
)
|
||||
.order_by(DraftArtifact.created_at.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
reviews: list[CandidateReview] = []
|
||||
for draft in drafts:
|
||||
meta = dict(draft.capture_meta or {})
|
||||
gate = db.query(ApprovalGate).filter(
|
||||
ApprovalGate.id == meta.get("gate_id"), ApprovalGate.run_id == draft.run_id
|
||||
).first() if meta.get("gate_id") else None
|
||||
release = db.query(DashboardRelease).filter(
|
||||
DashboardRelease.id == meta.get("release_id")
|
||||
).first() if meta.get("release_id") else None
|
||||
deployment = db.get(DeploymentRecord, release.deployment_id) if release else None
|
||||
environment = db.get(DeploymentEnvironment, deployment.environment_id) if deployment else None
|
||||
request_data = {key: value for key, value in meta.items() if key in CandidateRequest.model_fields}
|
||||
try:
|
||||
request = CandidateRequest.model_validate(request_data)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
catalog = load_catalog(assert_canonical_safe_path(request.repository_key, request.dashboard_key))
|
||||
except ValueError as err:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Catalog path unavailable: {err}") from err
|
||||
fatal_warnings = [warning for warning in catalog.warnings if warning.code != "CATALOG_NOT_FOUND"]
|
||||
if fatal_warnings:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Catalog cannot be reviewed: {fatal_warnings[0].code}")
|
||||
coordinate = [entry for entry in catalog.entries if
|
||||
entry.dashboard_id == request.dashboard_id
|
||||
and entry.result_key == request.result_key
|
||||
and entry.chart_id == request.chart_id
|
||||
and entry.dataset_id == request.dataset_id
|
||||
and entry.normalized_filters == request.normalized_filters]
|
||||
catalog_status = "missing"
|
||||
if coordinate:
|
||||
catalog_status = "approved" if any(
|
||||
_same_source_hash(entry.source_response_hash, request.source_response_hash)
|
||||
and entry.release_version == (release.version if release else None)
|
||||
and entry.release_commit_hash == (release.commit_hash if release else None)
|
||||
for entry in coordinate
|
||||
) else "stale"
|
||||
current_baseline = max(coordinate, key=lambda entry: entry.created_at) if coordinate else None
|
||||
reviews.append(CandidateReview(
|
||||
candidate_id=draft.id,
|
||||
agent_run_id=draft.run_id,
|
||||
status=str(meta.get("candidate_status") or "draft"),
|
||||
request=request,
|
||||
gate_id=gate.id if gate else None,
|
||||
gate_status=gate.status if gate else None,
|
||||
gate_reason=gate.reason if gate else None,
|
||||
release_version=release.version if release else None,
|
||||
release_commit_hash=release.commit_hash if release else None,
|
||||
environment_name=environment.name if environment else None,
|
||||
catalog_status=catalog_status,
|
||||
current_baseline=current_baseline,
|
||||
created_at=draft.created_at,
|
||||
))
|
||||
return reviews
|
||||
# #endregion Api.DashboardTesting.ListCandidateReviews
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.CaptureCandidate [C:5] [TYPE Function] [SEMANTICS baseline,api,candidate,capture,authoritative]
|
||||
@@ -142,7 +304,7 @@ async def request_baseline_approval(
|
||||
) -> ApprovalGateResponse:
|
||||
"""Request HITL approval for a candidate."""
|
||||
try:
|
||||
gate = request_approval(db, _current_user.id, candidate_id, body)
|
||||
gate = request_approval(db, _current_user.id, candidate_id, body.model_copy(update={"reason_required": True}))
|
||||
db.commit()
|
||||
return ApprovalGateResponse(**gate)
|
||||
except ValueError as err:
|
||||
@@ -167,6 +329,8 @@ async def decide_baseline_approval(
|
||||
db: Session = _DB_SESSION,
|
||||
) -> ApprovalDecisionResponse:
|
||||
"""Confirm or deny an approval gate."""
|
||||
if not body.reason or not body.reason.strip():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A review reason is required")
|
||||
try:
|
||||
decision = decide_approval(db, _current_user.id, gate_id, body, candidate_id=candidate_id)
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# #region Api.DashboardTesting.ReferenceDiscovery [C:5] [TYPE Module] [SEMANTICS baseline,reference,discovery,api]
|
||||
# @defgroup Api Scenario-bound reference dashboard preview endpoint.
|
||||
# @INVARIANT The request cannot choose dashboard or Superset environment identities.
|
||||
# @INVARIANT READ and EXECUTE permissions precede database and Superset access.
|
||||
# @RATIONALE The persisted scenario supplies dashboard_id; an exact configured origin supplies the only permitted client.
|
||||
# @REJECTED Accepting environment_id or dashboard_id from the browser was rejected because those values could bypass scenario binding.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.core.utils.client_registry import get_superset_client
|
||||
from src.dependencies import get_config_manager, has_permission
|
||||
from src.models.auth import User
|
||||
from src.models.scenario_registry import ScenarioRegistryEntry
|
||||
from src.services.dashboard_testing.reference_discovery import discover_reference_dashboard
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard-testing", tags=["Dashboard-Testing"])
|
||||
_READ = Depends(has_permission("dashboard:testing", "READ"))
|
||||
_EXECUTE = Depends(has_permission("dashboard:testing", "EXECUTE"))
|
||||
_DB = Depends(get_db)
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.ReferenceDiscovery.Request [C:2] [TYPE Class]
|
||||
# @BRIEF Browser request carries only scenario identity, URL and chart-data budget.
|
||||
class ReferenceDiscoveryRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scenario_id: str = Field(min_length=1)
|
||||
url: str = Field(min_length=1, max_length=8192)
|
||||
max_queries: int = Field(default=20, ge=1, le=20)
|
||||
# #endregion Api.DashboardTesting.ReferenceDiscovery.Request
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.ReferenceDiscovery.Origin [C:3] [TYPE Function]
|
||||
# @BRIEF Resolve exactly one configured environment by host and port, allowing an HTTP link to an HTTPS environment.
|
||||
def _resolve_origin(url: str):
|
||||
supplied = urlsplit(url)
|
||||
if supplied.scheme not in ("http", "https") or not supplied.hostname or supplied.username or supplied.password:
|
||||
raise HTTPException(status_code=422, detail={"code": "INVALID_REFERENCE_ORIGIN", "message": "Reference URL must use a configured Superset origin"})
|
||||
matches = []
|
||||
for env in get_config_manager().get_environments():
|
||||
configured = urlsplit(env.url)
|
||||
same_origin = (configured.scheme, configured.netloc) == (supplied.scheme, supplied.netloc)
|
||||
upgraded_link = (supplied.scheme == "http" and configured.scheme == "https"
|
||||
and supplied.hostname == configured.hostname
|
||||
and supplied.port == configured.port)
|
||||
if same_origin or upgraded_link:
|
||||
matches.append(env)
|
||||
if not matches:
|
||||
raise HTTPException(status_code=422, detail={"code": "UNKNOWN_REFERENCE_ORIGIN", "message": "Reference URL origin is not configured"})
|
||||
if len(matches) > 1:
|
||||
raise HTTPException(status_code=409, detail={"code": "AMBIGUOUS_REFERENCE_ORIGIN", "message": "Multiple Superset environments match the reference URL"})
|
||||
return matches[0]
|
||||
# #endregion Api.DashboardTesting.ReferenceDiscovery.Origin
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.ReferenceDiscovery.Post [C:5] [TYPE Function]
|
||||
# @BRIEF Preview scenario dashboard metric evidence from a guarded reference URL.
|
||||
# @PRE Authenticated caller has dashboard:testing READ and EXECUTE.
|
||||
# @POST Returns read-only preview with `sha256:<64 hex>` public evidence digests, or a typed error; no baseline mutation.
|
||||
@router.post("/reference-discovery")
|
||||
async def reference_discovery(
|
||||
body: ReferenceDiscoveryRequest,
|
||||
_reader: User = _READ,
|
||||
_executor: User = _EXECUTE,
|
||||
db: Session = _DB,
|
||||
) -> dict[str, Any]:
|
||||
scenario = db.get(ScenarioRegistryEntry, body.scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail={"code": "SCENARIO_NOT_FOUND", "message": "Scenario not found"})
|
||||
env = _resolve_origin(body.url)
|
||||
if env.id not in (scenario.environment_ids or []):
|
||||
raise HTTPException(status_code=422, detail={"code": "ENVIRONMENT_MISMATCH", "message": "Reference URL environment differs from scenario environments"})
|
||||
try:
|
||||
client = await get_superset_client(env)
|
||||
preview = await discover_reference_dashboard(
|
||||
client, env.id, scenario.dashboard_id, body.url,
|
||||
max_queries=body.max_queries,
|
||||
)
|
||||
for coordinate in preview.get("coordinates", []):
|
||||
digest = coordinate.get("evidence_digest")
|
||||
if isinstance(digest, str) and re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
coordinate["evidence_digest"] = f"sha256:{digest}"
|
||||
return preview
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
code = "DASHBOARD_MISMATCH" if "dashboard" in message.lower() and "differs" in message.lower() else "INVALID_REFERENCE_URL"
|
||||
raise HTTPException(status_code=422, detail={"code": code, "message": message}) from exc
|
||||
# #endregion Api.DashboardTesting.ReferenceDiscovery.Post
|
||||
# #endregion Api.DashboardTesting.ReferenceDiscovery
|
||||
@@ -0,0 +1,92 @@
|
||||
# #region Api.DashboardTesting.ReferenceSelection [C:5] [TYPE Module] [SEMANTICS baseline,reference,selection,capture,api]
|
||||
# @defgroup Api Capture selected reference metrics into unapproved draft candidates.
|
||||
# @INVARIANT Request cannot supply dashboard, environment, filters, candidate value, SQL, release, or AgentRun identity.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.routes.dashboard_testing.reference_discovery import _resolve_origin
|
||||
from src.core.database import get_db
|
||||
from src.core.utils.client_registry import get_superset_client
|
||||
from src.dependencies import has_permission
|
||||
from src.models.auth import User
|
||||
from src.models.scenario_registry import ScenarioRegistryEntry
|
||||
from src.services.dashboard_testing.reference_selection import capture_reference_selection
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard-testing", tags=["Dashboard-Testing"])
|
||||
_READ = Depends(has_permission("dashboard:testing", "READ"))
|
||||
_EXECUTE = Depends(has_permission("dashboard:testing", "EXECUTE"))
|
||||
_WRITE = Depends(has_permission("dashboard:testing", "WRITE"))
|
||||
_DB = Depends(get_db)
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.ReferenceSelection.Coordinate [C:2] [TYPE Class]
|
||||
# @BRIEF One successful preview coordinate identified by chart, dataset and metric.
|
||||
class SelectedReferenceCoordinate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
chart_id: int = Field(gt=0)
|
||||
dataset_id: int = Field(gt=0)
|
||||
metric: str = Field(min_length=1, max_length=256)
|
||||
evidence_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
value_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
execution_status: Literal["executed"]
|
||||
# #endregion Api.DashboardTesting.ReferenceSelection.Coordinate
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.ReferenceSelection.Request [C:2] [TYPE Class]
|
||||
# @BRIEF Client supplies only preview identity and selected coordinate evidence.
|
||||
class ReferenceSelectionCaptureRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scenario_id: str = Field(min_length=1)
|
||||
url: str = Field(min_length=1, max_length=8192)
|
||||
selected: list[SelectedReferenceCoordinate] = Field(min_length=1, max_length=20)
|
||||
expected_query_model_fingerprint: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
expected_filters_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
# #endregion Api.DashboardTesting.ReferenceSelection.Request
|
||||
|
||||
|
||||
# #region Api.DashboardTesting.ReferenceSelection.Post [C:5] [TYPE Function]
|
||||
# @BRIEF Revalidate preview and create draft candidates for the selected scenario metrics.
|
||||
# @PRE Caller has READ, EXECUTE and WRITE; scenario dashboard is persisted in the registry.
|
||||
# @POST Returns IDs only; no auto-approval, catalog update, or publication.
|
||||
@router.post("/reference-selection/capture", status_code=201)
|
||||
async def capture_reference_selection_endpoint(
|
||||
body: ReferenceSelectionCaptureRequest,
|
||||
_reader: User = _READ,
|
||||
_executor: User = _EXECUTE,
|
||||
_writer: User = _WRITE,
|
||||
db: Session = _DB,
|
||||
) -> dict:
|
||||
scenario = db.get(ScenarioRegistryEntry, body.scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail={"code": "SCENARIO_NOT_FOUND", "message": "Scenario not found"})
|
||||
env = _resolve_origin(body.url)
|
||||
if env.id not in (scenario.environment_ids or []):
|
||||
raise HTTPException(status_code=422, detail={"code": "ENVIRONMENT_MISMATCH", "message": "Reference URL environment differs from scenario environments"})
|
||||
try:
|
||||
client = await get_superset_client(env)
|
||||
result = await capture_reference_selection(
|
||||
db, _writer.id, client, env.id, scenario.dashboard_id, body.url,
|
||||
body.selected, body.expected_query_model_fingerprint, body.expected_filters_hash,
|
||||
)
|
||||
db.commit()
|
||||
return result
|
||||
except LookupError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail={"code": "RELEASE_PREREQUISITE_MISSING", "message": str(exc)}) from exc
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
message = str(exc)
|
||||
code = "DASHBOARD_MISMATCH" if "dashboard" in message.lower() and "differs" in message.lower() else "REFERENCE_DRIFT"
|
||||
raise HTTPException(status_code=422, detail={"code": code, "message": message}) from exc
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
# #endregion Api.DashboardTesting.ReferenceSelection.Post
|
||||
# #endregion Api.DashboardTesting.ReferenceSelection
|
||||
@@ -65,14 +65,27 @@ class SupersetChartDataMixin:
|
||||
@INVARIANT Hash computed from raw bytes before any extraction/canonicalization.
|
||||
"""
|
||||
with belief_scope("SupersetClient.execute_chart_data_raw", f"chart={chart_id}"):
|
||||
metric_specs: list[dict] = []
|
||||
metric_specs: list[str | dict] = []
|
||||
for m in (metrics or []):
|
||||
if isinstance(m, str):
|
||||
metric_specs.append({"label": m})
|
||||
metric_specs.append(m)
|
||||
elif isinstance(m, dict):
|
||||
metric_specs.append(m)
|
||||
|
||||
adhoc_filters = self._build_adhoc_filters(filters)
|
||||
adhoc_filters = self._build_adhoc_filters(
|
||||
[f for f in (filters or []) if f.get("operator") not in
|
||||
("TEMPORAL_RANGE", "TIME_COLUMN", "TIME_GRAIN")]
|
||||
)
|
||||
time_ranges = [f for f in (filters or []) if f.get("operator") == "TEMPORAL_RANGE"]
|
||||
time_columns = [f for f in (filters or []) if f.get("operator") == "TIME_COLUMN"]
|
||||
time_grains = [f for f in (filters or []) if f.get("operator") == "TIME_GRAIN"]
|
||||
if len(time_ranges) > 1:
|
||||
raise ValueError("Multiple time ranges cannot be applied to one chart")
|
||||
if len(time_columns) > 1 or len(time_grains) > 1:
|
||||
raise ValueError("Conflicting time column or grain filters for one chart")
|
||||
time_range = None
|
||||
if time_ranges:
|
||||
time_range = f"{time_ranges[0].get('from', '')} : {time_ranges[0].get('to', '')}"
|
||||
|
||||
query_context = {
|
||||
"datasource": {
|
||||
@@ -80,10 +93,12 @@ class SupersetChartDataMixin:
|
||||
"type": datasource_type,
|
||||
},
|
||||
"queries": [{
|
||||
"datasource": {"id": datasource_id, "type": datasource_type},
|
||||
"metrics": metric_specs,
|
||||
"groupby": groupby or [],
|
||||
"filters": adhoc_filters,
|
||||
**({"time_range": time_range} if time_range else {}),
|
||||
**({"granularity": time_columns[0]["comparator"]} if time_columns else {}),
|
||||
**({"time_grain": time_grains[0]["comparator"]} if time_grains else {}),
|
||||
"row_limit": min(row_limit, 10000),
|
||||
}],
|
||||
"force": False,
|
||||
@@ -154,42 +169,25 @@ class SupersetChartDataMixin:
|
||||
|
||||
# #region SupersetClient.ChartData.BuildAdhocFilters [C:2] [TYPE Function] [SEMANTICS superse,chart-data,filters]
|
||||
# @ingroup Core
|
||||
# @BRIEF Normalize filter dicts into Superset adhoc filter format, preserving TEMPORAL_RANGE.
|
||||
# @PRE Input filters use subject/comparator/expressionType keys.
|
||||
# @POST Returns list of adhoc filter dicts; empty list if filters is None/empty.
|
||||
# @REJECTED Remapping to column/value format was rejected — it silently drops TEMPORAL_RANGE.
|
||||
# @BRIEF Convert guarded filter clauses to Superset ChartDataFilterSchema fields.
|
||||
# @PRE Input filters use subject/comparator/operator; temporal range is carried in queries.time_range.
|
||||
# @POST Returns col/op/val objects accepted by POST /chart/data.
|
||||
# @RATIONALE Superset's ChartDataFilterSchema requires col/op/val and rejects adhoc expression fields in queries.filters.
|
||||
@staticmethod
|
||||
def _build_adhoc_filters(filters: list[dict] | None) -> list[dict]:
|
||||
"""Build normalized adhoc filter specs from input filter list.
|
||||
|
||||
Filters arrive in Superset adhoc format (subject/comparator). Do NOT remap
|
||||
to column/value — that silently drops TEMPORAL_RANGE and all filters.
|
||||
"""
|
||||
"""Build validated chart-data query filters from guarded clauses."""
|
||||
adhoc_filters: list[dict] = []
|
||||
for f in (filters or []):
|
||||
clause = f.get("clause", "WHERE")
|
||||
subject = f.get("subject", "")
|
||||
comparator = f.get("comparator")
|
||||
operator = f.get("operator", "==")
|
||||
expression_type = f.get("expressionType", "SIMPLE")
|
||||
|
||||
if expression_type == "SIMPLE" and subject:
|
||||
entry: dict[str, object] = {
|
||||
"clause": clause,
|
||||
"comparator": comparator,
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": operator,
|
||||
"subject": subject,
|
||||
}
|
||||
# Preserve temporal range from/to boundary
|
||||
if operator == "TEMPORAL_RANGE":
|
||||
from_val = f.get("from", "")
|
||||
to_val = f.get("to", "")
|
||||
if from_val:
|
||||
entry["from"] = from_val
|
||||
if to_val:
|
||||
entry["to"] = to_val
|
||||
adhoc_filters.append(entry)
|
||||
if expression_type != "SIMPLE" or not isinstance(subject, str) or not subject:
|
||||
raise ValueError("Unsupported chart-data filter clause")
|
||||
if operator == "TEMPORAL_RANGE":
|
||||
raise ValueError("Temporal range must use chart-data time_range")
|
||||
adhoc_filters.append({"col": subject, "op": operator, "val": comparator})
|
||||
return adhoc_filters
|
||||
# #endregion SupersetClient.ChartData.BuildAdhocFilters
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class SupersetDashboardsFiltersMixin:
|
||||
# @RELATION CALLS -> [Core.AsyncNetwork.AsyncAPIClient]
|
||||
async def get_dashboard_permalink_state(self, permalink_key: str) -> dict:
|
||||
with belief_scope(
|
||||
"SupersetClient.get_dashboard_permalink_state", f"key={permalink_key}"
|
||||
"SupersetClient.get_dashboard_permalink_state", "key=[redacted]"
|
||||
):
|
||||
response = await self.client.request(
|
||||
method="GET", endpoint=f"/dashboard/permalink/{permalink_key}"
|
||||
@@ -52,7 +52,7 @@ class SupersetDashboardsFiltersMixin:
|
||||
) -> dict:
|
||||
with belief_scope(
|
||||
"SupersetClient.get_native_filter_state",
|
||||
f"dashboard={dashboard_id}, key={filter_state_key}",
|
||||
f"dashboard={dashboard_id}, key=[redacted]",
|
||||
):
|
||||
response = await self.client.request(
|
||||
method="GET",
|
||||
|
||||
@@ -203,10 +203,11 @@ def register_ops_tools(server: Any, access_token_context: ContextVar) -> None:
|
||||
|
||||
# Registration seams: checkpoint and baseline tool bodies live in tools_review.py;
|
||||
# call order preserves the original registration sequence (tools/list ordering frozen).
|
||||
from src.mcp_server.tools_review import register_baseline_tools, register_checkpoint_tools
|
||||
from src.mcp_server.tools_review import register_baseline_tools, register_checkpoint_tools, register_reference_url_tools
|
||||
|
||||
register_checkpoint_tools(server, access_token_context)
|
||||
register_baseline_tools(server, access_token_context)
|
||||
register_reference_url_tools(server, access_token_context)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ def _scenario_start_permission(arguments: dict[str, Any]) -> tuple[str, str]:
|
||||
# tool listed with deprecated=True for one minor cycle; additive changes bump MINOR.
|
||||
# The discipline is pinned executable by tests/test_mcp_catalog_version.py (the pinned
|
||||
# major in that test is the deliberate-bump ritual — it cannot change by accident).
|
||||
MCP_CATALOG_VERSION = "2.4.0"
|
||||
MCP_CATALOG_VERSION = "2.5.0"
|
||||
# #endregion McpServer.CatalogVersion
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ class McpToolDefinition:
|
||||
requires_approval: bool = False
|
||||
deprecated: bool = False
|
||||
deprecation_note: str | None = None
|
||||
additional_permissions: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
_MCP_CATALOG = (
|
||||
@@ -139,6 +140,10 @@ _MCP_CATALOG = (
|
||||
McpToolDefinition("superset_copy_dashboard", ("plugin:superset_proxy", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
|
||||
McpToolDefinition("superset_create_dataset", ("plugin:superset_proxy", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
|
||||
McpToolDefinition("capture_baseline_candidate", ("dashboard:testing", "WRITE"), service_allowed=False, risk_level="guarded"),
|
||||
McpToolDefinition("preview_reference_dashboard", ("dashboard:testing", "READ"), service_allowed=False,
|
||||
additional_permissions=(("dashboard:testing", "EXECUTE"),)),
|
||||
McpToolDefinition("capture_reference_selection", ("dashboard:testing", "READ"), service_allowed=False,
|
||||
risk_level="guarded", additional_permissions=(("dashboard:testing", "EXECUTE"), ("dashboard:testing", "WRITE"))),
|
||||
McpToolDefinition("request_baseline_approval", ("dashboard:testing", "APPROVE"), service_allowed=False, risk_level="guarded"),
|
||||
McpToolDefinition("decide_baseline_approval", ("dashboard:testing", "APPROVE"), service_allowed=False, risk_level="guarded"),
|
||||
McpToolDefinition("consume_baseline_approval", ("dashboard:testing", "APPROVE"), service_allowed=False, risk_level="guarded", requires_approval=True),
|
||||
@@ -259,7 +264,7 @@ class RbacFastMCP(FastMCP):
|
||||
# tools/list has no arguments; expose the tool if either server-owned
|
||||
# scenario permission can authorize a later typed call.
|
||||
return self._has_permission(("scenario", "RUN")) or self._has_permission(("scenario", "RUN_PROD"))
|
||||
return self._has_permission(definition.permission)
|
||||
return all(self._has_permission(permission) for permission in (definition.permission, *definition.additional_permissions))
|
||||
|
||||
def _oversized(self, result: Any) -> dict[str, Any] | None:
|
||||
"""Fail closed when a structured tool reply exceeds the server response limit."""
|
||||
|
||||
@@ -17,8 +17,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.api.routes.dashboard_testing.reference_discovery import ReferenceDiscoveryRequest, reference_discovery
|
||||
from src.api.routes.dashboard_testing.reference_selection import ReferenceSelectionCaptureRequest, capture_reference_selection_endpoint
|
||||
from src.core.auth.permission_utils import user_has_permission
|
||||
from src.core.auth.repository import AuthRepository
|
||||
from src.core.database import SessionLocal
|
||||
from src.core.logger import logger
|
||||
from src.core.utils.client_registry import get_superset_client
|
||||
@@ -257,4 +264,58 @@ def register_baseline_tools(server: Any, access_token_context: ContextVar) -> No
|
||||
# #endregion McpServer.OperationsTools.BaselineTools
|
||||
# #endregion McpServer.ToolsReview.RegisterBaseline
|
||||
|
||||
|
||||
# #region McpServer.ToolsReview.ReferenceUrl [C:5] [TYPE Function] [SEMANTICS mcp,reference,url,preview,capture]
|
||||
# @BRIEF Expose scenario-bound reference URL preview and selection through the REST handlers.
|
||||
# @PRE A live human principal has READ+EXECUTE for preview, plus WRITE for capture.
|
||||
# @POST Origin, scenario, query-model and evidence checks are identical to REST.
|
||||
# @RATIONALE Reusing the route handlers keeps the configured-origin and persisted-scenario guards
|
||||
# in one place while the MCP catalog and body both enforce the composite grants.
|
||||
# @REJECTED Calling the discovery/capture services with client-supplied environment or dashboard
|
||||
# was rejected because it would bypass the REST scenario and origin binding.
|
||||
def register_reference_url_tools(server: Any, access_token_context: ContextVar) -> None:
|
||||
def authorized_user(*actions: str) -> Any | None:
|
||||
access = access_token_context.get()
|
||||
if access is None or not access.subject or (access.claims and access.claims.get("principal_type") == "service"):
|
||||
return None
|
||||
with SessionLocal() as db:
|
||||
user = AuthRepository(db).get_user_by_username(access.subject)
|
||||
if user is None or not user.is_active or not all(
|
||||
user_has_permission(user, "dashboard:testing", action) for action in actions
|
||||
):
|
||||
return None
|
||||
user_id = user.id
|
||||
return SimpleNamespace(id=user_id)
|
||||
|
||||
def rejected(exc: HTTPException) -> dict[str, Any]:
|
||||
detail = exc.detail if isinstance(exc.detail, dict) else {"code": "REFERENCE_ERROR", "message": str(exc.detail)}
|
||||
return {"status": "rejected", "error": detail["code"], "message": detail["message"]}
|
||||
|
||||
@server.tool(name="preview_reference_dashboard", structured_output=True)
|
||||
async def preview_reference_dashboard_tool(request: ReferenceDiscoveryRequest) -> dict[str, Any]:
|
||||
"""Preview a scenario dashboard from an absolute configured Superset reference URL."""
|
||||
user = authorized_user("READ", "EXECUTE")
|
||||
if user is None:
|
||||
return {"status": "permission_denied", "error": "permission_denied"}
|
||||
with SessionLocal() as db:
|
||||
try:
|
||||
return await reference_discovery(request, _reader=user, _executor=user, db=db)
|
||||
except HTTPException as exc:
|
||||
return rejected(exc)
|
||||
|
||||
@server.tool(name="capture_reference_selection", structured_output=True)
|
||||
async def capture_reference_selection_tool(request: ReferenceSelectionCaptureRequest) -> dict[str, Any]:
|
||||
"""Capture selected preview evidence as unapproved candidates for the same scenario URL."""
|
||||
user = authorized_user("READ", "EXECUTE", "WRITE")
|
||||
if user is None:
|
||||
return {"status": "permission_denied", "error": "permission_denied"}
|
||||
with SessionLocal() as db:
|
||||
try:
|
||||
return await capture_reference_selection_endpoint(
|
||||
request, _reader=user, _executor=user, _writer=user, db=db,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
return rejected(exc)
|
||||
# #endregion McpServer.ToolsReview.ReferenceUrl
|
||||
|
||||
# #endregion McpServer.ToolsReview
|
||||
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
||||
from .common import Provenance
|
||||
from .filters import NormalizedFilterContext
|
||||
from .results import ComparisonPolicy, NormalizedValue
|
||||
from .catalog import BaselineEntry
|
||||
|
||||
_SEMVER_RE = re.compile(r"^v\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$")
|
||||
_COMMIT_HASH_RE = re.compile(r"^[a-f0-9]{40}$")
|
||||
@@ -43,7 +44,8 @@ class CandidateRequest(BaseModel):
|
||||
None,
|
||||
description="Optional reference to a server-issued capture execution DraftArtifact. "
|
||||
"For kind=metric, this is REQUIRED — the source_response_hash must match "
|
||||
"the capture artifact's sha256. For kind=visual, this is ignored.",
|
||||
"the capture artifact's sha256. For kind=visual, the server binds "
|
||||
"the expected screenshot artifact during candidate creation.",
|
||||
)
|
||||
# Feature-037: visual candidate fields
|
||||
kind: Literal["metric", "visual", "scenario_transform"] = "metric"
|
||||
@@ -84,6 +86,8 @@ class CandidateRequest(BaseModel):
|
||||
None, description="ApprovalInfo dict (required for kind=visual; uses existing approval info)"
|
||||
)
|
||||
|
||||
# #region DashboardTesting.Schemas.CandidateRequest.ValidateKind [C:2] [TYPE Function]
|
||||
# @BRIEF Dispatch kind-specific validation before accepting candidate input.
|
||||
@model_validator(mode="after")
|
||||
def _validate_kind_requirements(self) -> CandidateRequest:
|
||||
"""Cross-validate kind-specific required fields."""
|
||||
@@ -94,6 +98,7 @@ class CandidateRequest(BaseModel):
|
||||
else:
|
||||
self._validate_metric_requirements()
|
||||
return self
|
||||
# #endregion DashboardTesting.Schemas.CandidateRequest.ValidateKind
|
||||
|
||||
# #region DashboardTesting.Schemas.CandidateRequest.ValidateVisual [C:2] [TYPE Function] [SEMANTICS baseline,visual,validation]
|
||||
# @BRIEF Validate the visual-only candidate fields and visual comparison policy.
|
||||
@@ -199,6 +204,49 @@ class BaselineCandidate(BaseModel):
|
||||
# #endregion DashboardTesting.Schemas.BaselineCandidate
|
||||
|
||||
|
||||
# #region DashboardTesting.Schemas.CandidateReview [C:2] [TYPE Class] [SEMANTICS baseline,candidate,review]
|
||||
# @BRIEF Owner-scoped candidate projection with durable gate and authoritative release pin.
|
||||
class CandidateReview(BaseModel):
|
||||
candidate_id: str
|
||||
agent_run_id: str
|
||||
status: str
|
||||
request: CandidateRequest
|
||||
gate_id: str | None = None
|
||||
gate_status: str | None = None
|
||||
gate_reason: str | None = None
|
||||
release_version: str | None = None
|
||||
release_commit_hash: str | None = None
|
||||
environment_name: str | None = None
|
||||
catalog_status: Literal["missing", "approved", "stale"] = "missing"
|
||||
current_baseline: BaselineEntry | None = None
|
||||
created_at: datetime
|
||||
# #endregion DashboardTesting.Schemas.CandidateReview
|
||||
|
||||
|
||||
# #region DashboardTesting.Schemas.BaselineOverviewEntry [C:2] [TYPE Class] [SEMANTICS baseline,catalog,analyst,overview]
|
||||
# @BRIEF One approved value projected with its current or stale release state.
|
||||
class BaselineOverviewEntry(BaseModel):
|
||||
label: str
|
||||
result_key: str
|
||||
expected: NormalizedValue
|
||||
comparison_policy: ComparisonPolicy
|
||||
release_version: str
|
||||
state: Literal["current", "stale"]
|
||||
# #endregion DashboardTesting.Schemas.BaselineOverviewEntry
|
||||
|
||||
|
||||
# #region DashboardTesting.Schemas.BaselineOverview [C:2] [TYPE Class] [SEMANTICS baseline,catalog,analyst,overview]
|
||||
# @BRIEF Release-bound catalog summary available even when no candidate has been captured.
|
||||
# @INVARIANT A missing catalog and an absent approved release are distinct states.
|
||||
class BaselineOverview(BaseModel):
|
||||
state: Literal["no_release", "missing", "ready", "stale"]
|
||||
release_version: str | None = None
|
||||
environment_name: str | None = None
|
||||
entries: list[BaselineOverviewEntry] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
# #endregion DashboardTesting.Schemas.BaselineOverview
|
||||
|
||||
|
||||
# #region DashboardTesting.Schemas.ApprovalGateRequest [C:2] [TYPE Class] [SEMANTICS baseline,api,approval-gate]
|
||||
# @BRIEF Request to open an approval gate for a baseline candidate.
|
||||
# @INVARIANT required_permission is NOT client-controlled — server sets it from candidate context.
|
||||
@@ -252,6 +300,8 @@ class ApprovalGateRequest(BaseModel):
|
||||
"When omitted, the period is preserved as open (no immutability enforcement).",
|
||||
)
|
||||
|
||||
# #region DashboardTesting.Schemas.ApprovalGateRequest.ValidateSemver [C:2] [TYPE Function]
|
||||
# @BRIEF Accept only the release version format bound into the approval request hash.
|
||||
@field_validator("release_version")
|
||||
@classmethod
|
||||
def _validate_semver(cls, v: str) -> str:
|
||||
@@ -260,6 +310,7 @@ class ApprovalGateRequest(BaseModel):
|
||||
f"release_version must be v-prefixed SemVer (e.g. 'v1.0.0' or 'v2.0.0-rc1'), got {v!r}"
|
||||
)
|
||||
return v
|
||||
# #endregion DashboardTesting.Schemas.ApprovalGateRequest.ValidateSemver
|
||||
# #endregion DashboardTesting.Schemas.ApprovalGateRequest
|
||||
|
||||
|
||||
@@ -313,4 +364,3 @@ class ApprovalConsumeResponse(BaseModel):
|
||||
# #endregion DashboardTesting.Schemas.ApprovalConsumeResponse
|
||||
|
||||
# #endregion DashboardTesting.Schemas.Candidates
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class CaptureCandidateRequest(BaseModel):
|
||||
result_key: str = Field(description="Metric or result identifier to extract")
|
||||
label: str = Field(description="Human-readable label for the baseline candidate")
|
||||
normalized_filters: NormalizedFilterContext
|
||||
query_model_fingerprint: str | None = None
|
||||
comparison_policy: ComparisonPolicy
|
||||
kind: Literal["metric"] = "metric"
|
||||
max_rows: int = Field(default=10000, le=10000, description="Bounded result limit")
|
||||
|
||||
@@ -33,6 +33,7 @@ class MetricDescriptor(BaseModel):
|
||||
column: ColumnRef | None = None
|
||||
aggregate: str | None = None
|
||||
sql_expression: str | None = None
|
||||
chart_spec: dict | None = None
|
||||
# #endregion DashboardTesting.Schemas.MetricDescriptor
|
||||
|
||||
|
||||
|
||||
@@ -154,6 +154,8 @@ class ScenarioEditorLoadResponse(BaseModel):
|
||||
graph: dict
|
||||
dirty: bool = False
|
||||
validation: dict = Field(default_factory=dict)
|
||||
dashboard_id: int | None = None
|
||||
environments: list[dict] = Field(default_factory=list)
|
||||
# #endregion ScenarioEditor.Schemas.LoadResponse
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +71,24 @@ def validate_catalog(raw: bytes) -> dict[str, Any]:
|
||||
raise PublishError("PUBLISH_CATALOG_INVALID", f"top-level identity missing keys: {', '.join(missing)}")
|
||||
if not isinstance(payload.get("catalog_revision"), dict):
|
||||
raise PublishError("PUBLISH_CATALOG_INVALID", "published envelope is missing a catalog_revision object")
|
||||
from src.services.dashboard_testing.reference_source import require_reference_source
|
||||
|
||||
revisions = payload["catalog_revision"].get("entry_revisions")
|
||||
if not isinstance(revisions, list):
|
||||
raise PublishError("PUBLISH_CATALOG_INVALID", "catalog_revision has no entry_revisions")
|
||||
for item in revisions:
|
||||
if not isinstance(item, dict) or item.get("status") != "approved":
|
||||
continue
|
||||
entry = item.get("entry") if isinstance(item.get("entry"), dict) else {}
|
||||
try:
|
||||
require_reference_source(
|
||||
item.get("reference_source"),
|
||||
environment_id=(entry.get("provenance") or {}).get("environment"),
|
||||
dashboard_id=entry.get("dashboard_id"),
|
||||
filters_hash=(entry.get("normalized_filters") or {}).get("filters_hash"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise PublishError("PUBLISH_CATALOG_INVALID", "approved entry has no valid resolved reference URL") from exc
|
||||
return payload
|
||||
# #endregion Tooling.PublishCatalog.Validate
|
||||
|
||||
|
||||
158
backend/src/scripts/seed_baseline_review_e2e.py
Normal file
158
backend/src/scripts/seed_baseline_review_e2e.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# #region Scripts.SeedBaselineReviewE2E [C:4] [TYPE Module] [SEMANTICS e2e,baseline,scenario,approval]
|
||||
# @BRIEF Reset a release-bound scenario and two captured candidates in an isolated E2E database.
|
||||
# @PRE Run only in the dedicated E2E Compose project after backend migrations/admin bootstrap.
|
||||
# @POST The editor exposes two owned draft candidates and an initially absent catalog.
|
||||
# @INVARIANT Candidate release, dashboard and source hashes agree with server-owned rows.
|
||||
# @RATIONALE Fixed IDs and a fixed catalog path make approve/reject browser runs repeatable.
|
||||
# @REJECTED Ambient scenarios/candidates were rejected because their owner, gate state and
|
||||
# catalog revision vary between browser runs.
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from src.core.database import SessionLocal
|
||||
from src.models.agent_run import AgentRun, DraftArtifact
|
||||
from src.models.auth import User
|
||||
from src.models.dashboard_release import DashboardRelease
|
||||
from src.models.deployment import DeploymentRecord
|
||||
from src.models.git import DeploymentEnvironment, GitRepository, GitServerConfig
|
||||
from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision
|
||||
from src.schemas.dashboard_testing import CandidateRequest
|
||||
from src.services.agent_runs.artifacts import DraftStorage
|
||||
from src.services.dashboard_testing.safe_path import assert_canonical_safe_path, build_catalog_relative_path
|
||||
|
||||
|
||||
SCENARIO_ID = "0b45e100-0000-4000-8000-000000000001"
|
||||
REVISION_ID = "0b45e100-0000-4000-8000-000000000002"
|
||||
SERVER_ID = "0b45e100-0000-4000-8000-000000000003"
|
||||
REPOSITORY_ID = "0b45e100-0000-4000-8000-000000000004"
|
||||
ENVIRONMENT_ID = "0b45e100-0000-4000-8000-000000000005"
|
||||
RELEASE_ID = "0b45e100-0000-4000-8000-000000000006"
|
||||
APPROVE_RUN_ID = "0b45e100-0000-4000-8000-000000000007"
|
||||
REJECT_RUN_ID = "0b45e100-0000-4000-8000-000000000008"
|
||||
STALE_RUN_ID = "0b45e100-0000-4000-8000-000000000013"
|
||||
APPROVE_CAPTURE_ID = "0b45e100-0000-4000-8000-000000000009"
|
||||
REJECT_CAPTURE_ID = "0b45e100-0000-4000-8000-000000000010"
|
||||
STALE_CAPTURE_ID = "0b45e100-0000-4000-8000-000000000014"
|
||||
APPROVE_CANDIDATE_ID = "0b45e100-0000-4000-8000-000000000011"
|
||||
REJECT_CANDIDATE_ID = "0b45e100-0000-4000-8000-000000000012"
|
||||
STALE_CANDIDATE_ID = "0b45e100-0000-4000-8000-000000000015"
|
||||
DASHBOARD_ID = 47002
|
||||
COMMIT = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b"
|
||||
REPO_KEY = "baseline_review_e2e"
|
||||
DASH_KEY = f"dash_{RELEASE_ID[:8]}"
|
||||
|
||||
|
||||
def _candidate_payload(run_id: str, capture_id: str, result_key: str, value: int) -> dict:
|
||||
source_hash = sha256(f'{{"{result_key}":{value}}}'.encode()).hexdigest()
|
||||
return CandidateRequest.model_validate({
|
||||
"environment_id": ENVIRONMENT_ID,
|
||||
"dashboard_id": DASHBOARD_ID,
|
||||
"repository_key": REPO_KEY,
|
||||
"dashboard_key": DASH_KEY,
|
||||
"chart_id": 1,
|
||||
"result_key": result_key,
|
||||
"label": f"E2E {result_key}",
|
||||
"normalized_filters": {"filters": [], "filters_hash": "sha256:" + "a" * 64},
|
||||
"candidate_value": {"kind": "integer", "raw_value": value, "canonical_value": str(value), "display_value": str(value)},
|
||||
"source_response_hash": source_hash,
|
||||
"comparison_policy": {"type": "exact"},
|
||||
"provenance": {"environment": ENVIRONMENT_ID, "actor": "e2e capture", "agent_run_id": run_id},
|
||||
"agent_run_id": run_id,
|
||||
"capture_artifact_ref": capture_id,
|
||||
}).model_dump(mode="json")
|
||||
|
||||
|
||||
def seed() -> None:
|
||||
if os.getenv("BASELINE_REVIEW_E2E") != "1":
|
||||
raise RuntimeError("Set BASELINE_REVIEW_E2E=1 for the isolated E2E seed")
|
||||
with SessionLocal.begin() as db:
|
||||
admin = db.query(User).filter(User.username == "admin").one()
|
||||
for run_id in (APPROVE_RUN_ID, REJECT_RUN_ID, STALE_RUN_ID):
|
||||
run = db.get(AgentRun, run_id)
|
||||
if run is not None:
|
||||
db.delete(run)
|
||||
db.flush()
|
||||
|
||||
server = db.get(GitServerConfig, SERVER_ID)
|
||||
if server is None:
|
||||
db.add(GitServerConfig(id=SERVER_ID, name="E2E baseline review", provider="GITHUB",
|
||||
url="https://example.test", pat="e2e-only"))
|
||||
db.flush()
|
||||
repo = db.get(GitRepository, REPOSITORY_ID)
|
||||
if repo is None:
|
||||
db.add(GitRepository(id=REPOSITORY_ID, dashboard_id=DASHBOARD_ID, config_id=SERVER_ID,
|
||||
remote_url="https://example.test/baseline-review.git", local_path=REPO_KEY))
|
||||
db.flush()
|
||||
env = db.get(DeploymentEnvironment, ENVIRONMENT_ID)
|
||||
if env is None:
|
||||
db.add(DeploymentEnvironment(id=ENVIRONMENT_ID, name="E2E baseline review",
|
||||
superset_url="https://example.test", superset_token="e2e-only"))
|
||||
db.flush()
|
||||
release = db.get(DashboardRelease, RELEASE_ID)
|
||||
if release is None:
|
||||
deployment = DeploymentRecord(repository_id=REPOSITORY_ID, environment_id=ENVIRONMENT_ID,
|
||||
commit_hash=COMMIT, content_hash="a" * 64, status="success")
|
||||
db.add(deployment)
|
||||
db.flush()
|
||||
db.add(DashboardRelease(id=RELEASE_ID, repository_id=REPOSITORY_ID, deployment_id=deployment.id,
|
||||
name="E2E baseline review", version="v1.0.0", notes="isolated fixture",
|
||||
commit_hash=COMMIT, content_hash="a" * 64, status="approved",
|
||||
created_by=admin.id))
|
||||
db.flush()
|
||||
|
||||
entry = db.get(ScenarioRegistryEntry, SCENARIO_ID)
|
||||
if entry is None:
|
||||
entry = ScenarioRegistryEntry(scenario_id=SCENARIO_ID, scenario_key="e2e-baseline-review",
|
||||
name="E2E baseline review", dashboard_id=DASHBOARD_ID,
|
||||
environment_ids=[ENVIRONMENT_ID], owner_id=admin.id,
|
||||
owner_username=admin.username)
|
||||
db.add(entry)
|
||||
entry.validation_status = "valid"
|
||||
db.flush()
|
||||
if db.get(ScenarioRevision, REVISION_ID) is None:
|
||||
db.add(ScenarioRevision(revision_id=REVISION_ID, scenario_id=SCENARIO_ID,
|
||||
content_hash="b" * 64, graph_snapshot={"steps": [], "assertions": {}},
|
||||
created_by=admin.id, activation_status="current"))
|
||||
db.flush()
|
||||
entry.current_revision_id = REVISION_ID
|
||||
db.flush()
|
||||
|
||||
for run_id, capture_id, candidate_id, key, value, label in (
|
||||
(APPROVE_RUN_ID, APPROVE_CAPTURE_ID, APPROVE_CANDIDATE_ID, "approved_total", 125, "E2E approve baseline"),
|
||||
(REJECT_RUN_ID, REJECT_CAPTURE_ID, REJECT_CANDIDATE_ID, "rejected_total", 77, "E2E reject baseline"),
|
||||
(STALE_RUN_ID, STALE_CAPTURE_ID, STALE_CANDIDATE_ID, "approved_total", 130, "E2E stale candidate"),
|
||||
):
|
||||
db.add(AgentRun(id=run_id, user_id=admin.id, dashboard_id=str(DASHBOARD_ID),
|
||||
environment_id=ENVIRONMENT_ID, context_snapshot={}, status="CREATED"))
|
||||
db.flush()
|
||||
meta = _candidate_payload(run_id, capture_id, key, value)
|
||||
meta["label"] = label
|
||||
raw_bytes = f'{{"{key}":{value}}}'.encode()
|
||||
content_ref = DraftStorage().store(run_id, meta["source_response_hash"], raw_bytes)
|
||||
db.add(DraftArtifact(id=capture_id, run_id=run_id, kind="capture_execution", name=f"capture:{key}",
|
||||
intended_path="", content_ref=content_ref,
|
||||
sha256=meta["source_response_hash"], validation_status="valid",
|
||||
capture_meta={"release_id": RELEASE_ID, "environment_id": ENVIRONMENT_ID,
|
||||
"dashboard_id": DASHBOARD_ID, "repo_key": REPO_KEY,
|
||||
"dash_key": DASH_KEY, "chart_id": 1, "dataset_id": None,
|
||||
"result_key": key, "source_response_hash": meta["source_response_hash"],
|
||||
"normalized_value": meta["candidate_value"]}))
|
||||
db.flush()
|
||||
db.add(DraftArtifact(id=candidate_id, run_id=run_id, kind="baseline_candidate",
|
||||
name=meta["label"], intended_path=build_catalog_relative_path(REPO_KEY, DASH_KEY),
|
||||
content_ref=f"draft:{run_id}:{meta['source_response_hash'][:8]}",
|
||||
sha256=meta["source_response_hash"], validation_status="pending",
|
||||
capture_meta={**meta, "release_id": RELEASE_ID, "candidate_status": "draft"}))
|
||||
db.flush()
|
||||
|
||||
catalog_path = Path(assert_canonical_safe_path(REPO_KEY, DASH_KEY))
|
||||
catalog_path.unlink(missing_ok=True)
|
||||
print(f"seeded baseline review: {SCENARIO_ID}; catalog reset at {catalog_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
# #endregion Scripts.SeedBaselineReviewE2E
|
||||
90
backend/src/scripts/seed_reference_url_e2e.py
Normal file
90
backend/src/scripts/seed_reference_url_e2e.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# #region Scripts.SeedReferenceUrlE2E [C:4] [TYPE Module] [SEMANTICS e2e,reference,scenario,release]
|
||||
# @BRIEF Seed dashboard 10's scenario and an explicitly synthetic release in the isolated E2E database.
|
||||
# @PRE BASELINE_REFERENCE_E2E=1 and the dedicated E2E Compose database is migrated.
|
||||
# @POST Scenario identity and release gate are repeatable; no production database is touched.
|
||||
# @INVARIANT Only fixed E2E fixture IDs are deleted or created; Superset credentials are supplied at runtime through ignored .env.e2e.
|
||||
# @RATIONALE Candidate capture requires an approved release matching the scenario dashboard and environment; this row is labeled synthetic and test-only.
|
||||
# @REJECTED Using any ambient release was rejected because it would make capture provenance depend on unrelated database state.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from src.core.database import SessionLocal
|
||||
from src.models.auth import User
|
||||
from src.models.dashboard_release import DashboardRelease
|
||||
from src.models.deployment import DeploymentRecord
|
||||
from src.models.git import DeploymentEnvironment, GitRepository, GitServerConfig
|
||||
from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision
|
||||
|
||||
|
||||
SCENARIO_ID = "5a240e10-0000-4000-8000-000000000010"
|
||||
REVISION_ID = "5a240e10-0000-4000-8000-000000000011"
|
||||
SERVER_ID = "5a240e10-0000-4000-8000-000000000012"
|
||||
REPOSITORY_ID = "5a240e10-0000-4000-8000-000000000013"
|
||||
RELEASE_ID = "5a240e10-0000-4000-8000-000000000014"
|
||||
ENVIRONMENT_ID = "ss-dev"
|
||||
DASHBOARD_ID = 10
|
||||
COMMIT_HASH = "e2e0000000000000000000000000000000000010"
|
||||
CONTENT_HASH = "e" * 64
|
||||
|
||||
|
||||
# #region Scripts.SeedReferenceUrlE2E.Seed [C:4] [TYPE Function]
|
||||
# @BRIEF Create the dedicated reference scenario and synthetic approved release idempotently.
|
||||
def seed() -> None:
|
||||
if os.getenv("BASELINE_REFERENCE_E2E") != "1":
|
||||
raise RuntimeError("Set BASELINE_REFERENCE_E2E=1 for the isolated reference E2E seed")
|
||||
with SessionLocal.begin() as db:
|
||||
admin = db.query(User).filter(User.username == "admin").one()
|
||||
if db.get(GitServerConfig, SERVER_ID) is None:
|
||||
db.add(GitServerConfig(id=SERVER_ID, name="E2E synthetic reference fixture",
|
||||
provider="GITHUB", url="https://example.test", pat="test-only"))
|
||||
db.flush()
|
||||
if db.get(GitRepository, REPOSITORY_ID) is None:
|
||||
db.add(GitRepository(id=REPOSITORY_ID, dashboard_id=DASHBOARD_ID, config_id=SERVER_ID,
|
||||
remote_url="https://example.test/reference-e2e.git",
|
||||
local_path="reference_url_e2e"))
|
||||
db.flush()
|
||||
environment = db.get(DeploymentEnvironment, ENVIRONMENT_ID)
|
||||
if environment is None:
|
||||
db.add(DeploymentEnvironment(id=ENVIRONMENT_ID, name="ss-dev (E2E test-only release fixture)",
|
||||
superset_url="https://ss-dev.bebesh.ru", superset_token="test-only"))
|
||||
db.flush()
|
||||
release = db.get(DashboardRelease, RELEASE_ID)
|
||||
if release is None:
|
||||
deployment = DeploymentRecord(repository_id=REPOSITORY_ID, environment_id=ENVIRONMENT_ID,
|
||||
commit_hash=COMMIT_HASH, content_hash=CONTENT_HASH,
|
||||
status="success")
|
||||
db.add(deployment)
|
||||
db.flush()
|
||||
db.add(DashboardRelease(
|
||||
id=RELEASE_ID, repository_id=REPOSITORY_ID, deployment_id=deployment.id,
|
||||
name="E2E synthetic test-only release for dashboard 10", version="v1.0.0",
|
||||
notes="Synthetic fixture; this is not a production deployment or approval.",
|
||||
commit_hash=COMMIT_HASH, content_hash=CONTENT_HASH, status="approved",
|
||||
created_by=admin.id,
|
||||
))
|
||||
else:
|
||||
release.version = "v1.0.0"
|
||||
scenario = db.get(ScenarioRegistryEntry, SCENARIO_ID)
|
||||
if scenario is None:
|
||||
scenario = ScenarioRegistryEntry(
|
||||
scenario_id=SCENARIO_ID, scenario_key="e2e-reference-dashboard-10",
|
||||
name="E2E reference URL dashboard 10", dashboard_id=DASHBOARD_ID,
|
||||
environment_ids=[ENVIRONMENT_ID], owner_id=admin.id, owner_username=admin.username,
|
||||
)
|
||||
db.add(scenario)
|
||||
scenario.validation_status = "valid"
|
||||
if db.get(ScenarioRevision, REVISION_ID) is None:
|
||||
db.add(ScenarioRevision(
|
||||
revision_id=REVISION_ID, scenario_id=SCENARIO_ID, content_hash="f" * 64,
|
||||
graph_snapshot={"steps": [], "assertions": {}}, created_by=admin.id,
|
||||
activation_status="current",
|
||||
))
|
||||
scenario.current_revision_id = REVISION_ID
|
||||
print(f"seeded E2E scenario {SCENARIO_ID} with synthetic release {RELEASE_ID}")
|
||||
# #endregion Scripts.SeedReferenceUrlE2E.Seed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
# #endregion Scripts.SeedReferenceUrlE2E
|
||||
102
backend/src/scripts/seed_scenario_disposition_e2e.py
Normal file
102
backend/src/scripts/seed_scenario_disposition_e2e.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# #region Scripts.SeedScenarioDispositionE2E [C:3] [TYPE Module] [SEMANTICS e2e,scenario,seed,investigation]
|
||||
# @BRIEF Seed one deterministic failed run and its active evidence into the isolated E2E database.
|
||||
# @PRE Only the docker-compose.e2e.yml seed service invokes this script after migrations are ready.
|
||||
# @POST Exactly one fresh queued signal is available for each E2E stack start; reruns reset only
|
||||
# this script's fixed fixture identities and leave unrelated data untouched.
|
||||
# @INVARIANT The queue signal is produced through emit_terminal_run_signal, preserving its
|
||||
# frozen run/evidence provenance and the normal case/disposition contract.
|
||||
# @RATIONALE A fixed, owned fixture can be reset after an earlier browser run closes its case.
|
||||
# @REJECTED Selecting any ambient queued row was rejected because it can point at the wrong
|
||||
# scenario, lack verifiable evidence, or already be claimed by another test.
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from src.core.database import SessionLocal
|
||||
from src.models.scenario_artifact import ScenarioArtifact
|
||||
from src.models.scenario_investigation import AgentAction, InvestigationCase, InvestigationQueueItem
|
||||
from src.models.scenario_registry import ScenarioRegistryEntry
|
||||
from src.models.scenario_run import ScenarioRun
|
||||
from src.services.agent_runs.artifacts import DraftStorage
|
||||
from src.services.dashboard_testing.analytics.investigation import emit_terminal_run_signal
|
||||
|
||||
|
||||
SCENARIO_ID = "047e2e00-0000-4000-8000-000000000001"
|
||||
RUN_ID = "047e2e00-0000-4000-8000-000000000002"
|
||||
ARTIFACT_ID = "047e2e00-0000-4000-8000-000000000003"
|
||||
EVIDENCE_BYTES = b'{"result":"failed","source":"e2e"}\n'
|
||||
CONTENT_SHA256 = sha256(EVIDENCE_BYTES).hexdigest()
|
||||
SCENARIO_KEY = "e2e-scenario-disposition"
|
||||
|
||||
|
||||
def seed() -> None:
|
||||
content_ref = DraftStorage().store(RUN_ID, CONTENT_SHA256, EVIDENCE_BYTES)
|
||||
with SessionLocal.begin() as db:
|
||||
fingerprints = db.query(InvestigationQueueItem.fingerprint).filter(
|
||||
InvestigationQueueItem.scenario_id == SCENARIO_ID,
|
||||
).all()
|
||||
for (fingerprint,) in fingerprints:
|
||||
case_ids = select(InvestigationCase.id).where(
|
||||
InvestigationCase.fingerprint == fingerprint,
|
||||
InvestigationCase.scenario_id == SCENARIO_ID,
|
||||
)
|
||||
db.execute(delete(AgentAction).where(AgentAction.case_id.in_(case_ids)))
|
||||
db.execute(delete(InvestigationCase).where(
|
||||
InvestigationCase.fingerprint == fingerprint,
|
||||
InvestigationCase.scenario_id == SCENARIO_ID,
|
||||
))
|
||||
db.execute(delete(InvestigationQueueItem).where(InvestigationQueueItem.scenario_id == SCENARIO_ID))
|
||||
|
||||
if db.get(ScenarioRegistryEntry, SCENARIO_ID) is None:
|
||||
db.add(ScenarioRegistryEntry(
|
||||
scenario_id=SCENARIO_ID,
|
||||
scenario_key=SCENARIO_KEY,
|
||||
name="E2E investigation disposition",
|
||||
dashboard_id=47001,
|
||||
environment_ids=["e2e"],
|
||||
owner_id="e2e-seed",
|
||||
owner_username="e2e-seed",
|
||||
))
|
||||
if db.get(ScenarioRun, RUN_ID) is None:
|
||||
db.add(ScenarioRun(
|
||||
id=RUN_ID,
|
||||
scenario_id=SCENARIO_ID,
|
||||
scenario_revision_id="047e2e00-0000-4000-8000-000000000004",
|
||||
scenario_content_hash="b" * 64,
|
||||
environment_id="e2e",
|
||||
status="failed",
|
||||
phase="completed",
|
||||
idempotency_key="e2e-scenario-disposition-run",
|
||||
target_snapshot={"dashboard_id": 47001},
|
||||
))
|
||||
artifact = db.get(ScenarioArtifact, ARTIFACT_ID)
|
||||
if artifact is None:
|
||||
db.add(ScenarioArtifact(
|
||||
id=ARTIFACT_ID,
|
||||
owner_type="scenario_run",
|
||||
owner_id=RUN_ID,
|
||||
kind="evidence",
|
||||
name="E2E failed run evidence",
|
||||
content_ref=content_ref,
|
||||
sha256=CONTENT_SHA256,
|
||||
content_type="application/json",
|
||||
byte_length=len(EVIDENCE_BYTES),
|
||||
is_active=True,
|
||||
))
|
||||
else:
|
||||
artifact.content_ref = content_ref
|
||||
artifact.sha256 = CONTENT_SHA256
|
||||
artifact.content_type = "application/json"
|
||||
artifact.byte_length = len(EVIDENCE_BYTES)
|
||||
artifact.is_active = True
|
||||
db.flush()
|
||||
item = emit_terminal_run_signal(db, db.get(ScenarioRun, RUN_ID))
|
||||
assert item is not None and item.status == "queued"
|
||||
print(f"seeded scenario disposition queue: {item.fingerprint}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
# #endregion Scripts.SeedScenarioDispositionE2E
|
||||
@@ -178,9 +178,10 @@ def decide_approval(
|
||||
db.flush()
|
||||
raise ValueError("gate expired")
|
||||
|
||||
if decision in {"confirm", "deny"} and gate.reason_required and not (reason and reason.strip()):
|
||||
raise ValueError("reason required for this operation")
|
||||
|
||||
if decision == "confirm":
|
||||
if gate.reason_required and not reason:
|
||||
raise ValueError("reason required for this operation")
|
||||
gate.status = "confirmed"
|
||||
gate.actor_id = user_id
|
||||
gate.decided_at = _now()
|
||||
@@ -189,6 +190,7 @@ def decide_approval(
|
||||
gate.status = "denied"
|
||||
gate.actor_id = user_id
|
||||
gate.decided_at = _now()
|
||||
gate.reason = reason
|
||||
# Return run to RUNNING
|
||||
run = repo.get(run_id, user_id)
|
||||
if run:
|
||||
|
||||
@@ -46,6 +46,23 @@ from src.services.dashboard_testing.catalog_revision_wiring import (
|
||||
from src.services.dashboard_testing.materialization import (
|
||||
_commit_with_catalog_compensation,
|
||||
)
|
||||
from src.services.dashboard_testing.reference_source import require_reference_source, verify_reference_url_bytes
|
||||
|
||||
|
||||
def _require_candidate_reference(db: Session, draft: DraftArtifact, meta: dict[str, Any]) -> None:
|
||||
source = require_reference_source(
|
||||
meta.get("reference_source"), environment_id=meta.get("environment_id"),
|
||||
dashboard_id=meta.get("dashboard_id"),
|
||||
filters_hash=(meta.get("normalized_filters") or {}).get("filters_hash"),
|
||||
agent_run_id=draft.run_id,
|
||||
)
|
||||
capture_id = meta.get("capture_artifact_ref")
|
||||
capture = db.get(DraftArtifact, capture_id) if capture_id else None
|
||||
if capture is None or capture.run_id != draft.run_id or (
|
||||
capture.capture_meta or {}
|
||||
).get("reference_source") != source:
|
||||
raise ValueError("REFERENCE_SOURCE_MISMATCH")
|
||||
verify_reference_url_bytes(source)
|
||||
|
||||
|
||||
# #region BaselineEngine.Candidates.ApprovalLifecycle.RequestApproval [C:4] [TYPE Function]
|
||||
@@ -84,6 +101,7 @@ def request_approval(
|
||||
|
||||
# Guard: candidate must be in draft status
|
||||
_cg._ensure_capture_meta_status(meta, "draft")
|
||||
_require_candidate_reference(db, draft, meta)
|
||||
|
||||
# Guard: candidate must not already have a bound gate
|
||||
if meta.get("gate_id"):
|
||||
@@ -376,6 +394,7 @@ def consume_approval(
|
||||
else:
|
||||
meta["immutability"] = closure_block
|
||||
|
||||
_require_candidate_reference(db, draft, meta)
|
||||
entry = _meta_to_entry(meta, release_version, release_commit_hash)
|
||||
result = ar_consume_approval(db=db, run_id=run_id, gate_id=gate_id, user_id=user_id, bound_draft_id=draft.id)
|
||||
meta.update({"candidate_status": "consumed", "consumed_release_version": release_version, "consumed_release_commit_hash": release_commit_hash})
|
||||
|
||||
@@ -35,12 +35,13 @@ from src.schemas.dashboard_testing import (
|
||||
)
|
||||
from src.schemas.dashboard_testing.filters import NormalizedFilterContext
|
||||
from src.schemas.dashboard_testing.query_model import DashboardQueryModel
|
||||
from src.services.dashboard_testing.filters import _compute_filters_hash
|
||||
|
||||
_SRC = "BaselineEngine.SelectionProposal"
|
||||
|
||||
_DEFAULT_FILTERS = NormalizedFilterContext(
|
||||
filters=[],
|
||||
filters_hash="sha256:" + "0" * 64,
|
||||
filters_hash=_compute_filters_hash([]),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from src.services.dashboard_testing.query_executor import (
|
||||
execute_dashboard_query_envelope,
|
||||
)
|
||||
from src.services.dashboard_testing.query_model import inspect_dashboard_query_model
|
||||
from src.services.dashboard_testing.reference_source import require_reference_source
|
||||
|
||||
|
||||
# #region BaselineEngine.Candidates.Capture.ResolveRelease [C:4] [TYPE Function] [SEMANTICS capture,resolve,release,environment,repository]
|
||||
@@ -95,7 +96,7 @@ def resolve_release_authoritative(
|
||||
"env_id": env_id,
|
||||
"repo_key": repo_key,
|
||||
"dash_key": dash_key,
|
||||
"dashboard_id": release.id, # fallback dashboard_id from release context
|
||||
"dashboard_id": repo.dashboard_id,
|
||||
}
|
||||
# #endregion BaselineEngine.Candidates.Capture.ResolveRelease
|
||||
|
||||
@@ -112,6 +113,7 @@ def _build_capture_artifact(
|
||||
content_ref: str,
|
||||
db: Session,
|
||||
repo: AgentRunRepository,
|
||||
reference_source: dict[str, Any] | None = None,
|
||||
) -> DraftArtifact:
|
||||
"""Build and register a capture_execution DraftArtifact."""
|
||||
capture_meta: dict[str, Any] = {
|
||||
@@ -131,6 +133,8 @@ def _build_capture_artifact(
|
||||
"repo_key": repo_key,
|
||||
"dash_key": dash_key,
|
||||
}
|
||||
if reference_source is not None:
|
||||
capture_meta["reference_source"] = reference_source
|
||||
artifact = DraftArtifact(
|
||||
id=None,
|
||||
run_id=request.agent_run_id,
|
||||
@@ -159,6 +163,7 @@ def _build_capture_artifact(
|
||||
# @SIDE_EFFECT Mutable: DraftStorage store, DraftArtifact create, baseline candidate create.
|
||||
# @INVARIANT All coordinates (environment, repo_key, dash_key) derived server-side, never from caller.
|
||||
# @INVARIANT source_response_hash from httpx raw bytes (via execute_dashboard_query_envelope raw path).
|
||||
# @INVARIANT A caller-owned batch may defer candidate commit until all captures succeed.
|
||||
# @DATA_CONTRACT CaptureCandidateRequest + SupersetClient + DB -> CaptureCandidateResponse
|
||||
# @RATIONALE The authoritative capture pipeline: (1) resolve release -> environment_id from deployment, (2) inspect fresh DashboardQueryModel from Superset, (3) execute query envelope which computes source_response_hash from raw httpx bytes, (4) persist raw bytes via DraftStorage with content_ref, (5) create capture_execution DraftArtifact with coordinates + hash, (6) build candidate entirely from server-derived data — caller supplies ONLY agent_run_id, release_id, and query coordinates (chart, result_key, filters). No caller-provided hash, environment_id, or provenance can substitute server values.
|
||||
# @REJECTED Accepting source_response_hash from caller was rejected — defeats the purpose of server-side hash computation from raw wire bytes. Accepting environment_id from caller was rejected — environment must always resolve from release->deployment->environment_id chain. Storing raw bytes as base64 in capture_meta was rejected — DraftStorage provides bounded durable storage with content-addressed retrieval separate from relational metadata.
|
||||
@@ -167,6 +172,8 @@ async def capture_and_create_candidate(
|
||||
user_id: str,
|
||||
request: CaptureCandidateRequest,
|
||||
client: SupersetClient,
|
||||
*,
|
||||
commit_candidate: bool = True,
|
||||
) -> CaptureCandidateResponse:
|
||||
"""Execute authoritative capture: resolve release -> model -> envelope -> artifact -> candidate."""
|
||||
logger.reason("Starting authoritative capture", src="BaselineEngine.Candidates.Capture.ExecuteAndCapture", payload={"agent_run_id": request.agent_run_id, "release_id": request.release_id,
|
||||
@@ -184,6 +191,17 @@ async def capture_and_create_candidate(
|
||||
repo_key: str = release_info["repo_key"]
|
||||
dash_key: str = release_info["dash_key"]
|
||||
dashboard_id: int = request.dashboard_id
|
||||
if dashboard_id != release_info["dashboard_id"]:
|
||||
raise ValueError("Dashboard ID does not match the release repository dashboard")
|
||||
reference_source = (getattr(run, "context_snapshot", None) or {}).get("reference_source")
|
||||
if getattr(run, "intent", None) == "manual_reference_capture":
|
||||
require_reference_source(
|
||||
reference_source, environment_id=env_id, dashboard_id=dashboard_id,
|
||||
filters_hash=request.normalized_filters.filters_hash,
|
||||
agent_run_id=request.agent_run_id,
|
||||
)
|
||||
if reference_source["query_model_fingerprint"] != request.query_model_fingerprint:
|
||||
raise ValueError("REFERENCE_SOURCE_MISMATCH")
|
||||
|
||||
# 3. Resolve Superset client environment
|
||||
from src.dependencies import get_config_manager
|
||||
@@ -194,6 +212,8 @@ async def capture_and_create_candidate(
|
||||
|
||||
# 4. Inspect fresh DashboardQueryModel
|
||||
query_model = await inspect_dashboard_query_model(client, env_id, dashboard_id)
|
||||
if request.query_model_fingerprint and query_model.query_model_fingerprint != request.query_model_fingerprint:
|
||||
raise ValueError("Dashboard query model changed since reference preview")
|
||||
|
||||
# 5. Execute authoritative query envelope (uses execute_chart_data_raw for real httpx bytes)
|
||||
exec_request = ExecuteQueryRequest(
|
||||
@@ -203,9 +223,16 @@ async def capture_and_create_candidate(
|
||||
dataset_id=request.dataset_id,
|
||||
result_key=request.result_key,
|
||||
normalized_filters=request.normalized_filters,
|
||||
query_model_fingerprint=request.query_model_fingerprint,
|
||||
max_rows=request.max_rows,
|
||||
)
|
||||
envelope = await execute_dashboard_query_envelope(client, exec_request, query_model=query_model)
|
||||
if request.query_model_fingerprint and (
|
||||
envelope.normalized_value.canonical_value is None or envelope.normalized_value.warnings
|
||||
):
|
||||
raise ValueError("Capture returned no verified metric value")
|
||||
if request.query_model_fingerprint and len(envelope.raw_response_content) > 262144:
|
||||
raise ValueError("Capture response exceeds reference selection size limit")
|
||||
|
||||
# 6. Persist raw bytes via DraftStorage (bounded storage, opaque content_ref)
|
||||
draft_storage = get_draft_storage()
|
||||
@@ -218,6 +245,7 @@ async def capture_and_create_candidate(
|
||||
# 7. Create capture_execution DraftArtifact with coordinates + content_ref
|
||||
capture_artifact = _build_capture_artifact(
|
||||
request, envelope, env_id, repo_key, dash_key, content_ref, db, repo,
|
||||
reference_source=reference_source,
|
||||
)
|
||||
|
||||
# 8. Build candidate ENTIRELY from server data (caller cannot substitute expected/source_hash)
|
||||
@@ -244,7 +272,7 @@ async def capture_and_create_candidate(
|
||||
capture_artifact_ref=capture_artifact.id,
|
||||
)
|
||||
|
||||
candidate = create_candidate(db, user_id, candidate_req)
|
||||
candidate = create_candidate(db, user_id, candidate_req, commit=commit_candidate)
|
||||
|
||||
logger.reflect("Candidate created from authoritative capture", src="BaselineEngine.Candidates.Capture.ExecuteAndCapture", payload={"candidate_id": candidate.candidate_id, "hash_prefix": envelope.source_response_hash[:16]})
|
||||
|
||||
|
||||
@@ -234,7 +234,8 @@ def _draft_to_candidate(draft: DraftArtifact) -> BaselineCandidate:
|
||||
"bound_release_commit_hash", "consumed_release_version",
|
||||
"consumed_release_commit_hash", "gate_actor", "gate_decided_at",
|
||||
}
|
||||
req_data = {key: value for key, value in meta.items() if key not in lifecycle_keys}
|
||||
req_data = {key: value for key, value in meta.items()
|
||||
if key in CandidateRequest.model_fields and key not in lifecycle_keys}
|
||||
req = CandidateRequest(**req_data)
|
||||
gate_id = meta.get("gate_id")
|
||||
status = meta.get("candidate_status", "draft")
|
||||
|
||||
@@ -42,6 +42,7 @@ from src.services.dashboard_testing.safe_path import (
|
||||
assert_canonical_safe_path as _assert_canonical_safe_path,
|
||||
build_catalog_relative_path,
|
||||
)
|
||||
from src.services.dashboard_testing.reference_source import require_reference_source
|
||||
|
||||
# Re-export approval lifecycle functions for backward compat
|
||||
from .approvals import ( # noqa: F401
|
||||
@@ -52,15 +53,65 @@ from .approvals import ( # noqa: F401
|
||||
from .candidate_helpers import candidate_to_entry # noqa: F401 — re-exported for backward compat
|
||||
|
||||
|
||||
# #region BaselineEngine.Candidates.BindMetricCapture [C:3] [TYPE Function] [SEMANTICS baseline,metric,reference]
|
||||
# @ingroup BaselineEngine
|
||||
# @BRIEF Copy release and verified URL identity from the owning server capture artifact.
|
||||
# @POST The candidate metadata binds the same release and URL snapshot as its capture, when present.
|
||||
def _bind_metric_capture(db: Session, request: CandidateRequest, meta: dict) -> None:
|
||||
if not request.capture_artifact_ref:
|
||||
return
|
||||
capture = db.query(DraftArtifact).filter(
|
||||
DraftArtifact.id == request.capture_artifact_ref,
|
||||
DraftArtifact.run_id == request.agent_run_id,
|
||||
DraftArtifact.kind == "capture_execution",
|
||||
).first()
|
||||
if capture is None:
|
||||
return
|
||||
capture_meta = capture.capture_meta or {}
|
||||
if capture_meta.get("release_id"):
|
||||
meta["release_id"] = capture_meta["release_id"]
|
||||
if capture_meta.get("reference_source"):
|
||||
meta["reference_source"] = require_reference_source(
|
||||
capture_meta["reference_source"], environment_id=request.environment_id,
|
||||
dashboard_id=request.dashboard_id,
|
||||
filters_hash=request.normalized_filters.filters_hash,
|
||||
agent_run_id=request.agent_run_id,
|
||||
)
|
||||
# #endregion BaselineEngine.Candidates.BindMetricCapture
|
||||
|
||||
|
||||
# #region BaselineEngine.Candidates.ValidateVisualCapture [C:3] [TYPE Function] [SEMANTICS baseline,visual,screenshot]
|
||||
# @ingroup BaselineEngine
|
||||
# @BRIEF Validate the expected screenshot without trusting caller-writable screenshot metadata.
|
||||
# @PRE Expected image reference and SHA-256 are schema validated.
|
||||
# @POST A mismatched screenshot is rejected; no reference_source is inferred from this artifact.
|
||||
# @INVARIANT Generic screenshot capture_meta is caller-writable and cannot authorize a baseline URL.
|
||||
def _validate_visual_capture(db: Session, request: CandidateRequest) -> None:
|
||||
screenshot = db.query(DraftArtifact).filter(
|
||||
DraftArtifact.content_ref == request.expected_image_content_ref
|
||||
).first()
|
||||
if screenshot is None:
|
||||
raise ValueError("Visual expected_image_content_ref does not resolve to a DraftArtifact")
|
||||
if screenshot.run_id != request.agent_run_id:
|
||||
raise ValueError("Visual expected screenshot must belong to candidate agent_run_id")
|
||||
if screenshot.sha256 != request.expected_image_sha256:
|
||||
raise ValueError("Visual expected screenshot sha256 does not match expected_image_sha256")
|
||||
if screenshot.kind not in {"screenshot_evidence", "visual", "screenshot"}:
|
||||
raise ValueError("Visual expected screenshot DraftArtifact kind is not allowed")
|
||||
# A future server-owned visual capture will bind source, release and image bytes
|
||||
# together. The generic draft registration API cannot attest to that relation.
|
||||
# #endregion BaselineEngine.Candidates.ValidateVisualCapture
|
||||
|
||||
|
||||
# #region BaselineEngine.Candidates.CreateCandidate [C:4] [TYPE Function]
|
||||
# @ingroup BaselineEngine
|
||||
# @BRIEF Create a draft baseline candidate from discovered values — stored as DraftArtifact on the AgentRun.
|
||||
# @PRE request is validated, provenance is complete. AgentRun exists and is owned by user.
|
||||
# @POST Returns BaselineCandidate with status=draft. DraftArtifact persisted to DB. DB committed.
|
||||
# @POST Returns BaselineCandidate with status=draft. DraftArtifact persisted in the active transaction.
|
||||
# @SIDE_EFFECT Registers a DraftArtifact row on the linked AgentRun.
|
||||
# @RATIONALE Commit on success ensures the DraftArtifact is durable. Rollback on ValueError
|
||||
# prevents partial state from polluting the DB session.
|
||||
def create_candidate(db: Session, user_id: str, request: CandidateRequest) -> BaselineCandidate:
|
||||
# @RATIONALE Ordinary calls commit on success; the reference-selection batch can defer
|
||||
# commit until every selected coordinate is captured in one transaction.
|
||||
def create_candidate(db: Session, user_id: str, request: CandidateRequest, *, commit: bool = True) -> BaselineCandidate:
|
||||
"""Create a draft baseline candidate stored as a DraftArtifact on the AgentRun."""
|
||||
try:
|
||||
# Validate canonical safe path before any DB reads
|
||||
@@ -89,9 +140,6 @@ def create_candidate(db: Session, user_id: str, request: CandidateRequest) -> Ba
|
||||
verify_scenario_transform_ref,
|
||||
)
|
||||
verify_scenario_transform_ref(db, request)
|
||||
if run is None:
|
||||
raise ValueError(f"AgentRun {request.agent_run_id} not found or access denied")
|
||||
|
||||
# Store RELATIVE path in DraftArtifact (absolute resolution is done at
|
||||
# write time by consume_approval against a configurable base).
|
||||
intended_path = build_catalog_relative_path(
|
||||
@@ -101,20 +149,12 @@ def create_candidate(db: Session, user_id: str, request: CandidateRequest) -> Ba
|
||||
|
||||
meta = request.model_dump(mode="json")
|
||||
meta["candidate_status"] = "draft"
|
||||
# The authoritative capture artifact owns the release binding. Keep it on the
|
||||
# candidate so approval request/consume and review can validate the same pin.
|
||||
if request.kind == "metric":
|
||||
_bind_metric_capture(db, request, meta)
|
||||
if request.kind == "visual":
|
||||
from src.models.agent_run import DraftArtifact as StoredDraftArtifact
|
||||
|
||||
screenshot = db.query(StoredDraftArtifact).filter(
|
||||
StoredDraftArtifact.content_ref == request.expected_image_content_ref
|
||||
).first()
|
||||
if screenshot is None:
|
||||
raise ValueError("Visual expected_image_content_ref does not resolve to a DraftArtifact")
|
||||
if screenshot.run_id != request.agent_run_id:
|
||||
raise ValueError("Visual expected screenshot must belong to candidate agent_run_id")
|
||||
if screenshot.sha256 != request.expected_image_sha256:
|
||||
raise ValueError("Visual expected screenshot sha256 does not match expected_image_sha256")
|
||||
if screenshot.kind not in {"screenshot_evidence", "visual", "screenshot"}:
|
||||
raise ValueError("Visual expected screenshot DraftArtifact kind is not allowed")
|
||||
_validate_visual_capture(db, request)
|
||||
|
||||
draft = DraftArtifact(
|
||||
id=None,
|
||||
@@ -128,7 +168,10 @@ def create_candidate(db: Session, user_id: str, request: CandidateRequest) -> Ba
|
||||
capture_meta=meta,
|
||||
)
|
||||
repo.register_draft(draft)
|
||||
db.commit()
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
logger.reflect("Candidate persisted", src="BaselineEngine.Candidates.CreateCandidate", payload={"candidate_id": draft.id, "run_id": request.agent_run_id})
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -23,6 +23,7 @@ from src.services.dashboard_testing.catalog_revision_log import (
|
||||
record_publish_outcome,
|
||||
)
|
||||
from src.services.dashboard_testing.fingerprints import compute_sha256
|
||||
from src.services.dashboard_testing.reference_source import require_reference_source
|
||||
|
||||
_SRC = "BaselineEngine.CatalogRevisionWiring"
|
||||
_CAPTURE_REF_REQUIRED = "CATALOG_REVISION_CAPTURE_REF_REQUIRED"
|
||||
@@ -99,6 +100,11 @@ def build_capture_profile_hash(entry: Any, capture_artifact_id: str) -> str:
|
||||
# into a revision that the resolver could never pin.
|
||||
def build_entry_revisions(entry: Any, meta: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
payload = entry.model_dump(mode="json")
|
||||
reference_source = require_reference_source(
|
||||
meta.get("reference_source"), environment_id=payload["provenance"]["environment"],
|
||||
dashboard_id=payload["dashboard_id"],
|
||||
filters_hash=payload["normalized_filters"]["filters_hash"],
|
||||
)
|
||||
capture_artifact_id = meta.get("capture_artifact_id") or meta.get("capture_artifact_ref")
|
||||
if payload.get("kind") != "visual" and not capture_artifact_id:
|
||||
logger.explore(
|
||||
@@ -120,6 +126,7 @@ def build_entry_revisions(entry: Any, meta: dict[str, Any]) -> list[dict[str, An
|
||||
"entry": payload,
|
||||
"capture_artifact_id": str(capture_artifact_id) if capture_artifact_id else None,
|
||||
"capture_profile_hash": capture_profile_hash,
|
||||
"reference_source": reference_source,
|
||||
"review_disposition_id": meta.get("review_disposition_id"),
|
||||
"review_status": meta.get("review_status") or "not_applicable",
|
||||
}]
|
||||
|
||||
@@ -10,12 +10,36 @@ from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from src.core.logger import logger
|
||||
from src.dependencies import get_config_manager
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision
|
||||
|
||||
_SRC = "ScenarioEditor.Load.Editor"
|
||||
|
||||
|
||||
def _dashboard_environment_context(entry: ScenarioRegistryEntry) -> list[dict[str, str | None]]:
|
||||
"""Expose only configured, scenario-bound Superset origins for editor navigation."""
|
||||
configured = {env.id: env for env in get_config_manager().get_environments()}
|
||||
context = []
|
||||
for environment_id in entry.environment_ids or []:
|
||||
env = configured.get(environment_id)
|
||||
if env is None:
|
||||
context.append({"id": environment_id, "name": environment_id, "dashboard_url": None})
|
||||
continue
|
||||
parsed = urlsplit(env.url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
context.append({"id": env.id, "name": env.name, "dashboard_url": None})
|
||||
continue
|
||||
base_path = parsed.path.rstrip("/")
|
||||
if base_path.lower().endswith("/api/v1"):
|
||||
base_path = base_path[:-len("/api/v1")]
|
||||
path = f"{base_path}/superset/dashboard/{entry.dashboard_id}/"
|
||||
context.append({"id": env.id, "name": env.name,
|
||||
"dashboard_url": urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))})
|
||||
return context
|
||||
|
||||
|
||||
# #region ScenarioEditor.Load.Editor [C:3] [TYPE Function] [SEMANTICS scenario,editor,load,readonly,draft]
|
||||
# @ingroup ScenarioEditor
|
||||
# @BRIEF Return entry, graph and base revision for a read-only editor view.
|
||||
@@ -62,6 +86,8 @@ def load_editor(db: Session, scenario_id: str, revision_id: str | None = None) -
|
||||
"graph": revision.graph_snapshot,
|
||||
"dirty": False,
|
||||
"validation": {"status": entry.validation_status, "findings": []},
|
||||
"dashboard_id": entry.dashboard_id,
|
||||
"environments": _dashboard_environment_context(entry),
|
||||
}
|
||||
logger.reflect("Editor projection loaded", src=_SRC, claim="POST: dirty=false and base_revision_id", payload={
|
||||
"scenario_id": entry.scenario_id, "base_revision_id": revision.revision_id,
|
||||
|
||||
@@ -28,8 +28,10 @@ import re
|
||||
from typing import Any
|
||||
|
||||
import jsonschema
|
||||
from referencing import Registry, Resource
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.dashboard_testing.reference_source import require_reference_source
|
||||
from src.services.dashboard_testing.baseline_staleness import (
|
||||
BaselinePeriodStale,
|
||||
detect_period_staleness,
|
||||
@@ -139,7 +141,11 @@ def _load_pin_schema() -> dict[str, Any]:
|
||||
|
||||
def _validate_constructed_pin(pin: dict[str, Any]) -> None:
|
||||
try:
|
||||
jsonschema.validate(instance=pin, schema=_load_pin_schema())
|
||||
schema = _load_pin_schema()
|
||||
reference_path = Path(__file__).resolve().parents[5] / "specs" / "037-superset-baseline-engine" / "contracts" / "reference-url.schema.json"
|
||||
reference_schema = json.loads(reference_path.read_text(encoding="utf-8"))
|
||||
registry = Registry().with_resource(reference_schema["$id"], Resource.from_contents(reference_schema))
|
||||
jsonschema.Draft202012Validator(schema, registry=registry).validate(pin)
|
||||
except jsonschema.ValidationError:
|
||||
logger.explore("Constructed pin failed schema", src=_SRC, error_code=BASELINE_EVIDENCE_UNAVAILABLE)
|
||||
_reject(BASELINE_EVIDENCE_UNAVAILABLE)
|
||||
@@ -268,6 +274,15 @@ def _pin_entry(item: dict[str, Any]) -> dict[str, Any]:
|
||||
_reject(BASELINE_EVIDENCE_UNAVAILABLE)
|
||||
if not _matches(_SHA256, item.get("coordinate_hash")) or not _matches(_SHA256, item.get("entry_digest")):
|
||||
_reject(BASELINE_STALE)
|
||||
try:
|
||||
reference_source = require_reference_source(
|
||||
item.get("reference_source"), dashboard_id=entry.get("dashboard_id"),
|
||||
filters_hash=(entry.get("normalized_filters") or {}).get("filters_hash"),
|
||||
)
|
||||
except ValueError:
|
||||
_reject(BASELINE_EVIDENCE_UNAVAILABLE)
|
||||
if (entry.get("provenance") or {}).get("environment") != reference_source["environment_id"]:
|
||||
_reject(BASELINE_STALE)
|
||||
return {
|
||||
"baseline_id": item.get("baseline_id"),
|
||||
"baseline_revision_id": item.get("baseline_revision_id"),
|
||||
@@ -278,6 +293,7 @@ def _pin_entry(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"expected_image_sha256": image_hash if kind == "visual" else None,
|
||||
"capture_artifact_id": capture_artifact_id,
|
||||
"capture_profile_hash": capture_profile_hash,
|
||||
"reference_source": reference_source,
|
||||
}
|
||||
# #endregion ScenarioExecution.BaselineResolver.PinEntry
|
||||
|
||||
@@ -317,6 +333,8 @@ def resolve_baseline_pin(
|
||||
baseline_set_version: str | None = None,
|
||||
published_catalog: dict[str, Any] | None = None,
|
||||
requested_period: str | None = None,
|
||||
environment_id: str | None = None,
|
||||
dashboard_id: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
set_id = _blank(baseline_set)
|
||||
set_version = _blank(baseline_set_version)
|
||||
@@ -347,6 +365,12 @@ def resolve_baseline_pin(
|
||||
raise BaselinePeriodStale(period_stale)
|
||||
release_version, release_commit_hash = _unique_release(approved)
|
||||
entries = [_pin_entry(item) for item in sorted(approved, key=lambda item: str(item.get("baseline_id") or ""))]
|
||||
for pinned in entries:
|
||||
source = pinned["reference_source"]
|
||||
if (environment_id is not None and source["environment_id"] != environment_id) or (
|
||||
dashboard_id is not None and source["dashboard_id"] != dashboard_id
|
||||
):
|
||||
_reject(BASELINE_STALE)
|
||||
pin = {
|
||||
"schema_version": 1,
|
||||
"baseline_set_id": set_id,
|
||||
|
||||
@@ -247,6 +247,8 @@ def start_run(
|
||||
baseline_set_version=baseline_set_version,
|
||||
published_catalog=catalog_snapshot,
|
||||
requested_period=requested_period_from(params, plan),
|
||||
environment_id=environment_id,
|
||||
dashboard_id=entry.dashboard_id,
|
||||
)
|
||||
except BaselinePeriodStale as stale_exc:
|
||||
# AGBASE-FR-018: a closed period that rolled over blocks the launch instead of running on
|
||||
|
||||
@@ -48,11 +48,17 @@ def _build_chart_data_filters(normalized_filters) -> list[dict]:
|
||||
"EQUALS": "==",
|
||||
"NOT_EQUALS": "!=",
|
||||
"GREATER_THAN": ">",
|
||||
"GREATER_THAN_OR_EQUALS": ">=",
|
||||
"LESS_THAN": "<",
|
||||
"LESS_THAN_OR_EQUALS": "<=",
|
||||
"IS_NULL": "IS NULL",
|
||||
}
|
||||
sup_operator = operator_map.get(nf.operator, nf.operator)
|
||||
|
||||
if nf.operator in ("TIME_COLUMN", "TIME_GRAIN"):
|
||||
result.append({"operator": nf.operator, "comparator": nf.value.value})
|
||||
continue
|
||||
|
||||
if sup_operator == "TEMPORAL_RANGE":
|
||||
# Temporal range: use from/to values
|
||||
result.append({
|
||||
@@ -141,6 +147,11 @@ def _verify_authoritative_model(
|
||||
f"authoritative model. Valid datasets: {valid_dataset_ids}"
|
||||
)
|
||||
|
||||
if chart_id is not None and dataset_id is not None:
|
||||
chart = next(ch for ch in query_model.charts if ch.chart_id == chart_id)
|
||||
if chart.dataset_id != dataset_id:
|
||||
raise ValueError("Chart dataset differs from authoritative dashboard model")
|
||||
|
||||
# 4. Verify result_key/metrics
|
||||
if chart_id is not None:
|
||||
matching_charts = [ch for ch in query_model.charts if ch.chart_id == chart_id]
|
||||
@@ -192,7 +203,10 @@ def _extract_query_result(result_data: dict, result_key: str) -> tuple[Any, str]
|
||||
first_record = actual_result[0]
|
||||
if isinstance(first_record, dict):
|
||||
data = first_record.get("data", first_record)
|
||||
raw_value = data.get(result_key, data)
|
||||
if isinstance(data, list):
|
||||
data = data[0] if data else {}
|
||||
if isinstance(data, dict):
|
||||
raw_value = data.get(result_key)
|
||||
else:
|
||||
raw_value = first_record
|
||||
return raw_value, query_id
|
||||
@@ -239,13 +253,22 @@ async def execute_dashboard_query_envelope(
|
||||
# No authoritative model — use filters as-is (legacy path)
|
||||
filters = _build_chart_data_filters(request.normalized_filters)
|
||||
|
||||
# Use the chart's authoritative metric definition. An ad hoc metric needs
|
||||
# its aggregate and column; its label alone is not executable in Superset.
|
||||
metric_spec: str | dict = request.result_key
|
||||
if query_model is not None and chart_id is not None:
|
||||
chart_model = next((chart for chart in query_model.charts if chart.chart_id == chart_id), None)
|
||||
descriptor = next((metric for metric in chart_model.metrics if metric.metric_name == request.result_key), None) if chart_model else None
|
||||
if descriptor and descriptor.chart_spec:
|
||||
metric_spec = descriptor.chart_spec
|
||||
|
||||
# ── Execute chart-data query via raw path (preserves httpx bytes) ─────
|
||||
try:
|
||||
raw_response: ChartDataResponse = await client.execute_chart_data_raw(
|
||||
chart_id=chart_id or 0,
|
||||
datasource_id=dataset_id or 0,
|
||||
datasource_type="table",
|
||||
metrics=[request.result_key],
|
||||
metrics=[metric_spec],
|
||||
groupby=None,
|
||||
filters=filters,
|
||||
row_limit=request.max_rows,
|
||||
@@ -282,6 +305,8 @@ async def execute_dashboard_query_envelope(
|
||||
raw_response_content: bytes = raw_response.raw_bytes
|
||||
source_response_hash: str = raw_response.source_response_hash
|
||||
result_data: dict[str, Any] = raw_response.parsed
|
||||
if "result" not in result_data and ("message" in result_data or "errors" in result_data):
|
||||
raise ValueError("Superset chart-data rejected the query")
|
||||
|
||||
# Extract result value from Superset response (AFTER hash computation)
|
||||
raw_value, query_id = _extract_query_result(result_data, request.result_key)
|
||||
|
||||
@@ -81,10 +81,7 @@ def _parse_native_filters(raw_filters: list) -> list[NativeFilterModel]:
|
||||
"""Extract native filter models from json_metadata."""
|
||||
result: list[NativeFilterModel] = []
|
||||
for rf in raw_filters:
|
||||
targets = [
|
||||
FilterTarget(chart_id=0, dataset_id=t.get("datasetId", 0))
|
||||
for t in rf.get("targets", [])
|
||||
]
|
||||
targets: list[FilterTarget] = []
|
||||
filter_type = rf.get("filterType", "filter_select")
|
||||
type_map = {
|
||||
"filter_date": "DATE", "filter_time": "TIME",
|
||||
@@ -94,8 +91,8 @@ def _parse_native_filters(raw_filters: list) -> list[NativeFilterModel]:
|
||||
result.append(NativeFilterModel(
|
||||
filter_id=rf.get("id", ""), filter_type="NATIVE_FILTER",
|
||||
name=rf.get("name", rf.get("id", "")),
|
||||
column=rf.get("targets", [{}])[0].get("column", {}).get("name", ""),
|
||||
dataset_id=rf.get("targets", [{}])[0].get("datasetId", 0),
|
||||
column=(rf.get("targets") or [{}])[0].get("column", {}).get("name", ""),
|
||||
dataset_id=(rf.get("targets") or [{}])[0].get("datasetId", 0),
|
||||
type=type_map.get(filter_type, "STRING"),
|
||||
targets=targets,
|
||||
))
|
||||
@@ -140,11 +137,29 @@ def _parse_metrics(raw_metrics: list) -> list[MetricDescriptor]:
|
||||
column=column_ref,
|
||||
aggregate=aggregate if isinstance(aggregate, str) else None,
|
||||
sql_expression=sql_expression if isinstance(sql_expression, str) else None,
|
||||
chart_spec=rm,
|
||||
))
|
||||
return result
|
||||
# #endregion BaselineEngine.QueryModel.Inspect.ParseMetrics
|
||||
|
||||
|
||||
# #region BaselineEngine.QueryModel.Inspect.DatasetId [C:2] [TYPE Function] [SEMANTICS superset,chart,dataset]
|
||||
# @BRIEF Resolve a chart dataset ID from authoritative chart metadata or Superset form_data datasource.
|
||||
def _chart_dataset_id(chart_obj: dict, form_data: dict) -> int:
|
||||
raw_id = chart_obj.get("datasource_id")
|
||||
if isinstance(raw_id, int) and raw_id > 0:
|
||||
return raw_id
|
||||
if isinstance(raw_id, str) and raw_id.isdecimal() and int(raw_id) > 0:
|
||||
return int(raw_id)
|
||||
datasource = form_data.get("datasource")
|
||||
if isinstance(datasource, str):
|
||||
match = datasource.split("__", 1)
|
||||
if len(match) == 2 and match[0].isdecimal() and match[1] == "table" and int(match[0]) > 0:
|
||||
return int(match[0])
|
||||
return 0
|
||||
# #endregion BaselineEngine.QueryModel.Inspect.DatasetId
|
||||
|
||||
|
||||
# #region BaselineEngine.QueryModel.Inspect.ProcessChartsData [C:3] [TYPE Function] [SEMANTICS processing,charts]
|
||||
# @ingroup BaselineEngine
|
||||
# @BRIEF Process chart metadata from dashboard/charts endpoint into ChartQueryModel list.
|
||||
@@ -168,7 +183,7 @@ def _process_charts_data(charts_data: list) -> list[ChartQueryModel]:
|
||||
slice_name=chart_obj.get("slice_name", f"Chart {cid}"),
|
||||
viz_type=_parse_viz_type(
|
||||
form_data.get("viz_type") or chart_obj.get("viz_type")),
|
||||
dataset_id=chart_obj.get("datasource_id", 0),
|
||||
dataset_id=_chart_dataset_id(chart_obj, form_data),
|
||||
dataset_uuid=None,
|
||||
dataset_name=chart_obj.get("datasource_name_text", ""),
|
||||
metrics=metrics,
|
||||
@@ -195,16 +210,17 @@ async def _fetch_remaining_charts(
|
||||
chart_detail = await client.get_chart(cid)
|
||||
chart_data = chart_detail.get("result", chart_detail)
|
||||
params = _safe_json_load(chart_data.get("params", "{}"))
|
||||
metrics = _parse_metrics(params.get("metrics", []))
|
||||
form_data = _safe_json_load(chart_data.get("form_data", "{}"))
|
||||
metrics = _parse_metrics(params.get("metrics") or form_data.get("metrics", []))
|
||||
charts.append(ChartQueryModel(
|
||||
chart_id=cid,
|
||||
chart_uuid=chart_data.get("uuid"),
|
||||
slice_name=chart_data.get("slice_name", f"Chart {cid}"),
|
||||
viz_type=_parse_viz_type(chart_data.get("viz_type")),
|
||||
dataset_id=chart_data.get("datasource_id", 0),
|
||||
viz_type=_parse_viz_type(form_data.get("viz_type") or chart_data.get("viz_type")),
|
||||
dataset_id=_chart_dataset_id(chart_data, form_data),
|
||||
dataset_name=chart_data.get("datasource_name_text", ""),
|
||||
metrics=metrics,
|
||||
group_by_columns=list(params.get("groupby", [])),
|
||||
group_by_columns=list(params.get("groupby") or form_data.get("groupby", [])),
|
||||
execution_capable=True,
|
||||
))
|
||||
except SupersetAPIError as e:
|
||||
@@ -271,14 +287,58 @@ def _extract_chart_ids(position_json: dict) -> set[int]:
|
||||
# @BRIEF Assign applied_filter_ids to charts based on native filter dataset targets.
|
||||
def _resolve_filter_mapping(
|
||||
charts: list[ChartQueryModel], native_filters: list[NativeFilterModel],
|
||||
position_json: dict, raw_filters: list[dict], datasets: list[DatasetQueryModel],
|
||||
) -> None:
|
||||
"""Assign applied_filter_ids to charts based on native filter targets."""
|
||||
"""Resolve Superset rootPath/excluded chart scope against layout and dataset binding."""
|
||||
def descendants(root: str) -> set[int]:
|
||||
seen: set[str] = set()
|
||||
pending = [root]
|
||||
found: set[int] = set()
|
||||
while pending:
|
||||
node_id = pending.pop()
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
node = position_json.get(node_id, {})
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
chart_id = node.get("meta", {}).get("chartId")
|
||||
if chart_id is not None:
|
||||
found.add(int(chart_id))
|
||||
pending.extend(child for child in node.get("children", []) if isinstance(child, str))
|
||||
return found
|
||||
|
||||
for chart in charts:
|
||||
chart.applied_filter_ids = []
|
||||
for nf in native_filters:
|
||||
for t in nf.targets:
|
||||
if t.dataset_id == chart.dataset_id and chart.chart_id not in chart.applied_filter_ids:
|
||||
chart.applied_filter_ids.append(nf.filter_id)
|
||||
definitions = {item.get("id"): item for item in raw_filters}
|
||||
dataset_columns = {
|
||||
dataset.dataset_id: {column.column_name for column in dataset.columns if column.filterable}
|
||||
for dataset in datasets
|
||||
}
|
||||
for nf in native_filters:
|
||||
raw = definitions.get(nf.filter_id, {})
|
||||
scope = raw.get("scope", {})
|
||||
root_path = scope.get("rootPath", [])
|
||||
excluded = {str(chart_id) for chart_id in scope.get("excluded", [])}
|
||||
if root_path:
|
||||
scoped = descendants(root_path[-1]) if root_path[-1] in position_json else set()
|
||||
else:
|
||||
scoped = {chart.chart_id for chart in charts}
|
||||
dataset_ids = {target.get("datasetId") for target in raw.get("targets", [])}
|
||||
nf.targets = [
|
||||
FilterTarget(chart_id=chart.chart_id, dataset_id=chart.dataset_id)
|
||||
for chart in charts
|
||||
if chart.chart_id in scoped and str(chart.chart_id) not in excluded
|
||||
and (nf.type in ("DATE", "TIME", "TIME_GRAIN")
|
||||
or chart.dataset_id in dataset_ids
|
||||
or nf.column in dataset_columns.get(chart.dataset_id, set()))
|
||||
and chart.execution_capable
|
||||
]
|
||||
for chart in charts:
|
||||
if any(target.chart_id == chart.chart_id for target in nf.targets):
|
||||
chart.applied_filter_ids.append(nf.filter_id)
|
||||
else:
|
||||
chart.excluded_filter_ids.append(nf.filter_id)
|
||||
# #endregion BaselineEngine.QueryModel.Inspect.ResolveFilterMapping
|
||||
|
||||
|
||||
@@ -295,6 +355,7 @@ async def inspect_dashboard_query_model(
|
||||
client: SupersetClient,
|
||||
environment_id: str,
|
||||
dashboard_id: int,
|
||||
*, strict_identity: bool = False,
|
||||
) -> DashboardQueryModel:
|
||||
"""
|
||||
Build a deterministic query model from authoritative Superset metadata.
|
||||
@@ -317,14 +378,17 @@ async def inspect_dashboard_query_model(
|
||||
query_model_fingerprint="sha256:error",
|
||||
)
|
||||
|
||||
if strict_identity and str(dash_data.get("id")) != str(dashboard_id):
|
||||
raise ValueError("Superset dashboard identity differs from requested dashboard")
|
||||
|
||||
title = dash_data.get("dashboard_title", f"Dashboard {dashboard_id}")
|
||||
slug = dash_data.get("slug")
|
||||
json_metadata = _safe_json_load(dash_data.get("json_metadata", "{}"))
|
||||
position_json = _safe_json_load(dash_data.get("position_json", "{}"))
|
||||
|
||||
# 2. Extract native filters from json_metadata
|
||||
native_filters = _parse_native_filters(
|
||||
json_metadata.get("native_filter_configuration", []))
|
||||
raw_filters = json_metadata.get("native_filter_configuration", [])
|
||||
native_filters = _parse_native_filters(raw_filters)
|
||||
|
||||
# 3. Extract chart IDs from position JSON
|
||||
chart_ids = _extract_chart_ids(position_json)
|
||||
@@ -358,7 +422,7 @@ async def inspect_dashboard_query_model(
|
||||
datasets = _process_datasets_data(datasets_data)
|
||||
|
||||
# 6. Resolve filter→chart mapping
|
||||
_resolve_filter_mapping(charts, native_filters)
|
||||
_resolve_filter_mapping(charts, native_filters, position_json, raw_filters, datasets)
|
||||
|
||||
# 7. Capabilities
|
||||
capabilities = DashboardCapabilities(
|
||||
|
||||
@@ -75,9 +75,8 @@ def _reconcile_entry(entry: dict) -> dict:
|
||||
e["source_response_hash"] = _bare_hash(e["source_response_hash"])
|
||||
# Drop Pydantic-only fields
|
||||
e.pop("warnings", None)
|
||||
# Drop null chart_id/dataset_id so schema oneOf works correctly
|
||||
# (oneOf requires exactly one to match — null values prevent match).
|
||||
# If both are non-null, the schema oneOf will reject as intended.
|
||||
# Drop null IDs so schema anyOf requires at least one concrete coordinate.
|
||||
# Preserve both IDs when a chart metric identifies its chart and dataset.
|
||||
if e.get("chart_id") is None:
|
||||
e.pop("chart_id", None)
|
||||
if e.get("dataset_id") is None:
|
||||
@@ -233,7 +232,7 @@ def _reconcile_from_schema_entry(entry: dict) -> dict:
|
||||
# Manufacturing a placeholder ``dashboard.id: 1`` was rejected — it masks
|
||||
# invalid input and allows structurally incomplete catalogs to pass validation.
|
||||
# Silently dropping dataset_id when both IDs are present was rejected — it
|
||||
# masks oneOf violations and allows invalid catalogs to persist.
|
||||
# loses the chart metric's authoritative dataset attribution.
|
||||
def _reconcile_for_schema(raw: dict) -> dict:
|
||||
"""Map raw catalog dict to schema-expected format so jsonschema.validate works.
|
||||
|
||||
@@ -241,17 +240,15 @@ def _reconcile_for_schema(raw: dict) -> dict:
|
||||
- entry.comparison_policy -> entry.policy
|
||||
- entry.expected.canonical_value -> entry.expected.canonical
|
||||
- hash fields: strip 'sha256:' prefix for bare hex
|
||||
- Drop null chart_id/dataset_id to allow schema oneOf to select the non-null branch
|
||||
- Do NOT drop dataset_id when both are non-null — schema oneOf rejects that naturally
|
||||
- Drop null chart_id/dataset_id so schema anyOf requires a concrete ID
|
||||
- Preserve chart_id and dataset_id together for chart metric coordinates
|
||||
- Do NOT force schema_version — schema const:1 validates the exact persisted value
|
||||
|
||||
@RATIONALE The schema's ``required: [dashboard]`` ensures a missing dashboard field
|
||||
produces a schema violation rather than being silently masked. The caller
|
||||
(write_catalog or load_catalog) decides whether to reject or warn.
|
||||
Previously, _reconcile_entry silently dropped dataset_id when both chart_id
|
||||
and dataset_id were non-null and forced schema_version to 1, masking valid
|
||||
schema violations. The reconciler now preserves the exact persisted
|
||||
representation and lets the schema validate it.
|
||||
The reconciler preserves both non-null IDs and the persisted schema_version;
|
||||
schema validation rejects entries with neither concrete ID.
|
||||
"""
|
||||
reconciled: dict[str, Any] = {
|
||||
"schema_version": raw.get("schema_version", 1),
|
||||
|
||||
348
backend/src/services/dashboard_testing/reference_discovery.py
Normal file
348
backend/src/services/dashboard_testing/reference_discovery.py
Normal file
@@ -0,0 +1,348 @@
|
||||
# #region BaselineEngine.ReferenceDiscovery [C:5] [TYPE Module] [SEMANTICS baseline,reference,discovery,preview]
|
||||
# @defgroup BaselineEngine Read-only discovery of evidence-backed chart metrics from a Superset reference URL.
|
||||
# @INVARIANT The URL is parsed as data; all HTTP calls use the configured SupersetClient.
|
||||
# @INVARIANT A preview never writes, approves, or publishes baseline values.
|
||||
# @RATIONALE URL state supplies values only; the fresh query model supplies dashboard, chart, dataset, metric and scope identities.
|
||||
# @REJECTED Fetching the submitted URL was rejected because it would allow arbitrary hosts and unauthoritative chart metadata.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from src.schemas.dashboard_testing import ExecuteQueryRequest
|
||||
from src.services.dashboard_testing.query_executor import execute_dashboard_query_envelope
|
||||
from src.services.dashboard_testing.query_model import inspect_dashboard_query_model
|
||||
from src.services.dashboard_testing.reference_discovery_filters import normalize_state as _normalize_state
|
||||
|
||||
_MAX_QUERIES = 20
|
||||
_MAX_RESPONSE_BYTES = 262144
|
||||
_MAX_STATE_BYTES = 65536
|
||||
_MAX_METADATA_COORDINATES = 500
|
||||
_KEY = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.ParseUrl [C:4] [TYPE Function]
|
||||
# @BRIEF Validate configured host and dashboard path before any Superset request; HTTP links may use configured HTTPS.
|
||||
def _parse_url(url: str, configured_url: str) -> tuple[str, str, dict[str, list[str]]]:
|
||||
if len(url.encode("utf-8")) > 8192:
|
||||
raise ValueError("Reference URL exceeds size limit")
|
||||
supplied, configured = urlsplit(url), urlsplit(configured_url)
|
||||
same_origin = (supplied.scheme, supplied.netloc) == (configured.scheme, configured.netloc)
|
||||
upgraded_link = (supplied.scheme == "http" and configured.scheme == "https"
|
||||
and supplied.hostname == configured.hostname
|
||||
and supplied.port == configured.port)
|
||||
if not (same_origin or upgraded_link):
|
||||
raise ValueError("Reference URL origin differs from configured Superset origin")
|
||||
if supplied.username or supplied.password or supplied.fragment:
|
||||
raise ValueError("Reference URL contains unsupported credentials or fragment")
|
||||
base = configured.path.rstrip("/")
|
||||
path = supplied.path
|
||||
if base and not path.startswith(base + "/"):
|
||||
raise ValueError("Reference URL is outside configured Superset path")
|
||||
path = path[len(base):]
|
||||
match = re.fullmatch(r"/(?:superset/)?dashboard/(p/)?([A-Za-z0-9_-]+)/?", path)
|
||||
if match is None:
|
||||
raise ValueError("Reference URL is not a dashboard URL")
|
||||
return ("permalink" if match.group(1) else "dashboard", match.group(2), parse_qs(supplied.query, strict_parsing=True))
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.ParseUrl
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.PermalinkState [C:3] [TYPE Function]
|
||||
# @BRIEF Fetch permalink state and bind it to the requested dashboard identity.
|
||||
async def _permalink_state(client: Any, reference: str, dashboard_id: int, dashboard_uuid: str | None) -> dict:
|
||||
if not _KEY.fullmatch(reference):
|
||||
raise ValueError("Ambiguous or invalid permalink")
|
||||
response = await client.get_dashboard_permalink_state(reference)
|
||||
result = response.get("result", response) if isinstance(response, dict) else None
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError("Permalink state is unavailable")
|
||||
state = result.get("state", result)
|
||||
bound_id = result.get("dashboardId", result.get("dashboard_id"))
|
||||
if bound_id is None:
|
||||
raise ValueError("Permalink dashboard identity cannot be verified")
|
||||
if str(bound_id) not in {str(dashboard_id), dashboard_uuid}:
|
||||
raise ValueError("Reference URL dashboard differs from scenario dashboard")
|
||||
if not isinstance(state, dict) or not isinstance(state.get("dataMask"), dict):
|
||||
raise ValueError("Permalink dataMask is unavailable")
|
||||
return state["dataMask"]
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.PermalinkState
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.KeyState [C:3] [TYPE Function]
|
||||
# @BRIEF Fetch and validate Superset native filter key data.
|
||||
async def _key_state(client: Any, dashboard_id: int, key: str) -> dict:
|
||||
if not _KEY.fullmatch(key):
|
||||
raise ValueError("Invalid native filter key")
|
||||
response = await client.get_native_filter_state(dashboard_id, key)
|
||||
result = response.get("result", response) if isinstance(response, dict) else None
|
||||
if not isinstance(result, dict) or "value" not in result:
|
||||
raise ValueError("Native filter key state is unavailable")
|
||||
value = result["value"]
|
||||
try:
|
||||
state = json.loads(value) if isinstance(value, str) else value
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Invalid native filter key state") from exc
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError("Invalid native filter key state")
|
||||
if "id" in state and "extraFormData" in state:
|
||||
state = {state["id"]: state}
|
||||
if not all(isinstance(entry, dict) for entry in state.values()):
|
||||
raise ValueError("Invalid native filter key state")
|
||||
return state
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.KeyState
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.DirectState [C:3] [TYPE Function]
|
||||
# @BRIEF Parse direct native_filters JSON and retain its dataMask.
|
||||
def _direct_state(value: str) -> dict:
|
||||
try:
|
||||
state = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Invalid native_filters JSON") from exc
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError("Invalid native_filters state")
|
||||
data_mask = state.get("dataMask", state)
|
||||
if not isinstance(data_mask, dict):
|
||||
raise ValueError("Invalid native_filters dataMask")
|
||||
return data_mask
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.DirectState
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.State [C:3] [TYPE Function]
|
||||
# @BRIEF Load filter state through the configured client and reject ambiguous or foreign dashboard state.
|
||||
async def _state(
|
||||
client: Any, kind: str, reference: str, query: dict[str, list[str]],
|
||||
dashboard_id: int, dashboard_uuid: str | None,
|
||||
) -> dict:
|
||||
modes = [name for name in ("native_filters_key", "native_filters") if name in query]
|
||||
if kind == "permalink":
|
||||
if modes:
|
||||
raise ValueError("Ambiguous or invalid permalink")
|
||||
return await _permalink_state(client, reference, dashboard_id, dashboard_uuid)
|
||||
if len(modes) > 1:
|
||||
raise ValueError("Ambiguous native filter state")
|
||||
if not modes:
|
||||
return {}
|
||||
values = query[modes[0]]
|
||||
if len(values) != 1:
|
||||
raise ValueError("Ambiguous native filter state")
|
||||
if modes[0] == "native_filters_key":
|
||||
return await _key_state(client, dashboard_id, values[0])
|
||||
return _direct_state(values[0])
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.State
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.EffectiveState [C:4] [TYPE Function]
|
||||
# @BRIEF Merge dashboard defaultDataMask with URL state by filter ID; an explicit empty URL entry clears a default.
|
||||
# @RATIONALE Superset initializes dashboard filters from defaultDataMask, then applies URL dataMask entries.
|
||||
# @REJECTED Treating a missing URL entry as an empty filter set loses dashboard defaults; treating an explicit empty entry as missing resurrects a cleared default.
|
||||
def _effective_state(dashboard: dict, url_state: dict) -> dict:
|
||||
if not isinstance(dashboard, dict):
|
||||
raise ValueError("Dashboard filter definitions are unavailable")
|
||||
metadata = dashboard.get("json_metadata")
|
||||
try:
|
||||
metadata = json.loads(metadata) if isinstance(metadata, str) else metadata
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Dashboard filter definitions are invalid") from exc
|
||||
if not isinstance(metadata, dict):
|
||||
raise ValueError("Dashboard filter definitions are invalid")
|
||||
definitions = metadata.get("native_filter_configuration", [])
|
||||
if not isinstance(definitions, list):
|
||||
raise ValueError("Dashboard filter definitions are invalid")
|
||||
defaults = {}
|
||||
for definition in definitions:
|
||||
if not isinstance(definition, dict) or not isinstance(definition.get("id"), str):
|
||||
raise ValueError("Dashboard filter definition is invalid")
|
||||
default = definition.get("defaultDataMask")
|
||||
if default is not None:
|
||||
if not isinstance(default, dict):
|
||||
raise ValueError("Dashboard default native filter state is invalid")
|
||||
defaults[definition["id"]] = default
|
||||
if definition.get("filterType") not in {
|
||||
"filter_select", "filter_range", "filter_date", "filter_time", "filter_time_grain",
|
||||
} and (default or definition["id"] in url_state):
|
||||
raise ValueError("Unsupported active native filter type")
|
||||
return {**defaults, **url_state}
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.EffectiveState
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.Context [C:4] [TYPE Function]
|
||||
# @BRIEF Resolve authoritative dashboard metadata and normalized URL filter state.
|
||||
async def _reference_context(client: Any, environment_id: str, dashboard_id: int, url: str):
|
||||
kind, reference, query = _parse_url(url, client.env.url)
|
||||
model = await inspect_dashboard_query_model(client, environment_id, dashboard_id, strict_identity=True)
|
||||
if model.query_model_fingerprint == "sha256:error":
|
||||
raise ValueError("Dashboard inspection failed")
|
||||
if kind == "dashboard" and reference not in {str(model.dashboard_id), model.slug}:
|
||||
raise ValueError("Reference URL dashboard differs from authoritative model")
|
||||
response = await client.get_dashboard(dashboard_id)
|
||||
dashboard = response.get("result", response) if isinstance(response, dict) else None
|
||||
if not isinstance(dashboard, dict) or str(dashboard.get("id")) != str(dashboard_id):
|
||||
raise ValueError("Dashboard filter definitions are unavailable")
|
||||
state = await _state(client, kind, reference, query, dashboard_id, dashboard.get("uuid"))
|
||||
state = _effective_state(dashboard, state)
|
||||
if len(json.dumps(state, default=str).encode("utf-8")) > _MAX_STATE_BYTES:
|
||||
raise ValueError("Native filter state exceeds preview size limit")
|
||||
return model, _normalize_state(state, model)
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.Context
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.MetricIdentity [C:2] [TYPE Function]
|
||||
# @BRIEF Deduplicate recommendations by metric formula, excluding Superset's chart-local option names.
|
||||
def _metric_identity(dataset_id: int, metric: Any) -> tuple[int, str]:
|
||||
specification = metric.chart_spec
|
||||
if not isinstance(specification, dict):
|
||||
return dataset_id, metric.metric_name
|
||||
formula = {key: value for key, value in specification.items()
|
||||
if key not in {"optionName", "isNew", "hasCustomLabel", "label"}}
|
||||
column = formula.get("column")
|
||||
if isinstance(column, dict):
|
||||
formula["column"] = {key: column.get(key) for key in ("column_name", "expression")
|
||||
if column.get(key) is not None}
|
||||
identity = json.dumps(formula, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return dataset_id, identity
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.MetricIdentity
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.MetricSelected [C:2] [TYPE Function]
|
||||
# @BRIEF Apply optional metric and coordinate selections to a model chart metric.
|
||||
def _metric_selected(chart: Any, metric: Any, metric_keys: set[str] | None,
|
||||
selected_coordinates: set[tuple[int, int, str]] | None) -> bool:
|
||||
if metric_keys is not None and metric.metric_name not in metric_keys:
|
||||
return False
|
||||
coordinate = (chart.chart_id, chart.dataset_id, metric.metric_name)
|
||||
return selected_coordinates is None or coordinate in selected_coordinates
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.MetricSelected
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.Coordinate [C:3] [TYPE Function]
|
||||
# @BRIEF Build a metric preview item and recommendation reason from authoritative chart metadata.
|
||||
def _coordinate(chart: Any, metric: Any, dataset: Any, filters: Any, reason: str | None) -> dict:
|
||||
return {
|
||||
"chart_id": chart.chart_id, "chart_name": chart.slice_name,
|
||||
"dataset_id": chart.dataset_id, "dataset_name": dataset.dataset_name,
|
||||
"metric": metric.metric_name, "metric_label": metric.label,
|
||||
"filter_ids": [f.filter_id for f in filters.filters if chart.chart_id in f.target_chart_ids],
|
||||
"value": None, "evidence_digest": None, "error": None,
|
||||
"value_digest": None, "execution_status": "skipped_budget",
|
||||
"recommended": reason is not None, "recommendation_reason": reason,
|
||||
}
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.Coordinate
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.ExecuteCoordinate [C:4] [TYPE Function]
|
||||
# @BRIEF Execute one bounded chart query and attach value, response digest, and visible errors.
|
||||
async def _execute_coordinate(client: Any, environment_id: str, dashboard_id: int, chart: Any,
|
||||
metric: Any, filters: Any, model: Any, item: dict) -> None:
|
||||
request = ExecuteQueryRequest(
|
||||
environment_id=environment_id, dashboard_id=dashboard_id,
|
||||
chart_id=chart.chart_id, dataset_id=chart.dataset_id,
|
||||
result_key=metric.metric_name, normalized_filters=filters,
|
||||
query_model_fingerprint=model.query_model_fingerprint, max_rows=1,
|
||||
)
|
||||
try:
|
||||
envelope = await execute_dashboard_query_envelope(client, request, model)
|
||||
if len(envelope.raw_response_content) > _MAX_RESPONSE_BYTES:
|
||||
raise ValueError("Chart-data response exceeds preview size limit")
|
||||
item["evidence_digest"] = envelope.source_response_hash
|
||||
item["value"] = envelope.normalized_value.canonical_value
|
||||
item["execution_status"] = "executed" if item["value"] is not None else "no_value"
|
||||
if item["value"] is not None:
|
||||
stable = json.dumps({
|
||||
"chart_id": chart.chart_id, "dataset_id": chart.dataset_id,
|
||||
"metric": metric.metric_name, "filters_hash": filters.filters_hash,
|
||||
"value": item["value"],
|
||||
}, sort_keys=True, separators=(",", ":"))
|
||||
item["value_digest"] = "sha256:" + hashlib.sha256(stable.encode()).hexdigest()
|
||||
if item["value"] is None:
|
||||
item["error"] = "Metric value absent from chart-data response"
|
||||
if envelope.normalized_value.warnings:
|
||||
item["error"] = "; ".join(w.detail for w in envelope.normalized_value.warnings)
|
||||
item["execution_status"] = "error"
|
||||
except Exception as exc:
|
||||
item["error"] = str(exc)
|
||||
item["execution_status"] = "error"
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.ExecuteCoordinate
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.Coordinates [C:4] [TYPE Function]
|
||||
# @BRIEF Collect dashboard metric coordinates and recommend distinct metrics plus ungrouped big numbers.
|
||||
async def _coordinates(client: Any, environment_id: str, dashboard_id: int, model: Any,
|
||||
filters: Any, datasets: dict, metric_keys: set[str] | None,
|
||||
dataset_ids: set[int] | None,
|
||||
selected_coordinates: set[tuple[int, int, str]] | None,
|
||||
max_queries: int) -> tuple[list[dict], int]:
|
||||
coordinates = []
|
||||
eligible = 0
|
||||
recommended_metrics: set[tuple[int, str]] = set()
|
||||
for chart in model.charts:
|
||||
if not chart.execution_capable or chart.dataset_id not in datasets:
|
||||
continue
|
||||
if dataset_ids is not None and chart.dataset_id not in dataset_ids:
|
||||
continue
|
||||
for metric in chart.metrics:
|
||||
if not metric.metric_name or not _metric_selected(chart, metric, metric_keys, selected_coordinates):
|
||||
continue
|
||||
eligible += 1
|
||||
identity = _metric_identity(chart.dataset_id, metric)
|
||||
first_occurrence = identity not in recommended_metrics
|
||||
recommended_metrics.add(identity)
|
||||
big_number = chart.viz_type in ("big_number", "big_number_total") and not chart.group_by_columns
|
||||
reason = "Big-number aggregate without grouping" if big_number else (
|
||||
"First occurrence of this metric on the dataset" if first_occurrence else None
|
||||
)
|
||||
if len(coordinates) >= _MAX_METADATA_COORDINATES:
|
||||
continue
|
||||
item = _coordinate(chart, metric, datasets[chart.dataset_id], filters, reason)
|
||||
if len(coordinates) < max_queries:
|
||||
await _execute_coordinate(client, environment_id, dashboard_id, chart, metric, filters, model, item)
|
||||
coordinates.append(item)
|
||||
return coordinates, eligible
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.Coordinates
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.Preview [C:3] [TYPE Function]
|
||||
# @BRIEF Assemble bounded preview metadata without writing baseline state.
|
||||
def _preview(environment_id: str, dashboard_id: int, model: Any, filters: Any,
|
||||
coordinates: list[dict], eligible: int, max_queries: int) -> dict:
|
||||
return {
|
||||
"environment_id": environment_id, "dashboard_id": dashboard_id,
|
||||
"dashboard_title": model.title, "query_model_fingerprint": model.query_model_fingerprint,
|
||||
"filters": filters.model_dump(mode="json"), "coordinates": coordinates,
|
||||
"native_filters": [nf.model_dump(mode="json") for nf in model.native_filters[:_MAX_METADATA_COORDINATES]],
|
||||
"datasets": [dataset.model_dump(mode="json") for dataset in model.datasets[:_MAX_METADATA_COORDINATES]],
|
||||
"native_filter_count": len(model.native_filters), "dataset_count": len(model.datasets),
|
||||
"coordinate_count": eligible, "skipped_count": max(eligible - max_queries, 0),
|
||||
"metadata_truncated_count": max(eligible - _MAX_METADATA_COORDINATES, 0),
|
||||
"truncated": eligible > max_queries,
|
||||
}
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.Preview
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscovery.Discover [C:4] [TYPE Function]
|
||||
# @BRIEF Return a bounded read-only preview with metric attribution, values, evidence digests, and per-coordinate errors.
|
||||
# @PRE client.env.url is the configured Superset origin; dashboard_id is selected by the caller.
|
||||
# @POST At most 20 chart-data calls, each limited to one row; no baseline writes.
|
||||
async def discover_reference_dashboard(
|
||||
client: Any, environment_id: str, dashboard_id: int, url: str,
|
||||
*, metric_keys: set[str] | None = None, dataset_ids: set[int] | None = None,
|
||||
selected_coordinates: set[tuple[int, int, str]] | None = None,
|
||||
max_queries: int = _MAX_QUERIES,
|
||||
) -> dict:
|
||||
if max_queries < 1 or max_queries > _MAX_QUERIES:
|
||||
raise ValueError("max_queries must be between 1 and 20")
|
||||
model, filters = await _reference_context(client, environment_id, dashboard_id, url)
|
||||
datasets = {dataset.dataset_id: dataset for dataset in model.datasets}
|
||||
if dataset_ids is not None and not dataset_ids.issubset(datasets):
|
||||
raise ValueError("Selected dataset is absent from authoritative dashboard model")
|
||||
coordinates, eligible = await _coordinates(
|
||||
client, environment_id, dashboard_id, model, filters, datasets,
|
||||
metric_keys, dataset_ids, selected_coordinates, max_queries,
|
||||
)
|
||||
return _preview(environment_id, dashboard_id, model, filters, coordinates, eligible, max_queries)
|
||||
# #endregion BaselineEngine.ReferenceDiscovery.Discover
|
||||
|
||||
# #endregion BaselineEngine.ReferenceDiscovery
|
||||
@@ -0,0 +1,152 @@
|
||||
# #region BaselineEngine.ReferenceDiscoveryFilters [C:5] [TYPE Module] [SEMANTICS baseline,filter,normalization]
|
||||
# @defgroup BaselineEngine.ReadOnlyFilterNormalization Validate and normalize native filter state against a fresh dashboard query model.
|
||||
# @INVARIANT Filter IDs, chart scope, dataset identity, and columns come from the authoritative query model.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.schemas.dashboard_testing import FilterValue, NormalizedFilter
|
||||
from src.services.dashboard_testing.filters import normalize_filters
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscoveryFilters.FilterInput [C:4] [TYPE Function]
|
||||
# @BRIEF Bind one active native filter entry to its typed Superset comparison.
|
||||
def _filter_input(filter_id: str, entry: dict, nf: Any, model: Any) -> list[NormalizedFilter]:
|
||||
extra = entry.get("extraFormData", {})
|
||||
filter_state = entry.get("filterState", {})
|
||||
if not isinstance(extra, dict) or not isinstance(filter_state, dict):
|
||||
raise ValueError(f"Invalid native filter state: {filter_id}")
|
||||
value = filter_state.get("value")
|
||||
if value in (None, "", []) and not extra:
|
||||
return []
|
||||
known_datasets = {dataset.dataset_id for dataset in model.datasets}
|
||||
if not nf.targets or nf.dataset_id not in known_datasets:
|
||||
raise ValueError(f"Native filter has no verified chart scope: {filter_id}")
|
||||
supported = {"time_range", "filters", "granularity_sqla", "time_grain_sqla"}
|
||||
if extra and (len(extra) != 1 or not set(extra).issubset(supported)):
|
||||
raise ValueError(f"Unsupported native filter payload: {filter_id}")
|
||||
if nf.type in ("DATE", "TIME", "TIME_GRAIN", "NUMERIC") and not extra:
|
||||
raise ValueError(f"Active {nf.type.lower()} filter has no executable payload: {filter_id}")
|
||||
if "filters" in extra:
|
||||
return _clause_inputs(filter_id, extra["filters"], nf)
|
||||
operator, typed = _typed_input(filter_id, extra, value, nf, model)
|
||||
return [_make_filter(filter_id, nf, operator, typed)]
|
||||
# #endregion BaselineEngine.ReferenceDiscoveryFilters.FilterInput
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscoveryFilters.TypedInput [C:4] [TYPE Function]
|
||||
# @BRIEF Decode a supported native filter value or Superset extraFormData field.
|
||||
def _typed_input(filter_id: str, extra: dict, value: Any, nf: Any, model: Any) -> tuple[str, FilterValue]:
|
||||
if "time_range" in extra:
|
||||
parts = str(extra["time_range"]).split(" : ", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Unsupported time range: {filter_id}")
|
||||
return "TEMPORAL_RANGE", FilterValue(from_=parts[0], to=parts[1])
|
||||
if "granularity_sqla" in extra and nf.type == "TIME":
|
||||
return _time_column(filter_id, extra["granularity_sqla"], nf, model)
|
||||
if "time_grain_sqla" in extra and nf.type == "TIME_GRAIN":
|
||||
grain = extra["time_grain_sqla"]
|
||||
if not isinstance(grain, str) or not grain or len(grain) > 128:
|
||||
raise ValueError(f"Invalid time grain: {filter_id}")
|
||||
return "TIME_GRAIN", FilterValue(value=grain)
|
||||
if isinstance(value, list):
|
||||
return "IN", FilterValue(values=[str(item) for item in value])
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return "EQUALS", FilterValue(value=str(value))
|
||||
raise ValueError(f"Unsupported native filter value: {filter_id}")
|
||||
# #endregion BaselineEngine.ReferenceDiscoveryFilters.TypedInput
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscoveryFilters.TimeColumn [C:3] [TYPE Function]
|
||||
# @BRIEF Confirm Superset's selected time column belongs to the filter's authoritative datasets.
|
||||
def _time_column(filter_id: str, column: Any, nf: Any, model: Any) -> tuple[str, FilterValue]:
|
||||
target_datasets = {target.dataset_id for target in nf.targets}
|
||||
known_columns = {
|
||||
entry.column_name for dataset in model.datasets if dataset.dataset_id in target_datasets
|
||||
for entry in dataset.columns
|
||||
}
|
||||
if not isinstance(column, str) or column not in known_columns:
|
||||
raise ValueError(f"Time column is absent from scoped datasets: {filter_id}")
|
||||
return "TIME_COLUMN", FilterValue(value=column)
|
||||
# #endregion BaselineEngine.ReferenceDiscoveryFilters.TimeColumn
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscoveryFilters.ClauseInputs [C:4] [TYPE Function]
|
||||
# @BRIEF Convert one or two Superset filter clauses after validating the model column.
|
||||
def _clause_inputs(filter_id: str, clauses: Any, nf: Any) -> list[NormalizedFilter]:
|
||||
if not isinstance(clauses, list) or not 1 <= len(clauses) <= 2:
|
||||
raise ValueError(f"Unsupported native filter clauses: {filter_id}")
|
||||
inputs = []
|
||||
for clause in clauses:
|
||||
if not isinstance(clause, dict) or clause.get("col") != nf.column:
|
||||
raise ValueError(f"Native filter column differs from model: {filter_id}")
|
||||
operator, typed = _clause_value(filter_id, clause)
|
||||
inputs.append(_make_filter(filter_id, nf, operator, typed))
|
||||
return inputs
|
||||
# #endregion BaselineEngine.ReferenceDiscoveryFilters.ClauseInputs
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscoveryFilters.ClauseValue [C:4] [TYPE Function]
|
||||
# @BRIEF Decode a supported Superset comparison operator and scalar or list value.
|
||||
def _clause_value(filter_id: str, clause: dict) -> tuple[str, FilterValue]:
|
||||
comparator = clause.get("val")
|
||||
if clause.get("op") == "IN" and isinstance(comparator, list):
|
||||
return "IN", FilterValue(values=[str(item) for item in comparator])
|
||||
operators = {
|
||||
"==": "EQUALS", "EQUALS": "EQUALS", ">": "GREATER_THAN",
|
||||
">=": "GREATER_THAN_OR_EQUALS", "<": "LESS_THAN",
|
||||
"<=": "LESS_THAN_OR_EQUALS",
|
||||
}
|
||||
operation = clause.get("op")
|
||||
if operation not in operators:
|
||||
raise ValueError(f"Unsupported native filter clause: {filter_id}")
|
||||
if isinstance(comparator, list) and len(comparator) == 1:
|
||||
comparator = comparator[0]
|
||||
if not isinstance(comparator, (str, int, float)) or isinstance(comparator, bool):
|
||||
raise ValueError(f"Unsupported native filter comparator: {filter_id}")
|
||||
return operators[operation], FilterValue(value=str(comparator))
|
||||
# #endregion BaselineEngine.ReferenceDiscoveryFilters.ClauseValue
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscoveryFilters.MakeFilter [C:2] [TYPE Function]
|
||||
# @BRIEF Create a normalized filter using only the model's verified scope.
|
||||
def _make_filter(filter_id: str, nf: Any, operator: str, typed: FilterValue) -> NormalizedFilter:
|
||||
return NormalizedFilter(
|
||||
filter_id=filter_id, dataset_id=nf.dataset_id, column=nf.column,
|
||||
operator=operator, value=typed,
|
||||
target_chart_ids=[target.chart_id for target in nf.targets],
|
||||
)
|
||||
# #endregion BaselineEngine.ReferenceDiscoveryFilters.MakeFilter
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscoveryFilters.IsInertChartSlot [C:3] [TYPE Function]
|
||||
# @BRIEF Accept only empty local-state slots Superset includes for a known chart.
|
||||
def _is_inert_chart_slot(filter_id: str, entry: Any, chart_ids: set[str]) -> bool:
|
||||
if filter_id not in chart_ids:
|
||||
return False
|
||||
if not isinstance(entry, dict) or str(entry.get("id")) != filter_id:
|
||||
raise ValueError(f"Unsupported chart-local filter state: {filter_id}")
|
||||
if any(entry.get(key) not in ({}, None) for key in ("extraFormData", "filterState", "ownState")):
|
||||
raise ValueError(f"Unsupported chart-local filter state: {filter_id}")
|
||||
return True
|
||||
# #endregion BaselineEngine.ReferenceDiscoveryFilters.IsInertChartSlot
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceDiscoveryFilters.NormalizeState [C:4] [TYPE Function]
|
||||
# @BRIEF Bind every supplied filter value and chart scope to the authoritative model.
|
||||
def normalize_state(state: dict, model: Any):
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError("Invalid native filter state")
|
||||
by_id = {nf.filter_id: nf for nf in model.native_filters}
|
||||
chart_ids = {str(chart.chart_id) for chart in model.charts}
|
||||
inputs = []
|
||||
for filter_id, entry in state.items():
|
||||
if filter_id not in by_id and _is_inert_chart_slot(filter_id, entry, chart_ids):
|
||||
continue
|
||||
if filter_id not in by_id or not isinstance(entry, dict):
|
||||
raise ValueError(f"Unknown or invalid native filter: {filter_id}")
|
||||
inputs.extend(_filter_input(filter_id, entry, by_id[filter_id], model))
|
||||
return normalize_filters(inputs, model)
|
||||
# #endregion BaselineEngine.ReferenceDiscoveryFilters.NormalizeState
|
||||
|
||||
# #endregion BaselineEngine.ReferenceDiscoveryFilters
|
||||
147
backend/src/services/dashboard_testing/reference_selection.py
Normal file
147
backend/src/services/dashboard_testing/reference_selection.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# #region BaselineEngine.ReferenceSelection [C:5] [TYPE Module] [SEMANTICS baseline,reference,selection,capture]
|
||||
# @defgroup BaselineEngine Capture user-selected preview coordinates as unapproved baseline candidates.
|
||||
# @INVARIANT Request values and filter payloads never enter CandidateCapture; only fresh server-derived context does.
|
||||
# @INVARIANT Every selected stable value digest must match a newly executed, same-dashboard preview before any draft is written.
|
||||
# @INVARIANT All selected captures share one caller transaction; a later failure rolls back earlier candidate rows.
|
||||
# @RATIONALE A second bounded preview prevents stale or failed preview coordinates from reaching the durable candidate lifecycle. Raw response hashes retain provenance but include transient Superset fields and cannot be used as repeatability checks.
|
||||
# @REJECTED Capturing directly from browser-supplied filter hashes or values was rejected because that would bypass authoritative Superset evidence.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.agent_run import AgentRun
|
||||
from src.models.dashboard_release import DashboardRelease
|
||||
from src.models.deployment import DeploymentRecord
|
||||
from src.models.git import GitRepository
|
||||
from src.schemas.dashboard_testing.capture import CaptureCandidateRequest
|
||||
from src.schemas.dashboard_testing.results import ComparisonPolicy
|
||||
from src.services.dashboard_testing.candidate_capture import capture_and_create_candidate
|
||||
from src.services.dashboard_testing.reference_discovery import discover_reference_dashboard
|
||||
from src.services.dashboard_testing.reference_discovery import _parse_url
|
||||
from src.services.dashboard_testing.reference_source import build_reference_source
|
||||
from src.services.agent_runs.artifacts import get_draft_storage
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceSelection.Release [C:3] [TYPE Function]
|
||||
# @BRIEF Select the latest approved/published release bound to dashboard and environment.
|
||||
def _latest_release(db: Session, dashboard_id: int, environment_id: str) -> DashboardRelease | None:
|
||||
return (
|
||||
db.query(DashboardRelease)
|
||||
.join(GitRepository, DashboardRelease.repository_id == GitRepository.id)
|
||||
.join(DeploymentRecord, DashboardRelease.deployment_id == DeploymentRecord.id)
|
||||
.filter(
|
||||
GitRepository.dashboard_id == dashboard_id,
|
||||
DeploymentRecord.environment_id == environment_id,
|
||||
DashboardRelease.status.in_(("approved", "published")),
|
||||
)
|
||||
.order_by(DashboardRelease.created_at.desc(), DashboardRelease.id.desc())
|
||||
.first()
|
||||
)
|
||||
# #endregion BaselineEngine.ReferenceSelection.Release
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceSelection.Validate [C:3] [TYPE Function]
|
||||
# @BRIEF Check that every selected coordinate still has a successful, equal-valued server preview.
|
||||
# @POST Returns fresh coordinate records keyed by chart, dataset and metric; rejects drift.
|
||||
def _validate_selection(selected: list[Any], preview: dict) -> dict[tuple[int, int, str], dict]:
|
||||
observed = {
|
||||
(item["chart_id"], item["dataset_id"], item["metric"]): item
|
||||
for item in preview["coordinates"]
|
||||
}
|
||||
for item in selected:
|
||||
key = (item.chart_id, item.dataset_id, item.metric)
|
||||
fresh = observed.get(key)
|
||||
if fresh is None or fresh["execution_status"] != "executed" or fresh["error"]:
|
||||
raise ValueError(f"Selected coordinate has no successful authoritative preview: {key}")
|
||||
if fresh["value_digest"] != item.value_digest:
|
||||
raise ValueError(f"Reference value changed since preview: {key}")
|
||||
return observed
|
||||
# #endregion BaselineEngine.ReferenceSelection.Validate
|
||||
|
||||
|
||||
# #region BaselineEngine.ReferenceSelection.Capture [C:5] [TYPE Function]
|
||||
# @BRIEF Revalidate preview evidence and create owner-scoped draft candidates for selected coordinates.
|
||||
# @PRE Exactly 1–20 selected chart/dataset/metric tuples with preview digest and status.
|
||||
# @POST Returns an AgentRun ID and candidate IDs; no baseline approval or publication.
|
||||
# @SIDE_EFFECT Writes AgentRun, capture artifacts, and draft candidates in the caller transaction.
|
||||
async def capture_reference_selection(
|
||||
db: Session, user_id: str, client: Any, environment_id: str,
|
||||
dashboard_id: int, url: str, selected: list[Any],
|
||||
expected_query_model_fingerprint: str, expected_filters_hash: str,
|
||||
) -> dict:
|
||||
coordinates = {(item.chart_id, item.dataset_id, item.metric) for item in selected}
|
||||
if not 1 <= len(selected) <= 20 or len(coordinates) != len(selected):
|
||||
raise ValueError("Select 1–20 unique chart/dataset/metric coordinates")
|
||||
if any(item.execution_status != "executed" or not item.evidence_digest or not item.value_digest for item in selected):
|
||||
raise ValueError("Only successfully executed preview coordinates can be captured")
|
||||
|
||||
preview = await discover_reference_dashboard(
|
||||
client, environment_id, dashboard_id, url,
|
||||
selected_coordinates=coordinates, max_queries=len(selected),
|
||||
)
|
||||
if preview["query_model_fingerprint"] != expected_query_model_fingerprint:
|
||||
raise ValueError("Dashboard query model changed since reference preview")
|
||||
if preview["filters"]["filters_hash"] != expected_filters_hash:
|
||||
raise ValueError("Native filter state changed since reference preview")
|
||||
observed = _validate_selection(selected, preview)
|
||||
|
||||
release = _latest_release(db, dashboard_id, environment_id)
|
||||
if release is None:
|
||||
raise LookupError("No approved or published release for this dashboard and environment")
|
||||
|
||||
run = AgentRun(
|
||||
user_id=user_id, intent="manual_reference_capture", trigger="manual",
|
||||
dashboard_id=str(dashboard_id), environment_id=environment_id,
|
||||
context_snapshot={
|
||||
"query_model_fingerprint": expected_query_model_fingerprint,
|
||||
"filters_hash": expected_filters_hash,
|
||||
"release_id": release.id,
|
||||
"selected_preview_evidence": [
|
||||
{"chart_id": item.chart_id, "dataset_id": item.dataset_id,
|
||||
"metric": item.metric,
|
||||
"source_response_hash": observed[(item.chart_id, item.dataset_id, item.metric)]["evidence_digest"],
|
||||
"value_digest": item.value_digest}
|
||||
for item in selected
|
||||
],
|
||||
},
|
||||
status="CREATED",
|
||||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
url_kind, _, query = _parse_url(url, client.env.url)
|
||||
if url_kind == "dashboard":
|
||||
url_kind = ("native_filters_key" if "native_filters_key" in query else
|
||||
"inline_native_filters" if "native_filters" in query else "dashboard")
|
||||
url_bytes = url.encode("utf-8")
|
||||
source_url_ref = get_draft_storage().store(run.id, hashlib.sha256(url_bytes).hexdigest(), url_bytes)
|
||||
reference_source = build_reference_source(
|
||||
url=url, url_kind=url_kind, source_url_ref=source_url_ref,
|
||||
environment_id=environment_id, dashboard_id=dashboard_id,
|
||||
filters=preview["filters"], query_model_fingerprint=expected_query_model_fingerprint,
|
||||
)
|
||||
run.context_snapshot = {**run.context_snapshot, "reference_source": reference_source}
|
||||
from src.schemas.dashboard_testing.filters import NormalizedFilterContext
|
||||
normalized = NormalizedFilterContext.model_validate(preview["filters"])
|
||||
candidate_ids: list[str] = []
|
||||
artifact_ids: list[str] = []
|
||||
for item in selected:
|
||||
fresh = observed[(item.chart_id, item.dataset_id, item.metric)]
|
||||
request = CaptureCandidateRequest(
|
||||
agent_run_id=run.id, release_id=release.id, dashboard_id=dashboard_id,
|
||||
chart_id=item.chart_id, dataset_id=item.dataset_id,
|
||||
result_key=item.metric,
|
||||
label=f"{fresh['chart_name']}: {fresh['metric_label']}",
|
||||
normalized_filters=normalized,
|
||||
query_model_fingerprint=expected_query_model_fingerprint,
|
||||
comparison_policy=ComparisonPolicy(type="exact"), max_rows=1,
|
||||
)
|
||||
captured = await capture_and_create_candidate(db, user_id, request, client, commit_candidate=False)
|
||||
candidate_ids.append(captured.candidate["candidate_id"])
|
||||
artifact_ids.append(captured.capture_artifact_id)
|
||||
return {"agent_run_id": run.id, "release_id": release.id,
|
||||
"candidate_ids": candidate_ids, "capture_artifact_ids": artifact_ids}
|
||||
# #endregion BaselineEngine.ReferenceSelection.Capture
|
||||
# #endregion BaselineEngine.ReferenceSelection
|
||||
109
backend/src/services/dashboard_testing/reference_source.py
Normal file
109
backend/src/services/dashboard_testing/reference_source.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# #region BaselineEngine.ReferenceSource [C:4] [TYPE Module] [SEMANTICS baseline,reference,snapshot,validation]
|
||||
# @BRIEF Freeze and validate the server-resolved URL identity without exposing URL bytes.
|
||||
# @RATIONALE A protected URL blob and a canonical semantic digest let revisions bind the exact
|
||||
# submitted address while equivalent resolved states retain a stable semantic identity.
|
||||
# @REJECTED Using the short-link key as an execution coordinate was rejected because it can expire.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from src.services.dashboard_testing.filters import _compute_filters_hash
|
||||
from src.schemas.dashboard_testing import NormalizedFilter
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def build_reference_source(
|
||||
*, url: str, url_kind: str, source_url_ref: str, environment_id: str,
|
||||
dashboard_id: int, filters: dict[str, Any], query_model_fingerprint: str,
|
||||
) -> dict[str, Any]:
|
||||
snapshot = {
|
||||
"source_url_ref": source_url_ref,
|
||||
"source_url_sha256": hashlib.sha256(url.encode("utf-8")).hexdigest(),
|
||||
"url_kind": url_kind,
|
||||
"environment_id": environment_id,
|
||||
"dashboard_id": dashboard_id,
|
||||
"resolved_native_filters": filters["filters"],
|
||||
"filters_hash": filters["filters_hash"],
|
||||
"query_model_fingerprint": query_model_fingerprint,
|
||||
"resolved_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
snapshot["reference_digest"] = _digest({
|
||||
"environment_id": environment_id, "dashboard_id": dashboard_id,
|
||||
"resolved_native_filters": snapshot["resolved_native_filters"],
|
||||
"filters_hash": snapshot["filters_hash"],
|
||||
"query_model_fingerprint": query_model_fingerprint,
|
||||
})
|
||||
require_reference_source(snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
def require_reference_source(source: Any, *, environment_id: str | None = None,
|
||||
dashboard_id: int | None = None,
|
||||
filters_hash: str | None = None,
|
||||
agent_run_id: str | None = None) -> dict[str, Any]:
|
||||
if not isinstance(source, dict):
|
||||
raise ValueError("REFERENCE_SOURCE_REQUIRED")
|
||||
required = ("source_url_ref", "source_url_sha256", "url_kind", "environment_id",
|
||||
"dashboard_id", "resolved_native_filters", "filters_hash",
|
||||
"query_model_fingerprint", "resolved_at", "reference_digest")
|
||||
if any(key not in source for key in required):
|
||||
raise ValueError("REFERENCE_SOURCE_REQUIRED")
|
||||
if source["url_kind"] not in {"permalink", "native_filters_key", "inline_native_filters", "dashboard"}:
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
for key in ("source_url_sha256", "reference_digest"):
|
||||
value = source[key]
|
||||
if not isinstance(value, str) or len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
if not isinstance(source["source_url_ref"], str) or not source["source_url_ref"].startswith("draft:"):
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
if agent_run_id is not None and not source["source_url_ref"].startswith(f"draft:{agent_run_id}:"):
|
||||
raise ValueError("REFERENCE_SOURCE_MISMATCH")
|
||||
if not isinstance(source["dashboard_id"], int) or source["dashboard_id"] < 1:
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
if not isinstance(source["environment_id"], str) or not source["environment_id"]:
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
if not isinstance(source["resolved_native_filters"], list):
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
try:
|
||||
parsed_filters = [NormalizedFilter.model_validate(item) for item in source["resolved_native_filters"]]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID") from exc
|
||||
if source["filters_hash"] != _compute_filters_hash(parsed_filters):
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
if not isinstance(source["query_model_fingerprint"], str) or not re.fullmatch(r"sha256:[a-f0-9]{64}", source["query_model_fingerprint"]):
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
if not isinstance(source["resolved_at"], str):
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
try:
|
||||
datetime.fromisoformat(source["resolved_at"].replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID") from exc
|
||||
if _digest({key: source[key] for key in ("environment_id", "dashboard_id", "resolved_native_filters",
|
||||
"filters_hash", "query_model_fingerprint")}) != source["reference_digest"]:
|
||||
raise ValueError("REFERENCE_SOURCE_INVALID")
|
||||
if environment_id is not None and source["environment_id"] != environment_id:
|
||||
raise ValueError("REFERENCE_SOURCE_MISMATCH")
|
||||
if dashboard_id is not None and source["dashboard_id"] != dashboard_id:
|
||||
raise ValueError("REFERENCE_SOURCE_MISMATCH")
|
||||
if filters_hash is not None and source["filters_hash"] != filters_hash:
|
||||
raise ValueError("REFERENCE_SOURCE_MISMATCH")
|
||||
return source
|
||||
|
||||
|
||||
def verify_reference_url_bytes(source: dict[str, Any]) -> None:
|
||||
from src.services.agent_runs.artifacts import get_draft_storage
|
||||
|
||||
try:
|
||||
url_bytes = get_draft_storage().retrieve(source["source_url_ref"])
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ValueError("REFERENCE_URL_UNAVAILABLE") from exc
|
||||
if url_bytes is None or hashlib.sha256(url_bytes).hexdigest() != source["source_url_sha256"]:
|
||||
raise ValueError("REFERENCE_URL_UNAVAILABLE")
|
||||
# #endregion BaselineEngine.ReferenceSource
|
||||
@@ -14,7 +14,7 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
_SCHEMA = Path(__file__).resolve().parent / ".." / ".." / ".." / ".." / "specs" / "038-dashboard-scenario-model" / "contracts" / "capture-profile.schema.json"
|
||||
_SCHEMA = Path(__file__).resolve().parents[5] / "specs" / "038-dashboard-scenario-model" / "contracts" / "capture-profile.schema.json"
|
||||
_PROFILE_DIR = Path(__file__).resolve().parent / "capture_profiles"
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,33 @@ class TestLifecycleDurability:
|
||||
verify_session.close()
|
||||
# #endregion Test.Api.DashboardTesting.LifecycleTests.TestCreateCandidatePersistence
|
||||
|
||||
# #region Test.Api.DashboardTesting.LifecycleTests.TestReleaseProvenance [C:2] [TYPE Function] [SEMANTICS test,baseline,release,provenance]
|
||||
# @BRIEF A verified capture release id survives candidate creation for gate validation and review.
|
||||
# @TEST_INVARIANT The release id comes from the server capture artifact, not the request body.
|
||||
def test_candidate_carries_verified_capture_release(self, dashboard_testing_client):
|
||||
session = SessionLocal()
|
||||
try:
|
||||
run = _create_agent_run(session)
|
||||
artifact_id, sha256 = _create_capture_artifact(session, run.id)
|
||||
capture = session.query(DraftArtifact).filter(DraftArtifact.id == artifact_id).one()
|
||||
capture.capture_meta = {**capture.capture_meta, "release_id": "release-from-capture"}
|
||||
session.commit()
|
||||
run_id = run.id
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
payload = _make_candidate_payload(run_id, artifact_id, sha256)
|
||||
assert "release_id" not in payload
|
||||
response = dashboard_testing_client.post("/api/dashboard-testing/baseline-candidates", json=payload)
|
||||
assert response.status_code == 201, response.text
|
||||
verify = SessionLocal()
|
||||
try:
|
||||
draft = verify.query(DraftArtifact).filter(DraftArtifact.id == response.json()["candidate_id"]).one()
|
||||
assert draft.capture_meta["release_id"] == "release-from-capture"
|
||||
finally:
|
||||
verify.close()
|
||||
# #endregion Test.Api.DashboardTesting.LifecycleTests.TestReleaseProvenance
|
||||
|
||||
# #region Test.Api.DashboardTesting.LifecycleTests.TestFullLifecyclePersistence [C:2] [TYPE Function] [SEMANTICS test,api,candidate,approval,lifecycle]
|
||||
# @BRIEF Full request→decide→consume lifecycle, verifying DB state after each step.
|
||||
# @TEST_EDGE: request_decide_consume_lifecycle -> ApprovalGate FSM persists.
|
||||
@@ -125,7 +152,7 @@ class TestLifecycleDurability:
|
||||
|
||||
response = dashboard_testing_client.post(
|
||||
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/decide",
|
||||
json={"decision": "confirm"},
|
||||
json={"decision": "confirm", "reason": "Reviewed capture and provenance"},
|
||||
)
|
||||
assert response.status_code == 200, f"Decide confirm failed: {response.text}"
|
||||
assert response.json()["status"] == "confirmed"
|
||||
@@ -212,7 +239,7 @@ class TestLifecycleDurability:
|
||||
|
||||
response = dashboard_testing_client.post(
|
||||
f"/api/dashboard-testing/baseline-candidates/{candidate_a_id}/approval-gate/{gate_a_id}/decide",
|
||||
json={"decision": "confirm"},
|
||||
json={"decision": "confirm", "reason": "Reviewed candidate A"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -225,7 +252,7 @@ class TestLifecycleDurability:
|
||||
|
||||
response = dashboard_testing_client.post(
|
||||
f"/api/dashboard-testing/baseline-candidates/{candidate_b_id}/approval-gate/{gate_b_id}/decide",
|
||||
json={"decision": "confirm"},
|
||||
json={"decision": "confirm", "reason": "Reviewed candidate B"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class TestFeature037ApprovalGuards:
|
||||
|
||||
resp = dashboard_testing_client.post(
|
||||
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/decide",
|
||||
json={"decision": "confirm"},
|
||||
json={"decision": "confirm", "reason": "Reviewed gate binding"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -137,7 +137,7 @@ class TestFeature037ApprovalGuards:
|
||||
|
||||
resp = dashboard_testing_client.post(
|
||||
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/decide",
|
||||
json={"decision": "confirm"},
|
||||
json={"decision": "confirm", "reason": "Reviewed gate binding"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ class TestClosedPeriodTransition:
|
||||
|
||||
resp = dashboard_testing_client.post(
|
||||
f"/api/dashboard-testing/baseline-candidates/{candidate_id}/approval-gate/{gate_id}/decide",
|
||||
json={"decision": "confirm"},
|
||||
json={"decision": "confirm", "reason": "Reviewed closed period"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -209,7 +209,7 @@ class TestClosedPeriodTransition:
|
||||
|
||||
resp = dashboard_testing_client.post(
|
||||
f"/api/dashboard-testing/baseline-candidates/{cand2_id}/approval-gate/{gate2_id}/decide",
|
||||
json={"decision": "confirm"},
|
||||
json={"decision": "confirm", "reason": "Reviewed closed period"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
149
backend/tests/api/test_reference_discovery_api.py
Normal file
149
backend/tests/api/test_reference_discovery_api.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# #region Test.Api.ReferenceDiscovery [C:4] [TYPE Module] [SEMANTICS baseline,reference,discovery,api,test]
|
||||
# @defgroup Tests Scenario-bound guarded REST discovery.
|
||||
# @RELATION VERIFIES -> [Api.DashboardTesting.ReferenceDiscovery.Post]
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
from src.api.routes.dashboard_testing.reference_discovery import (
|
||||
ReferenceDiscoveryRequest, reference_discovery, router,
|
||||
)
|
||||
from src.models.auth import Role, User
|
||||
|
||||
_URL = "/api/dashboard-testing/reference-discovery"
|
||||
|
||||
|
||||
def _db(environment_ids=None):
|
||||
return SimpleNamespace(get=lambda model, key: SimpleNamespace(
|
||||
dashboard_id=42, environment_ids=["preprod"] if environment_ids is None else environment_ids,
|
||||
) if key == "scenario-1" else None)
|
||||
|
||||
|
||||
def _body(url="https://superset.example/dashboard/42/", **kwargs):
|
||||
return ReferenceDiscoveryRequest(scenario_id="scenario-1", url=url, **kwargs)
|
||||
|
||||
|
||||
def _environment(env_id="preprod", url="https://superset.example"):
|
||||
return SimpleNamespace(id=env_id, url=url)
|
||||
|
||||
|
||||
# #region Test.Api.ReferenceDiscovery.Binding [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT The scenario dashboard and matching configured environment reach discovery; client body cannot override either.
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_dashboard_and_origin_are_server_bound():
|
||||
assert _URL in [route.path for route in router.routes]
|
||||
manager = SimpleNamespace(get_environments=lambda: [_environment()])
|
||||
preview = {"environment_id": "preprod", "dashboard_id": 42, "coordinates": []}
|
||||
with (
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_config_manager", return_value=manager),
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_superset_client", new=AsyncMock(return_value="configured-client")),
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.discover_reference_dashboard", new=AsyncMock(return_value=preview)) as discover,
|
||||
):
|
||||
result = await reference_discovery(_body(max_queries=2), _reader=object(), _executor=object(), db=_db())
|
||||
assert result == preview
|
||||
discover.assert_awaited_once_with(
|
||||
"configured-client", "preprod", 42,
|
||||
"https://superset.example/dashboard/42/", max_queries=2,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
ReferenceDiscoveryRequest(scenario_id="scenario-1", url="https://superset.example/dashboard/42/", dashboard_id=999)
|
||||
# #endregion Test.Api.ReferenceDiscovery.Binding
|
||||
|
||||
|
||||
# #region Test.Api.ReferenceDiscovery.PublicEvidenceDigest [C:3] [TYPE Function]
|
||||
# @TEST_INVARIANT Public preview evidence digests use the `sha256:<64 lowercase hex>` shape required by capture.
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_preview_normalizes_bare_source_hashes():
|
||||
raw_digest = "a" * 64
|
||||
already_prefixed = "sha256:" + "b" * 64
|
||||
preview = {"coordinates": [
|
||||
{"evidence_digest": raw_digest},
|
||||
{"evidence_digest": already_prefixed},
|
||||
{"evidence_digest": None},
|
||||
]}
|
||||
manager = SimpleNamespace(get_environments=lambda: [_environment()])
|
||||
with (
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_config_manager", return_value=manager),
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_superset_client", new=AsyncMock(return_value="client")),
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.discover_reference_dashboard", new=AsyncMock(return_value=preview)),
|
||||
):
|
||||
result = await reference_discovery(_body(), _reader=object(), _executor=object(), db=_db())
|
||||
assert [item["evidence_digest"] for item in result["coordinates"]] == [
|
||||
"sha256:" + raw_digest, already_prefixed, None,
|
||||
]
|
||||
# #endregion Test.Api.ReferenceDiscovery.PublicEvidenceDigest
|
||||
|
||||
|
||||
# #region Test.Api.ReferenceDiscovery.InvalidOrigin [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Unknown or ambiguous origins reject before any SupersetClient construction.
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_rejection_and_conflict():
|
||||
cases = [
|
||||
([_environment()], "https://evil.example/dashboard/42/", 422, "UNKNOWN_REFERENCE_ORIGIN"),
|
||||
([_environment("one"), _environment("two")], "https://superset.example/dashboard/42/", 409, "AMBIGUOUS_REFERENCE_ORIGIN"),
|
||||
]
|
||||
for environments, url, status_code, code in cases:
|
||||
manager = SimpleNamespace(get_environments=lambda: environments)
|
||||
with (
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_config_manager", return_value=manager),
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_superset_client", new=AsyncMock()) as client,
|
||||
):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await reference_discovery(_body(url), _reader=object(), _executor=object(), db=_db())
|
||||
assert error.value.status_code == status_code
|
||||
assert error.value.detail["code"] == code
|
||||
client.assert_not_awaited()
|
||||
# #endregion Test.Api.ReferenceDiscovery.InvalidOrigin
|
||||
|
||||
|
||||
# #region Test.Api.ReferenceDiscovery.DashboardMismatch [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT A different dashboard yields typed 422 with no partial preview.
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_mismatch_is_typed_422():
|
||||
manager = SimpleNamespace(get_environments=lambda: [_environment()])
|
||||
with (
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_config_manager", return_value=manager),
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_superset_client", new=AsyncMock(return_value="client")),
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.discover_reference_dashboard", new=AsyncMock(side_effect=ValueError("Reference URL dashboard differs from authoritative model"))),
|
||||
):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await reference_discovery(_body("https://superset.example/dashboard/43/"), _reader=object(), _executor=object(), db=_db())
|
||||
assert error.value.status_code == 422
|
||||
assert error.value.detail["code"] == "DASHBOARD_MISMATCH"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unbound_scenario_rejects_reference_environment_before_superset_io():
|
||||
manager = SimpleNamespace(get_environments=lambda: [_environment()])
|
||||
with (
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_config_manager", return_value=manager),
|
||||
patch("src.api.routes.dashboard_testing.reference_discovery.get_superset_client", new=AsyncMock()) as client,
|
||||
):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await reference_discovery(_body(), _reader=object(), _executor=object(), db=_db([]))
|
||||
assert error.value.detail["code"] == "ENVIRONMENT_MISMATCH"
|
||||
client.assert_not_awaited()
|
||||
# #endregion Test.Api.ReferenceDiscovery.DashboardMismatch
|
||||
|
||||
|
||||
# #region Test.Api.ReferenceDiscovery.Rbac [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Both READ and EXECUTE dependencies reject an unprivileged principal.
|
||||
def test_read_and_execute_permissions_are_required():
|
||||
parameters = inspect.signature(reference_discovery).parameters
|
||||
viewer_role = Role(id="reference-viewer", name="Viewer", is_admin=False)
|
||||
viewer = User(id="reference-viewer", username="viewer", email="viewer@test.com")
|
||||
viewer.roles = [viewer_role]
|
||||
for name, action in (("_reader", "READ"), ("_executor", "EXECUTE")):
|
||||
checker = parameters[name].default.dependency
|
||||
with pytest.raises(HTTPException) as error:
|
||||
checker(viewer)
|
||||
assert error.value.status_code == 403
|
||||
assert action in error.value.detail
|
||||
# #endregion Test.Api.ReferenceDiscovery.Rbac
|
||||
# #endregion Test.Api.ReferenceDiscovery
|
||||
116
backend/tests/api/test_reference_selection_api.py
Normal file
116
backend/tests/api/test_reference_selection_api.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# #region Test.Api.ReferenceSelection [C:4] [TYPE Module] [SEMANTICS baseline,reference,selection,api,test]
|
||||
# @defgroup Tests Guarded reference selection capture route.
|
||||
# @RELATION VERIFIES -> [Api.DashboardTesting.ReferenceSelection.Post]
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
from src.api.routes.dashboard_testing.reference_selection import (
|
||||
ReferenceSelectionCaptureRequest, capture_reference_selection_endpoint, router,
|
||||
)
|
||||
from src.models.auth import Role, User
|
||||
|
||||
_FP = "sha256:" + "a" * 64
|
||||
_HASH = "sha256:" + "b" * 64
|
||||
_DIGEST = "sha256:" + "c" * 64
|
||||
_VALUE_DIGEST = "sha256:" + "d" * 64
|
||||
|
||||
|
||||
def _body(**overrides):
|
||||
data = {
|
||||
"scenario_id": "scenario-1", "url": "https://superset.example/dashboard/42/",
|
||||
"selected": [{"chart_id": 11, "dataset_id": 7, "metric": "sum_sales",
|
||||
"evidence_digest": _DIGEST, "value_digest": _VALUE_DIGEST,
|
||||
"execution_status": "executed"}],
|
||||
"expected_query_model_fingerprint": _FP, "expected_filters_hash": _HASH,
|
||||
}
|
||||
data.update(overrides)
|
||||
return ReferenceSelectionCaptureRequest(**data)
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self):
|
||||
self.committed = False
|
||||
self.rolled_back = False
|
||||
|
||||
def get(self, model, key):
|
||||
return SimpleNamespace(dashboard_id=42, environment_ids=["preprod"]) if key == "scenario-1" else None
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def rollback(self):
|
||||
self.rolled_back = True
|
||||
|
||||
|
||||
# #region Test.Api.ReferenceSelection.Success [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Route passes only persisted dashboard and writer owner into the capture service.
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_binds_scenario_dashboard_and_commits():
|
||||
assert "/api/dashboard-testing/reference-selection/capture" in [route.path for route in router.routes]
|
||||
db = _DB()
|
||||
env = SimpleNamespace(id="preprod", url="https://superset.example")
|
||||
with (
|
||||
patch("src.api.routes.dashboard_testing.reference_selection._resolve_origin", return_value=env),
|
||||
patch("src.api.routes.dashboard_testing.reference_selection.get_superset_client", new=AsyncMock(return_value="client")),
|
||||
patch("src.api.routes.dashboard_testing.reference_selection.capture_reference_selection", new=AsyncMock(return_value={"candidate_ids": ["candidate-1"]})) as capture,
|
||||
):
|
||||
result = await capture_reference_selection_endpoint(
|
||||
_body(), _reader=object(), _executor=object(), _writer=SimpleNamespace(id="user-1"), db=db,
|
||||
)
|
||||
assert result == {"candidate_ids": ["candidate-1"]}
|
||||
assert db.committed
|
||||
assert capture.await_args.args[1:5] == ("user-1", "client", "preprod", 42)
|
||||
with pytest.raises(ValidationError):
|
||||
_body(dashboard_id=999)
|
||||
with pytest.raises(ValidationError):
|
||||
_body(selected=[{"chart_id": 11, "dataset_id": 7, "metric": "sum_sales",
|
||||
"evidence_digest": _DIGEST, "value_digest": _VALUE_DIGEST,
|
||||
"execution_status": "skipped_budget"}])
|
||||
# #endregion Test.Api.ReferenceSelection.Success
|
||||
|
||||
|
||||
# #region Test.Api.ReferenceSelection.Errors [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Missing release is typed 409; dashboard drift is typed 422 and transaction rolls back.
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure,status_code,code", [
|
||||
(LookupError("No approved release"), 409, "RELEASE_PREREQUISITE_MISSING"),
|
||||
(ValueError("Reference URL dashboard differs from authoritative model"), 422, "DASHBOARD_MISMATCH"),
|
||||
])
|
||||
async def test_typed_errors_rollback(failure, status_code, code):
|
||||
db = _DB()
|
||||
with (
|
||||
patch("src.api.routes.dashboard_testing.reference_selection._resolve_origin", return_value=SimpleNamespace(id="preprod")),
|
||||
patch("src.api.routes.dashboard_testing.reference_selection.get_superset_client", new=AsyncMock(return_value="client")),
|
||||
patch("src.api.routes.dashboard_testing.reference_selection.capture_reference_selection", new=AsyncMock(side_effect=failure)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await capture_reference_selection_endpoint(
|
||||
_body(), _reader=object(), _executor=object(), _writer=SimpleNamespace(id="user-1"), db=db,
|
||||
)
|
||||
assert error.value.status_code == status_code
|
||||
assert error.value.detail["code"] == code
|
||||
assert db.rolled_back and not db.committed
|
||||
# #endregion Test.Api.ReferenceSelection.Errors
|
||||
|
||||
|
||||
# #region Test.Api.ReferenceSelection.Rbac [C:3] [TYPE Function]
|
||||
# @TEST_INVARIANT The route declares READ, EXECUTE and WRITE guards.
|
||||
def test_capture_requires_read_execute_write():
|
||||
parameters = inspect.signature(capture_reference_selection_endpoint).parameters
|
||||
role = Role(id="reference-viewer", name="Viewer", is_admin=False)
|
||||
viewer = User(id="reference-viewer", username="viewer", email="viewer@test.com")
|
||||
viewer.roles = [role]
|
||||
for name, action in (("_reader", "READ"), ("_executor", "EXECUTE"), ("_writer", "WRITE")):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
parameters[name].default.dependency(viewer)
|
||||
assert error.value.status_code == 403
|
||||
assert action in error.value.detail
|
||||
# #endregion Test.Api.ReferenceSelection.Rbac
|
||||
# #endregion Test.Api.ReferenceSelection
|
||||
@@ -342,8 +342,8 @@ async def test_delete_database_not_found():
|
||||
# ── Chart data: execute_chart_data filter handling ───────────────────────────
|
||||
|
||||
# #region Test.SupersetClient.TestChartData.Filters.Simple [C:2] [TYPE Function] [SEMANTICS test,chart-data,filters]
|
||||
# @BRIEF execute_chart_data preserves simple EQUALS filters with subject/comparator.
|
||||
# @TEST_EDGE: chart_data_simple_filter -> subject/comparator pass through correctly.
|
||||
# @BRIEF execute_chart_data maps guarded EQUALS filters to ChartDataFilterSchema col/op/val.
|
||||
# @TEST_EDGE: chart_data_simple_filter -> live-compatible col/op/val shape.
|
||||
@pytest.mark.asyncio
|
||||
async def test_chart_data_simple_filter():
|
||||
"""Simple EQUALS filter is preserved in the chart-data payload."""
|
||||
@@ -375,16 +375,15 @@ async def test_chart_data_simple_filter():
|
||||
payload = json.loads(call_kwargs.kwargs["data"])
|
||||
queries = payload["queries"]
|
||||
assert len(queries) == 1
|
||||
assert "datasource" not in queries[0]
|
||||
adhoc = queries[0]["filters"]
|
||||
assert len(adhoc) == 1
|
||||
assert adhoc[0]["subject"] == "revenue"
|
||||
assert adhoc[0]["comparator"] == 100
|
||||
assert adhoc[0]["operator"] == "=="
|
||||
assert adhoc == [{"col": "revenue", "op": "==", "val": 100}]
|
||||
# #endregion Test.SupersetClient.TestChartData.Filters.Simple
|
||||
|
||||
# #region Test.SupersetClient.TestChartData.Filters.TemporalRange [C:2] [TYPE Function] [SEMANTICS test,chart-data,filters,temporal]
|
||||
# @BRIEF execute_chart_data preserves TEMPORAL_RANGE filters with from/to boundaries.
|
||||
# @TEST_EDGE: chart_data_temporal_filter -> from/to boundaries pass through.
|
||||
# @BRIEF execute_chart_data places TEMPORAL_RANGE in the chart-data time_range field.
|
||||
# @TEST_EDGE: chart_data_temporal_filter -> query has time_range and no incompatible filter clause.
|
||||
@pytest.mark.asyncio
|
||||
async def test_chart_data_temporal_range_filter():
|
||||
"""TEMPORAL_RANGE filter with from/to is preserved in the chart-data payload."""
|
||||
@@ -415,11 +414,8 @@ async def test_chart_data_temporal_range_filter():
|
||||
import json
|
||||
payload = json.loads(call_kwargs.kwargs["data"])
|
||||
adhoc = payload["queries"][0]["filters"]
|
||||
assert len(adhoc) == 1
|
||||
assert adhoc[0]["operator"] == "TEMPORAL_RANGE"
|
||||
assert adhoc[0]["subject"] == "business_date"
|
||||
assert adhoc[0]["from"] == "2026-05-01"
|
||||
assert adhoc[0]["to"] == "2026-05-31"
|
||||
assert adhoc == []
|
||||
assert payload["queries"][0]["time_range"] == "2026-05-01 : 2026-05-31"
|
||||
# #endregion Test.SupersetClient.TestChartData.Filters.TemporalRange
|
||||
|
||||
# #region Test.SupersetClient.TestChartData.Filters.Empty [C:2] [TYPE Function] [SEMANTICS test,chart-data,filters,empty]
|
||||
|
||||
@@ -29,7 +29,24 @@ _ENVELOPE = {
|
||||
"baseline_set_id": "ss-prod-visual", "baseline_set_version": "1",
|
||||
"release_id": "11111111-1111-4111-8111-111111111111", "baseline_family": "a" * 64,
|
||||
"catalog_digest": "a" * 64,
|
||||
"catalog_revision": {"catalog_revision_id": "rev-001", "publication": {"state": "published"}},
|
||||
"catalog_revision": {
|
||||
"catalog_revision_id": "rev-001", "publication": {"state": "published"},
|
||||
"entry_revisions": [{
|
||||
"status": "approved",
|
||||
"entry": {"dashboard_id": 12, "provenance": {"environment": "preprod"},
|
||||
"normalized_filters": {"filters_hash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"}},
|
||||
"reference_source": {
|
||||
"source_url_ref": "draft:run:" + "1" * 64,
|
||||
"source_url_sha256": "1" * 64,
|
||||
"url_kind": "dashboard", "environment_id": "preprod", "dashboard_id": 12,
|
||||
"resolved_native_filters": [],
|
||||
"filters_hash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"query_model_fingerprint": "sha256:" + "c" * 64,
|
||||
"resolved_at": "2026-09-24T00:00:00+00:00",
|
||||
"reference_digest": "e9b86a3a327fe072fb9ddb49171cab77a782db02d09a3943314416244bfea16d",
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
_RAW = json.dumps(_ENVELOPE).encode()
|
||||
_NESTED_ONLY = json.dumps({
|
||||
|
||||
@@ -136,6 +136,29 @@ class TestDecideApproval:
|
||||
db_session.commit()
|
||||
assert result.status == "denied"
|
||||
|
||||
# @TEST_INVARIANT A reason-required denial persists its explanation and remains one-shot.
|
||||
def test_deny_reason_persisted_and_immutable(self, db_session, run_id):
|
||||
gate = request_approval(db_session, run_id, "user-1",
|
||||
operation="baseline_approval", request_hash="c" * 64,
|
||||
target_paths=["baselines.yaml"], reason_required=True,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="reason required"):
|
||||
decide_approval(db_session, run_id, gate.id, "user-1", decision="deny", reason=" ")
|
||||
result = decide_approval(db_session, run_id, gate.id, "user-1",
|
||||
decision="deny", reason="Source total needs reconciliation")
|
||||
db_session.commit()
|
||||
db_session.expire_all()
|
||||
assert result.status == "denied"
|
||||
persisted = get_gate_view(db_session, run_id, gate.id, "user-1")
|
||||
assert persisted is not None
|
||||
assert persisted.reason == "Source total needs reconciliation"
|
||||
with pytest.raises(ValueError, match="already denied"):
|
||||
decide_approval(db_session, run_id, gate.id, "user-1", decision="confirm", reason="override")
|
||||
db_session.expire_all()
|
||||
assert get_gate_view(db_session, run_id, gate.id, "user-1").reason == "Source total needs reconciliation"
|
||||
|
||||
def test_reason_required_enforced(self, db_session, run_id):
|
||||
gate = request_approval(db_session, run_id, "user-1",
|
||||
operation="baseline_approval", request_hash="b" * 64,
|
||||
|
||||
@@ -94,9 +94,24 @@ _TEST_SOURCE_RESPONSE_HASH = "abc123def456abc123def456abc123def456abc123def456ab
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture_artifact_id(db_session: Session, run_id: str) -> str:
|
||||
def capture_artifact_id(db_session: Session, run_id: str, tmp_path, monkeypatch) -> str:
|
||||
"""Create a capture execution DraftArtifact for tests."""
|
||||
from src.models.agent_run import DraftArtifact
|
||||
from src.services.agent_runs.artifacts import DraftStorage
|
||||
from src.services.dashboard_testing.reference_source import build_reference_source
|
||||
import hashlib
|
||||
|
||||
url = "https://superset.example/superset/dashboard/42/"
|
||||
url_bytes = url.encode()
|
||||
storage = DraftStorage(storage_root=tmp_path)
|
||||
monkeypatch.setattr("src.services.agent_runs.artifacts.get_draft_storage", lambda: storage)
|
||||
url_ref = storage.store(run_id, hashlib.sha256(url_bytes).hexdigest(), url_bytes)
|
||||
reference_source = build_reference_source(
|
||||
url=url, url_kind="dashboard", source_url_ref=url_ref,
|
||||
environment_id="ss-preprod", dashboard_id=42,
|
||||
filters={"filters": [], "filters_hash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"},
|
||||
query_model_fingerprint="sha256:" + "c" * 64,
|
||||
)
|
||||
|
||||
artifact = DraftArtifact(
|
||||
id=None,
|
||||
@@ -111,6 +126,7 @@ def capture_artifact_id(db_session: Session, run_id: str) -> str:
|
||||
"kind": "capture_execution",
|
||||
"chart_id": 128,
|
||||
"result_key": "sum__revenue",
|
||||
"reference_source": reference_source,
|
||||
},
|
||||
)
|
||||
db_session.add(artifact)
|
||||
@@ -133,7 +149,7 @@ def _make_request_with_capture(agent_run_id: str, capture_artifact_id: str) -> C
|
||||
label="SUM(revenue)",
|
||||
normalized_filters=NormalizedFilterContext(
|
||||
filters=[],
|
||||
filters_hash="sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
filters_hash="sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
),
|
||||
candidate_value=NormalizedValue(kind=ValueKind.DECIMAL, canonical_value="50000.00"),
|
||||
source_response_hash=_TEST_SOURCE_RESPONSE_HASH,
|
||||
|
||||
@@ -45,7 +45,21 @@ _REFRESH_PATH = (
|
||||
/ "specs" / "044-dashboard-scenario-execution" / "fixtures" / "production-contract-refresh.json"
|
||||
)
|
||||
_REFRESH = json.loads(_REFRESH_PATH.read_text(encoding="utf-8"))
|
||||
EXPECTED_PIN = _REFRESH["baseline_pin"]
|
||||
_REFERENCE_SOURCE = {
|
||||
"source_url_ref": "draft:reference-run:" + "1" * 64,
|
||||
"source_url_sha256": "1" * 64,
|
||||
"url_kind": "dashboard",
|
||||
"environment_id": "preprod",
|
||||
"dashboard_id": 12,
|
||||
"resolved_native_filters": [],
|
||||
"filters_hash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"query_model_fingerprint": "sha256:" + "c" * 64,
|
||||
"resolved_at": "2026-09-24T00:00:00+00:00",
|
||||
"reference_digest": "e9b86a3a327fe072fb9ddb49171cab77a782db02d09a3943314416244bfea16d",
|
||||
}
|
||||
EXPECTED_PIN = deepcopy(_REFRESH["baseline_pin"])
|
||||
for _entry in EXPECTED_PIN["entries"]:
|
||||
_entry["reference_source"] = deepcopy(_REFERENCE_SOURCE)
|
||||
_SCENARIO = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"
|
||||
_REVISION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"
|
||||
_GRAPH_WITH_REF = {
|
||||
@@ -67,13 +81,18 @@ _GRAPH_NO_REF = {
|
||||
# @BRIEF Published envelope identity plus CatalogRevision; YAML cannot populate these pin fields.
|
||||
def _published_snapshot() -> dict:
|
||||
pin = EXPECTED_PIN
|
||||
revision = deepcopy(_REFRESH["catalog_revision"])
|
||||
for item in revision["entry_revisions"]:
|
||||
item["reference_source"] = deepcopy(_REFERENCE_SOURCE)
|
||||
item["entry"]["normalized_filters"]["filters_hash"] = _REFERENCE_SOURCE["filters_hash"]
|
||||
item["entry"]["provenance"]["environment"] = "preprod"
|
||||
return {
|
||||
"baseline_set_id": pin["baseline_set_id"],
|
||||
"baseline_set_version": pin["baseline_set_version"],
|
||||
"release_id": pin["release_id"],
|
||||
"baseline_family": pin["baseline_family"],
|
||||
"catalog_digest": pin["catalog_digest"],
|
||||
"catalog_revision": deepcopy(_REFRESH["catalog_revision"]),
|
||||
"catalog_revision": revision,
|
||||
}
|
||||
# #endregion Test.ScenarioExecution.BaselineResolver.Fixtures
|
||||
|
||||
@@ -138,6 +157,25 @@ def test_published_catalog_resolves_hardcoded_pin():
|
||||
# #endregion Test.ScenarioExecution.BaselineResolver.PublishedPin
|
||||
|
||||
|
||||
def test_legacy_value_only_revision_cannot_be_pinned():
|
||||
snapshot = _published_snapshot()
|
||||
snapshot["catalog_revision"]["entry_revisions"][0].pop("reference_source")
|
||||
with pytest.raises(ValueError, match=BASELINE_EVIDENCE_UNAVAILABLE):
|
||||
resolve_baseline_pin(
|
||||
graph_or_plan=_GRAPH_WITH_REF, baseline_set="ss-prod-visual",
|
||||
baseline_set_version="1", published_catalog=snapshot,
|
||||
)
|
||||
|
||||
|
||||
def test_reference_environment_mismatch_blocks_admission():
|
||||
with pytest.raises(ValueError, match=BASELINE_STALE):
|
||||
resolve_baseline_pin(
|
||||
graph_or_plan=_GRAPH_WITH_REF, baseline_set="ss-prod-visual",
|
||||
baseline_set_version="1", published_catalog=_published_snapshot(),
|
||||
environment_id="production", dashboard_id=12,
|
||||
)
|
||||
|
||||
|
||||
# #region Test.ScenarioExecution.BaselineResolver.MissingSelector [C:2] [TYPE Function]
|
||||
# @BRIEF compare_to_baseline without set/version is BASELINE_MISSING and leaves no ScenarioRun row.
|
||||
def test_compare_without_baseline_set_is_missing_and_creates_no_run():
|
||||
|
||||
@@ -80,6 +80,20 @@ def _closed_block(period: str, stored_hash: str) -> dict:
|
||||
def _snapshot(period: str | None = _PERIOD) -> dict:
|
||||
"""Published envelope; the approved entry is closed for `period` when period is given."""
|
||||
revision = deepcopy(_REFRESH["catalog_revision"])
|
||||
reference_source = {
|
||||
"source_url_ref": "draft:reference-run:" + "1" * 64,
|
||||
"source_url_sha256": "1" * 64,
|
||||
"url_kind": "dashboard", "environment_id": "preprod", "dashboard_id": 12,
|
||||
"resolved_native_filters": [],
|
||||
"filters_hash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"query_model_fingerprint": "sha256:" + "c" * 64,
|
||||
"resolved_at": "2026-09-24T00:00:00+00:00",
|
||||
"reference_digest": "e9b86a3a327fe072fb9ddb49171cab77a782db02d09a3943314416244bfea16d",
|
||||
}
|
||||
for item in revision["entry_revisions"]:
|
||||
item["reference_source"] = deepcopy(reference_source)
|
||||
item["entry"]["normalized_filters"]["filters_hash"] = reference_source["filters_hash"]
|
||||
item["entry"]["provenance"]["environment"] = "preprod"
|
||||
if period is not None:
|
||||
for item in revision["entry_revisions"]:
|
||||
if item.get("status") == "approved":
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
# @TEST_EDGE: missing_scenario -> None; explicit_revision -> selected snapshot; no_revision -> None
|
||||
from __future__ import annotations
|
||||
|
||||
from src.services.dashboard_testing.editor.load import load_editor
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.services.dashboard_testing.editor.load import _dashboard_environment_context, load_editor
|
||||
|
||||
|
||||
def test_load_editor_returns_clean_revision_bound_draft(seeded_registry):
|
||||
@@ -27,4 +29,15 @@ def test_load_editor_accepts_explicit_revision(seeded_registry):
|
||||
|
||||
def test_load_editor_missing_scenario_returns_none(seeded_registry):
|
||||
assert load_editor(seeded_registry, "missing-scenario") is None
|
||||
|
||||
|
||||
def test_editor_context_links_only_bound_dashboard_and_environment(monkeypatch):
|
||||
environments = [SimpleNamespace(id="dev", name="Development", url="https://ss-dev.bebesh.ru")]
|
||||
monkeypatch.setattr("src.services.dashboard_testing.editor.load.get_config_manager",
|
||||
lambda: SimpleNamespace(get_environments=lambda: environments))
|
||||
entry = SimpleNamespace(dashboard_id=10, environment_ids=["dev", "missing"])
|
||||
assert _dashboard_environment_context(entry) == [
|
||||
{"id": "dev", "name": "Development", "dashboard_url": "https://ss-dev.bebesh.ru/superset/dashboard/10/"},
|
||||
{"id": "missing", "name": "missing", "dashboard_url": None},
|
||||
]
|
||||
# #endregion Test.ScenarioEditor.Load
|
||||
|
||||
@@ -76,6 +76,19 @@ def test_load_valid_catalog():
|
||||
# #endregion Test.DashboardTesting.BaselineCatalog.LoadValid
|
||||
|
||||
|
||||
# #region Test.DashboardTesting.BaselineCatalog.ChartAndDataset [C:2] [TYPE Function]
|
||||
# @BRIEF A chart metric with both authoritative IDs is a valid catalog coordinate.
|
||||
# @TEST_INVARIANT One metric may identify its chart and dataset together.
|
||||
def test_catalog_accepts_chart_and_dataset_ids(tmp_path):
|
||||
path = tmp_path / "baselines.yaml"
|
||||
entry = make_entry(chart_id=96, dataset_id=20)
|
||||
write_catalog(path, BaselineCatalog(schema_version=1, entries=[entry]))
|
||||
|
||||
loaded = load_catalog(path)
|
||||
assert (loaded.entries[0].chart_id, loaded.entries[0].dataset_id) == (96, 20)
|
||||
# #endregion Test.DashboardTesting.BaselineCatalog.ChartAndDataset
|
||||
|
||||
|
||||
# #region Test.DashboardTesting.BaselineCatalog.SchemaRejectsNoRelease [C:3] [TYPE Function] [SEMANTICS testing,schema,validation,release-version]
|
||||
def test_schema_rejects_entry_without_release_version():
|
||||
"""T023: Entry without release_version fails schema validation — raises ValueError.
|
||||
@@ -373,21 +386,18 @@ def test_write_catalog_with_explicit_dashboard_id():
|
||||
# #endregion Test.DashboardTesting.BaselineCatalog.WriteCatalogAcceptsValidWithDashboard
|
||||
|
||||
|
||||
# ── Schema rejects both chart_id + dataset_id ───────────────────
|
||||
# ── Schema requires at least one chart/dataset coordinate ──────
|
||||
|
||||
# #region Test.DashboardTesting.BaselineCatalog.SchemaRejectsBothIds [C:3] [TYPE Function] [SEMANTICS testing,schema,validation,both-ids]
|
||||
# @TEST_EDGE both_ids_rejected -> Schema oneOf rejects entries with both chart_id and dataset_id non-null.
|
||||
# @RATIONALE The JSON schema defines chart_id and dataset_id as alternate identities via oneOf.
|
||||
# Previously, _reconcile_entry silently dropped dataset_id when both were present,
|
||||
# masking the schema violation. Now the reconciler preserves the exact persisted
|
||||
# representation and lets the schema reject entries with both IDs set.
|
||||
def test_load_rejects_entry_with_both_chart_and_dataset_id():
|
||||
"""T025: load_catalog raises ValueError when entry has both chart_id and dataset_id non-null."""
|
||||
# #region Test.DashboardTesting.BaselineCatalog.SchemaRejectsNoIds [C:3] [TYPE Function] [SEMANTICS testing,schema,validation,coordinate]
|
||||
# @TEST_EDGE no_coordinate -> Schema rejects entries with neither chart_id nor dataset_id.
|
||||
# @RATIONALE A chart metric can carry both IDs; an entry still needs at least one concrete coordinate.
|
||||
def test_load_rejects_entry_without_chart_and_dataset_id():
|
||||
"""T025: load_catalog rejects a metric without a chart or dataset coordinate."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "baselines.yaml"
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Build raw data with BOTH chart_id AND dataset_id non-null
|
||||
# Build raw data without either chart or dataset identity.
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"dashboard": {"id": 42},
|
||||
@@ -397,8 +407,6 @@ def test_load_rejects_entry_with_both_chart_and_dataset_id():
|
||||
"release_version": "v1.0.0",
|
||||
"release_commit_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b",
|
||||
"dashboard_id": 42,
|
||||
"chart_id": 128,
|
||||
"dataset_id": 42, # Both non-null → oneOf violation
|
||||
"result_key": "sum__revenue",
|
||||
"label": "Both IDs Test",
|
||||
"normalized_filters": {"schema_version": 1, "filters": [], "filters_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||
@@ -416,16 +424,15 @@ def test_load_rejects_entry_with_both_chart_and_dataset_id():
|
||||
|
||||
with pytest.raises(ValueError, match="schema validation failed"):
|
||||
load_catalog(path)
|
||||
# #endregion Test.DashboardTesting.BaselineCatalog.SchemaRejectsBothIds
|
||||
# #endregion Test.DashboardTesting.BaselineCatalog.SchemaRejectsNoIds
|
||||
|
||||
|
||||
# #region Test.DashboardTesting.BaselineCatalog.WriteCatalogRejectsBothIds [C:3] [TYPE Function] [SEMANTICS testing,regression,write,both-ids,file-unchanged]
|
||||
# @TEST_EDGE write_catalog_both_ids -> write_catalog raises ValueError for entry with both IDs;
|
||||
# #region Test.DashboardTesting.BaselineCatalog.WriteCatalogRejectsNoIds [C:3] [TYPE Function] [SEMANTICS testing,regression,write,coordinate,file-unchanged]
|
||||
# @TEST_EDGE write_catalog_no_coordinate -> write_catalog raises ValueError for entry with neither ID;
|
||||
# existing file is unchanged on failure.
|
||||
# @RATIONALE The schema oneOf rejects entries with both chart_id and dataset_id non-null.
|
||||
# write_catalog must reject such entries and leave the existing file untouched.
|
||||
def test_write_catalog_rejects_entry_with_both_ids_file_unchanged():
|
||||
"""T041: write_catalog raises ValueError for both chart_id+dataset_id; file unchanged."""
|
||||
# @RATIONALE The schema anyOf requires one concrete chart_id or dataset_id.
|
||||
def test_write_catalog_rejects_entry_without_ids_file_unchanged():
|
||||
"""T041: write_catalog rejects a missing coordinate and preserves existing bytes."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "baselines.yaml"
|
||||
# Write an initial valid catalog
|
||||
@@ -433,7 +440,7 @@ def test_write_catalog_rejects_entry_with_both_ids_file_unchanged():
|
||||
path.write_text(initial_content)
|
||||
initial_mtime = path.stat().st_mtime_ns
|
||||
|
||||
entry = make_entry(chart_id=128, dataset_id=42)
|
||||
entry = make_entry(chart_id=None, dataset_id=None)
|
||||
catalog = BaselineCatalog(schema_version=1, entries=[entry], dashboard_id=42)
|
||||
|
||||
with pytest.raises(ValueError, match="schema validation failed"):
|
||||
@@ -442,7 +449,7 @@ def test_write_catalog_rejects_entry_with_both_ids_file_unchanged():
|
||||
# Verify file content and mtime are unchanged
|
||||
assert path.read_text() == initial_content, "File content changed after failed write"
|
||||
assert path.stat().st_mtime_ns == initial_mtime, "File mtime changed after failed write"
|
||||
# #endregion Test.DashboardTesting.BaselineCatalog.WriteCatalogRejectsBothIds
|
||||
# #endregion Test.DashboardTesting.BaselineCatalog.WriteCatalogRejectsNoIds
|
||||
|
||||
|
||||
# ── Lossless append preserves existing content ──────────────────
|
||||
|
||||
@@ -88,7 +88,7 @@ class TestResolveReleaseAuthoritative:
|
||||
assert info["env_id"] == "env-1"
|
||||
assert info["repo_key"] == "_data_my_repo"
|
||||
assert info["dash_key"] == "dash_rel-1"
|
||||
assert info["dashboard_id"] == "rel-1"
|
||||
assert info["dashboard_id"] == 7
|
||||
assert info["release"] is self._release() or info["release"].id == "rel-1"
|
||||
# #endregion Std.TestResolveReleaseAuthoritative.HappyPath
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# #region Test.CandidateCatalogIdentity [C:3] [TYPE Module] [SEMANTICS test,baseline,catalog,identity]
|
||||
# @BRIEF Catalog write/load reconciliation preserves exact source identity for review projection.
|
||||
# @TEST_INVARIANT The same captured SHA matches its loaded approved entry, while a different
|
||||
# valid SHA and malformed digest never match.
|
||||
from src.api.routes.dashboard_testing.candidates import _same_source_hash
|
||||
from src.schemas.dashboard_testing.catalog import BaselineCatalog
|
||||
from src.services.dashboard_testing.baseline_catalog import load_catalog, write_catalog
|
||||
from src.services.dashboard_testing.candidate_helpers import _meta_to_entry
|
||||
|
||||
|
||||
def test_published_candidate_hash_matches_after_catalog_round_trip(tmp_path) -> None:
|
||||
source_hash = "f" * 64
|
||||
entry = _meta_to_entry({
|
||||
"dashboard_id": 42,
|
||||
"chart_id": 1,
|
||||
"result_key": "approved_total",
|
||||
"label": "Approved total",
|
||||
"normalized_filters": {"filters": [], "filters_hash": "sha256:" + "a" * 64},
|
||||
"candidate_value": {"kind": "integer", "canonical_value": "125", "display_value": "125"},
|
||||
"source_response_hash": source_hash,
|
||||
"comparison_policy": {"type": "exact"},
|
||||
"provenance": {"environment": "preprod", "actor": "analyst"},
|
||||
}, "v1.0.0", "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b")
|
||||
path = tmp_path / "baselines.yaml"
|
||||
write_catalog(path, BaselineCatalog(dashboard_id=42, entries=[entry]))
|
||||
loaded = load_catalog(path)
|
||||
assert len(loaded.entries) == 1
|
||||
assert loaded.entries[0].normalized_filters == entry.normalized_filters
|
||||
assert loaded.entries[0].source_response_hash == "sha256:" + source_hash
|
||||
assert _same_source_hash(loaded.entries[0].source_response_hash, source_hash)
|
||||
assert not _same_source_hash(loaded.entries[0].source_response_hash, "e" * 64)
|
||||
assert not _same_source_hash("same-invalid", "same-invalid")
|
||||
# #endregion Test.CandidateCatalogIdentity
|
||||
@@ -176,10 +176,7 @@ def test_candidate_to_entry(db_session: Session, user_id: str, run_id: str, capt
|
||||
from src.models.agent_run import DraftArtifact
|
||||
refreshed = db_session.query(DraftArtifact).filter(DraftArtifact.id == cid).first()
|
||||
meta = refreshed.capture_meta or {}
|
||||
req_data = {k: v for k, v in meta.items()
|
||||
if k not in ("gate_id", "candidate_status", "consumed_release_version",
|
||||
"consumed_release_commit_hash", "bound_release_version",
|
||||
"bound_release_commit_hash", "gate_actor", "gate_decided_at")}
|
||||
req_data = {k: v for k, v in meta.items() if k in CandidateRequest.model_fields}
|
||||
req_rebuilt = CandidateRequest(**req_data)
|
||||
consumed_candidate = BaselineCandidate(
|
||||
candidate_id=UUID(refreshed.id),
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import hashlib
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -29,6 +30,8 @@ from src.schemas.dashboard_testing import (
|
||||
BaselineCandidate,
|
||||
)
|
||||
from src.services.agent_runs.service import create_agent_run
|
||||
from src.services.agent_runs import artifacts
|
||||
from src.services.dashboard_testing.reference_source import build_reference_source
|
||||
from src.services.dashboard_testing.candidates import (
|
||||
candidate_to_entry,
|
||||
consume_approval,
|
||||
@@ -40,6 +43,19 @@ from src.services.dashboard_testing.candidates import (
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _reference_capture_meta(run_id: str) -> dict:
|
||||
url = "https://superset.example/superset/dashboard/42/"
|
||||
url_bytes = url.encode()
|
||||
url_ref = artifacts.get_draft_storage().store(run_id, hashlib.sha256(url_bytes).hexdigest(), url_bytes)
|
||||
source = build_reference_source(
|
||||
url=url, url_kind="dashboard", source_url_ref=url_ref,
|
||||
environment_id="ss-preprod", dashboard_id=42,
|
||||
filters={"filters": [], "filters_hash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"},
|
||||
query_model_fingerprint="sha256:" + "c" * 64,
|
||||
)
|
||||
return {"chart_id": 128, "result_key": "sum__revenue", "reference_source": source}
|
||||
|
||||
|
||||
# #region Test.DashboardTesting.Candidates.Guards.MissingAgentRun [C:2] [TYPE Function] [SEMANTICS test,baseline,candidates,error]
|
||||
# @BRIEF Creating a candidate with a nonexistent agent_run_id raises ValueError (before capture ref check).
|
||||
def test_create_candidate_missing_agent_run(db_session: Session, user_id: str, capture_artifact_id: str):
|
||||
@@ -175,8 +191,8 @@ def test_cross_candidate_gate_is_rejected(db_session: Session, user_id: str, run
|
||||
|
||||
# Create a second run and verify its gate isn't consumable by first run's candidate
|
||||
context = UIContextV2(
|
||||
objectType="dashboard", objectId="99", envId="prod",
|
||||
route="/dashboards/99", contextVersion=2,
|
||||
objectType="dashboard", objectId="42", envId="ss-preprod",
|
||||
route="/dashboards/42", contextVersion=2,
|
||||
intent="build_dashboard_test_scenario",
|
||||
)
|
||||
other_run = create_agent_run(db_session, CreateAgentRunRequest(context=context), user_id="other-user")
|
||||
@@ -188,7 +204,7 @@ def test_cross_candidate_gate_is_rejected(db_session: Session, user_id: str, run
|
||||
name="test-capture-other", intended_path="",
|
||||
content_ref="capture:test:other", sha256=_TEST_SOURCE_RESPONSE_HASH,
|
||||
validation_status="valid",
|
||||
capture_meta={"chart_id": 128, "result_key": "sum__revenue"},
|
||||
capture_meta=_reference_capture_meta(other_run.id),
|
||||
)
|
||||
db_session.add(other_artifact)
|
||||
db_session.flush()
|
||||
@@ -220,8 +236,8 @@ def test_decide_approval_rejects_wrong_gate_for_candidate(db_session: Session, u
|
||||
"""T038: Verify gate binding — wrong gate_id relative to candidate's bound gate raises ValueError."""
|
||||
# Create second run so we can have two pending gates (ar_request_approval rejects >1 pending per run)
|
||||
context2 = UIContextV2(
|
||||
objectType="dashboard", objectId="99", envId="prod",
|
||||
route="/dashboards/99", contextVersion=2,
|
||||
objectType="dashboard", objectId="42", envId="ss-preprod",
|
||||
route="/dashboards/42", contextVersion=2,
|
||||
intent="build_dashboard_test_scenario",
|
||||
)
|
||||
run2 = create_agent_run(db_session, CreateAgentRunRequest(context=context2), user_id=user_id)
|
||||
@@ -234,7 +250,7 @@ def test_decide_approval_rejects_wrong_gate_for_candidate(db_session: Session, u
|
||||
name="test-capture-run2", intended_path="",
|
||||
content_ref="capture:test:run2", sha256=_TEST_SOURCE_RESPONSE_HASH,
|
||||
validation_status="valid",
|
||||
capture_meta={"chart_id": 128, "result_key": "sum__revenue"},
|
||||
capture_meta=_reference_capture_meta(run2.id),
|
||||
)
|
||||
db_session.add(art2)
|
||||
db_session.flush()
|
||||
|
||||
@@ -37,9 +37,18 @@ from src.services.dashboard_testing.materialization import (
|
||||
|
||||
_SRC_HASH = "a" * 64
|
||||
_COMMIT = "b" * 40
|
||||
_FILTERS_HASH = "sha256:" + "c" * 64
|
||||
_FILTERS_HASH = "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"
|
||||
_ARTIFACT = "11111111-1111-4111-8111-111111111111"
|
||||
_CATALOG = "catalogs/FI-0080/baselines.yaml"
|
||||
_REFERENCE_SOURCE = {
|
||||
"source_url_ref": "draft:run:" + "1" * 64,
|
||||
"source_url_sha256": "1" * 64,
|
||||
"url_kind": "dashboard", "environment_id": "ss-preprod", "dashboard_id": 42,
|
||||
"resolved_native_filters": [], "filters_hash": _FILTERS_HASH,
|
||||
"query_model_fingerprint": "sha256:" + "c" * 64,
|
||||
"resolved_at": "2026-09-24T00:00:00+00:00",
|
||||
"reference_digest": "40039d90117e8386535c71eeb68090e8f2b20e3809653698a483b9e04a81981b",
|
||||
}
|
||||
|
||||
|
||||
def _metric_meta(**overrides) -> dict:
|
||||
@@ -55,6 +64,7 @@ def _metric_meta(**overrides) -> dict:
|
||||
"comparison_policy": {"type": "exact"},
|
||||
"provenance": {"environment": "ss-preprod", "actor": "qa_analyst"},
|
||||
"capture_artifact_ref": _ARTIFACT,
|
||||
"reference_source": _REFERENCE_SOURCE,
|
||||
}
|
||||
meta.update(overrides)
|
||||
return meta
|
||||
@@ -68,6 +78,10 @@ def _visual_meta(**overrides) -> dict:
|
||||
"expected_image_sha256": "d" * 64,
|
||||
"source_response_hash": _SRC_HASH,
|
||||
"comparison_policy": {"type": "visual_exact"},
|
||||
"normalized_filters": {"filters": [], "filters_hash": _FILTERS_HASH},
|
||||
"provenance": {"environment": "ss-preprod", "actor": "qa_analyst"},
|
||||
"capture_artifact_ref": _ARTIFACT,
|
||||
"reference_source": _REFERENCE_SOURCE,
|
||||
}
|
||||
meta.update(overrides)
|
||||
return meta
|
||||
@@ -234,8 +248,9 @@ def test_entry_revision_fields_are_server_owned_and_coordinate_stable():
|
||||
|
||||
|
||||
def test_visual_entry_without_capture_artifact_stays_null():
|
||||
entry = _meta_to_entry(_visual_meta(), "v1.0.0", _COMMIT)
|
||||
wrapper = build_entry_revisions(entry, _visual_meta())[0]
|
||||
meta = _visual_meta(capture_artifact_ref=None)
|
||||
entry = _meta_to_entry(meta, "v1.0.0", _COMMIT)
|
||||
wrapper = build_entry_revisions(entry, meta)[0]
|
||||
assert wrapper["capture_artifact_id"] is None
|
||||
assert wrapper["capture_profile_hash"] is None
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from src.schemas.dashboard_testing import (
|
||||
NormalizedValue,
|
||||
ValueKind,
|
||||
)
|
||||
from src.services.dashboard_testing.query_executor import execute_dashboard_query
|
||||
from src.services.dashboard_testing.query_executor import execute_dashboard_query, execute_dashboard_query_envelope
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -121,6 +121,20 @@ async def test_superset_error_preserved():
|
||||
assert result.kind == ValueKind.UNKNOWN
|
||||
assert len(result.warnings) > 0
|
||||
assert "SUPERSET" in result.warnings[0].code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chart_data_error_body_cannot_become_empty_baseline_value():
|
||||
client = AsyncMock()
|
||||
client.execute_chart_data_raw = AsyncMock(return_value=_chart_data_response({
|
||||
"errors": [{"error_type": "GENERIC_BACKEND_ERROR", "message": "invalid query"}],
|
||||
}))
|
||||
request = ExecuteQueryRequest(
|
||||
environment_id="dev", dashboard_id=42, chart_id=128, result_key="revenue",
|
||||
normalized_filters=NormalizedFilterContext(filters=[], filters_hash="sha256:empty"),
|
||||
)
|
||||
with pytest.raises(ValueError, match="chart-data rejected"):
|
||||
await execute_dashboard_query_envelope(client, request)
|
||||
# #endregion Test.DashboardTesting.QueryExecutor.SupersetErrorTaxonomy
|
||||
|
||||
# #region Test.DashboardTesting.QueryExecutor.TemporalFilterMapping [C:3] [TYPE Function] [SEMANTICS testing,baseline,temporal,filter]
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# #region Test.QueryModel.LiveChartShape [C:4] [TYPE Module] [SEMANTICS superset,chart,form-data,scope,test]
|
||||
# @defgroup Tests Superset dashboard/charts payload with dict form_data and stale chartsInScope.
|
||||
# @RELATION VERIFIES -> [BaselineEngine.QueryModel.Inspect.ProcessChartsData]
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.dashboard_testing.query_model import inspect_dashboard_query_model
|
||||
from src.services.dashboard_testing.reference_discovery import discover_reference_dashboard
|
||||
from src.core.superset_client._chart_data import ChartDataResponse
|
||||
|
||||
|
||||
# #region Test.QueryModel.LiveChartShape.DatasetAndScope [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Form-data datasource 9__table binds charts 56/57 to dataset 9; stale chartsInScope 111/112 never controls scope.
|
||||
@pytest.mark.asyncio
|
||||
async def test_dict_form_data_dataset_metrics_and_position_scope():
|
||||
client = AsyncMock()
|
||||
client.get_dashboard.return_value = {"result": {
|
||||
"id": 11, "dashboard_title": "Orders", "slug": "orders",
|
||||
"json_metadata": json.dumps({"native_filter_configuration": [{
|
||||
"id": "NATIVE_FILTER-region", "name": "Region", "filterType": "filter_select",
|
||||
"targets": [{"datasetId": 9, "column": {"name": "region"}}],
|
||||
"scope": {"rootPath": ["ROOT_ID"], "excluded": [57]},
|
||||
"chartsInScope": [111, 112],
|
||||
}]}),
|
||||
"position_json": json.dumps({
|
||||
"ROOT_ID": {"children": ["CHART-56", "CHART-57"]},
|
||||
"CHART-56": {"meta": {"chartId": 56}},
|
||||
"CHART-57": {"meta": {"chartId": 57}},
|
||||
}),
|
||||
}}
|
||||
client.get_dashboard_charts.return_value = [
|
||||
{"id": 56, "slice_name": "Revenue", "form_data": {
|
||||
"datasource": "9__table", "viz_type": "big_number_total",
|
||||
"metrics": ["sum_sales"], "groupby": [],
|
||||
}},
|
||||
{"id": 57, "slice_name": "Orders", "form_data": {
|
||||
"datasource": "9__table", "viz_type": "table",
|
||||
"metrics": ["count_orders"], "groupby": ["region"],
|
||||
}},
|
||||
]
|
||||
client.get_dashboard_datasets.return_value = [{
|
||||
"id": 9, "table_name": "orders_fact", "columns": [
|
||||
{"column_name": "region", "type": "STRING", "filterable": True},
|
||||
], "metrics": [],
|
||||
}]
|
||||
client.env = SimpleNamespace(url="https://superset.example")
|
||||
raw = b'{"result":[{"data":[{"sum_sales":125,"count_orders":5}]}],"query_id":"q1"}'
|
||||
client.execute_chart_data_raw.return_value = ChartDataResponse(
|
||||
parsed=json.loads(raw), raw_bytes=raw,
|
||||
source_response_hash="sha256:" + hashlib.sha256(raw).hexdigest(),
|
||||
)
|
||||
model = await inspect_dashboard_query_model(client, "ss-prod", 11, strict_identity=True)
|
||||
assert [(chart.chart_id, chart.dataset_id, [m.metric_name for m in chart.metrics])
|
||||
for chart in model.charts] == [(56, 9, ["sum_sales"]), (57, 9, ["count_orders"])]
|
||||
assert model.charts[1].group_by_columns == ["region"]
|
||||
assert [(target.chart_id, target.dataset_id) for target in model.native_filters[0].targets] == [(56, 9)]
|
||||
assert model.charts[0].applied_filter_ids == ["NATIVE_FILTER-region"]
|
||||
assert model.charts[1].excluded_filter_ids == ["NATIVE_FILTER-region"]
|
||||
client.get_chart.assert_not_awaited()
|
||||
preview = await discover_reference_dashboard(
|
||||
client, "ss-prod", 11, "https://superset.example/superset/dashboard/11/",
|
||||
)
|
||||
assert [(item["chart_id"], item["dataset_id"], item["metric"], item["value"])
|
||||
for item in preview["coordinates"]] == [
|
||||
(56, 9, "sum_sales", "125"), (57, 9, "count_orders", "5"),
|
||||
]
|
||||
assert client.execute_chart_data_raw.await_count == 2
|
||||
# #endregion Test.QueryModel.LiveChartShape.DatasetAndScope
|
||||
|
||||
|
||||
# #region Test.QueryModel.LiveChartShape.InvalidDatasource [C:3] [TYPE Function]
|
||||
# @TEST_INVARIANT Untrusted datasource formats do not create executable chart/dataset attribution.
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_table_datasource_does_not_bind_dataset():
|
||||
client = AsyncMock()
|
||||
client.get_dashboard.return_value = {"result": {
|
||||
"id": 11, "dashboard_title": "Orders", "json_metadata": "{}", "position_json": "{}",
|
||||
}}
|
||||
client.get_dashboard_charts.return_value = [{
|
||||
"id": 56, "slice_name": "Unsafe", "form_data": {
|
||||
"datasource": "9__query", "metrics": ["sum_sales"],
|
||||
},
|
||||
}]
|
||||
client.get_dashboard_datasets.return_value = [{"id": 9, "table_name": "orders_fact"}]
|
||||
model = await inspect_dashboard_query_model(client, "ss-prod", 11, strict_identity=True)
|
||||
assert model.charts[0].dataset_id == 0
|
||||
assert model.charts[0].metrics[0].metric_name == "sum_sales"
|
||||
# #endregion Test.QueryModel.LiveChartShape.InvalidDatasource
|
||||
# #endregion Test.QueryModel.LiveChartShape
|
||||
@@ -0,0 +1,97 @@
|
||||
# #region Test.ReferenceBatchAtomicity [C:3] [TYPE Module] [SEMANTICS test,baseline,reference,transaction]
|
||||
# @TEST_INVARIANT A later selected metric failure leaves no earlier candidate or AgentRun row.
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.agent_run import AgentRun, DraftArtifact
|
||||
from src.schemas.dashboard_testing import CandidateRequest, ComparisonPolicy, NormalizedFilterContext, NormalizedValue, Provenance, ValueKind
|
||||
from src.services.agent_runs.artifacts import DraftStorage
|
||||
from src.services.dashboard_testing import reference_selection
|
||||
from src.services.dashboard_testing.candidates import create_candidate
|
||||
|
||||
_FINGERPRINT = "sha256:" + "a" * 64
|
||||
_FILTER_HASH = "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"
|
||||
_VALUE_DIGEST = "sha256:" + "d" * 64
|
||||
|
||||
|
||||
# #region Test.ReferenceBatchAtomicity.Failure [C:4] [TYPE Function]
|
||||
# @BRIEF Both selected coordinates defer commit, and a second capture failure rolls back the first.
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_capture_failure_rolls_back_first(db_session, tmp_path, monkeypatch):
|
||||
selected = [
|
||||
SimpleNamespace(chart_id=chart_id, dataset_id=7, metric="sum_sales",
|
||||
evidence_digest="sha256:" + "b" * 64, value_digest=_VALUE_DIGEST,
|
||||
execution_status="executed")
|
||||
for chart_id in (11, 12)
|
||||
]
|
||||
preview = {
|
||||
"query_model_fingerprint": _FINGERPRINT,
|
||||
"filters": {"filters": [], "filters_hash": _FILTER_HASH},
|
||||
"coordinates": [
|
||||
{"chart_id": item.chart_id, "dataset_id": 7, "metric": item.metric,
|
||||
"chart_name": f"Chart {item.chart_id}", "metric_label": "Sales",
|
||||
"evidence_digest": "c" * 64, "value_digest": _VALUE_DIGEST,
|
||||
"execution_status": "executed", "error": None}
|
||||
for item in selected
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(reference_selection, "discover_reference_dashboard", AsyncMock(return_value=preview))
|
||||
monkeypatch.setattr(reference_selection, "_latest_release", lambda *_: SimpleNamespace(id="release-1"))
|
||||
monkeypatch.setattr(reference_selection, "get_draft_storage", lambda: DraftStorage(str(tmp_path)))
|
||||
seen_commit_flags: list[bool] = []
|
||||
created_run_id: str | None = None
|
||||
|
||||
# #region Test.ReferenceBatchAtomicity.CaptureStub [C:2] [TYPE Function]
|
||||
# @BRIEF Persist the first draft in the caller transaction and fail the second capture.
|
||||
async def capture(db, _user_id, request, _client, *, commit_candidate=True):
|
||||
nonlocal created_run_id
|
||||
seen_commit_flags.append(commit_candidate)
|
||||
created_run_id = request.agent_run_id
|
||||
if request.chart_id == 12:
|
||||
raise ValueError("second coordinate failed")
|
||||
db.add(DraftArtifact(
|
||||
run_id=request.agent_run_id, kind="baseline_candidate", name="first",
|
||||
intended_path="", content_ref="draft:first", sha256="a" * 64,
|
||||
validation_status="pending", capture_meta={},
|
||||
))
|
||||
db.flush()
|
||||
return SimpleNamespace(candidate={"candidate_id": "first"}, capture_artifact_id="first-artifact")
|
||||
# #endregion Test.ReferenceBatchAtomicity.CaptureStub
|
||||
|
||||
monkeypatch.setattr(reference_selection, "capture_and_create_candidate", capture)
|
||||
with pytest.raises(ValueError, match="second coordinate failed"):
|
||||
await reference_selection.capture_reference_selection(
|
||||
db_session, "analyst", SimpleNamespace(env=SimpleNamespace(url="https://superset.example")),
|
||||
"ss-preprod", 42, "https://superset.example/superset/dashboard/42/",
|
||||
selected, _FINGERPRINT, _FILTER_HASH,
|
||||
)
|
||||
assert seen_commit_flags == [False, False]
|
||||
db_session.rollback()
|
||||
assert db_session.get(AgentRun, created_run_id) is None
|
||||
assert db_session.query(DraftArtifact).filter(DraftArtifact.run_id == created_run_id).count() == 0
|
||||
# #endregion Test.ReferenceBatchAtomicity.Failure
|
||||
|
||||
|
||||
# #region Test.ReferenceBatchAtomicity.DeferredCandidate [C:3] [TYPE Function]
|
||||
# @BRIEF A deferred candidate is visible in its transaction but disappears after rollback.
|
||||
def test_candidate_deferred_commit_is_rollback_safe(db_session, user_id, run_id, capture_artifact_id):
|
||||
request = CandidateRequest.model_construct(
|
||||
environment_id="ss-preprod", dashboard_id=42, repository_key="my-repo",
|
||||
dashboard_key="FI-0080", chart_id=128, result_key="sum__revenue", label="Revenue",
|
||||
normalized_filters=NormalizedFilterContext(filters=[], filters_hash=_FILTER_HASH),
|
||||
candidate_value=NormalizedValue(kind=ValueKind.DECIMAL, canonical_value="50000.00"),
|
||||
source_response_hash="abc123def456abc123def456abc123def456abc123def456abc123def456abcf",
|
||||
comparison_policy=ComparisonPolicy(type="exact"),
|
||||
provenance=Provenance(environment="ss-preprod", actor=user_id),
|
||||
agent_run_id=run_id, kind="metric", capture_artifact_ref=capture_artifact_id,
|
||||
)
|
||||
candidate = create_candidate(db_session, user_id, request, commit=False)
|
||||
assert db_session.get(DraftArtifact, candidate.draft_artifact_ref) is not None
|
||||
db_session.rollback()
|
||||
assert db_session.get(DraftArtifact, candidate.draft_artifact_ref) is None
|
||||
# #endregion Test.ReferenceBatchAtomicity.DeferredCandidate
|
||||
# #endregion Test.ReferenceBatchAtomicity
|
||||
@@ -0,0 +1,340 @@
|
||||
# #region Test.ReferenceDiscovery [C:4] [TYPE Module] [SEMANTICS baseline,reference,discovery,test]
|
||||
# @defgroup Tests Hardcoded reference URL, scope and evidence checks for read-only discovery.
|
||||
# @RELATION VERIFIES -> [BaselineEngine.ReferenceDiscovery.Discover]
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.superset_client._chart_data import ChartDataResponse
|
||||
from src.services.dashboard_testing.reference_discovery import discover_reference_dashboard
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.ClientFixture [C:3] [TYPE Function]
|
||||
# @BRIEF Provide fixed dashboard, filter and chart-data responses for URL discovery tests.
|
||||
def _client():
|
||||
client = SimpleNamespace(env=SimpleNamespace(url="https://superset.example"))
|
||||
client.get_dashboard = AsyncMock(return_value={"result": {
|
||||
"id": 42, "uuid": "d9f663dd-5b0c-4e3b-9a55-4742de6f5ea8",
|
||||
"dashboard_title": "Sales", "slug": "sales",
|
||||
"json_metadata": json.dumps({"native_filter_configuration": [{
|
||||
"id": "region", "name": "Region", "filterType": "filter_select",
|
||||
"targets": [{"datasetId": 7, "column": {"name": "region"}}],
|
||||
"scope": {"rootPath": ["ROOT"], "excluded": [12]},
|
||||
}]}),
|
||||
"position_json": json.dumps({
|
||||
"ROOT": {"children": ["CHART-11", "CHART-12"]},
|
||||
"CHART-11": {"meta": {"chartId": 11}},
|
||||
"CHART-12": {"meta": {"chartId": 12}},
|
||||
}),
|
||||
}})
|
||||
client.get_dashboard_charts = AsyncMock(return_value=[
|
||||
{"id": 11, "slice_name": "Revenue", "datasource_id": 7,
|
||||
"params": json.dumps({"metrics": ["sum_sales"]})},
|
||||
{"id": 12, "slice_name": "Orders", "datasource_id": 7,
|
||||
"params": json.dumps({"metrics": ["count_orders"]})},
|
||||
])
|
||||
client.get_dashboard_datasets = AsyncMock(return_value=[
|
||||
{"id": 7, "table_name": "sales_fact", "columns": [], "metrics": []},
|
||||
])
|
||||
client.get_chart = AsyncMock()
|
||||
client.get_dashboard_permalink_state = AsyncMock(return_value={"result": {
|
||||
"dashboard_id": 42, "state": {"dataMask": {
|
||||
"region": {"filterState": {"value": ["East"]}},
|
||||
}},
|
||||
}})
|
||||
client.get_native_filter_state = AsyncMock(return_value={"result": {"value": json.dumps({
|
||||
"region": {"filterState": {"value": ["East"]}},
|
||||
})}})
|
||||
raw = b'{"result":[{"data":[{"sum_sales":125}]}],"query_id":"q1"}'
|
||||
client.execute_chart_data_raw = AsyncMock(return_value=ChartDataResponse(
|
||||
parsed=json.loads(raw), raw_bytes=raw,
|
||||
source_response_hash="sha256:" + hashlib.sha256(raw).hexdigest(),
|
||||
))
|
||||
return client
|
||||
# #endregion Test.ReferenceDiscovery.ClientFixture
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.ValidatedPreview [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Same-origin permalink values are scoped to chart 11 and retain raw-response digest.
|
||||
@pytest.mark.asyncio
|
||||
async def test_permalink_preview_binds_scope_identity_and_evidence():
|
||||
client = _client()
|
||||
result = await discover_reference_dashboard(
|
||||
client, "preprod", 42, "https://superset.example/superset/dashboard/p/abc123/",
|
||||
metric_keys={"sum_sales"}, max_queries=1,
|
||||
)
|
||||
assert result["dashboard_id"] == 42
|
||||
assert result["filters"]["filters"][0]["target_chart_ids"] == [11]
|
||||
assert result["coordinates"] == [{
|
||||
"chart_id": 11, "chart_name": "Revenue", "dataset_id": 7,
|
||||
"dataset_name": "sales_fact", "metric": "sum_sales", "metric_label": "sum_sales",
|
||||
"filter_ids": ["region"], "value": "125",
|
||||
"evidence_digest": "sha256:" + hashlib.sha256(b'{"result":[{"data":[{"sum_sales":125}]}],"query_id":"q1"}').hexdigest(),
|
||||
"value_digest": "sha256:4ffef7713ecbe74e6db372bf23412638dc9db057e0290a2a1453422a84f1bea9",
|
||||
"error": None, "execution_status": "executed", "recommended": True,
|
||||
"recommendation_reason": "First occurrence of this metric on the dataset",
|
||||
}]
|
||||
assert client.execute_chart_data_raw.await_args.kwargs["row_limit"] == 1
|
||||
assert client.execute_chart_data_raw.await_args.kwargs["filters"][0]["subject"] == "region"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permalink_accepts_only_inert_known_chart_slots():
|
||||
client = _client()
|
||||
state = {"region": {"filterState": {"value": ["East"]}},
|
||||
"11": {"id": 11, "extraFormData": {}, "filterState": {}, "ownState": {}}}
|
||||
client.get_dashboard_permalink_state.return_value = {"dashboardId": 42, "state": {"dataMask": state}}
|
||||
url = "https://superset.example/superset/dashboard/p/abc123/"
|
||||
preview = await discover_reference_dashboard(client, "preprod", 42, url, max_queries=1)
|
||||
assert len(preview["filters"]["filters"]) == 1
|
||||
state["11"]["extraFormData"] = {"filters": [{"col": "region", "op": "IN", "val": ["West"]}]}
|
||||
with pytest.raises(ValueError, match="Unsupported chart-local filter state"):
|
||||
await discover_reference_dashboard(client, "preprod", 42, url, max_queries=1)
|
||||
# #endregion Test.ReferenceDiscovery.ValidatedPreview
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.DistinctMetricRecommendations [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Each distinct metric specification per dataset is suggested once; duplicate chart appearances are omitted.
|
||||
@pytest.mark.asyncio
|
||||
async def test_recommendations_cover_distinct_metrics_and_skip_duplicates():
|
||||
client = _client()
|
||||
charts = client.get_dashboard_charts.return_value
|
||||
charts[0]["params"] = json.dumps({"metrics": [{
|
||||
"metric_name": "sales", "label": "Sales", "expressionType": "SIMPLE",
|
||||
"aggregate": "SUM", "column": {"column_name": "amount"},
|
||||
}]})
|
||||
charts[1]["params"] = json.dumps({"metrics": [{
|
||||
"metric_name": "orders", "label": "Orders", "expressionType": "SIMPLE",
|
||||
"aggregate": "COUNT", "column": {"column_name": "order_id"},
|
||||
}]})
|
||||
url = "https://superset.example/superset/dashboard/42/"
|
||||
|
||||
distinct = await discover_reference_dashboard(client, "preprod", 42, url, max_queries=2)
|
||||
assert [item["recommended"] for item in distinct["coordinates"]] == [True, True]
|
||||
assert distinct["coordinates"][1]["recommendation_reason"] == (
|
||||
"First occurrence of this metric on the dataset"
|
||||
)
|
||||
|
||||
charts[1]["params"] = json.dumps({"metrics": [{
|
||||
"metric_name": "sales", "label": "Sales", "expressionType": "SIMPLE",
|
||||
"aggregate": "SUM", "optionName": "metric_chart_12",
|
||||
"column": {"column_name": "amount", "optionName": "_col_amount"},
|
||||
}]})
|
||||
duplicate = await discover_reference_dashboard(client, "preprod", 42, url, max_queries=2)
|
||||
assert [item["recommended"] for item in duplicate["coordinates"]] == [True, False]
|
||||
|
||||
charts[1]["params"] = json.dumps({"metrics": [{
|
||||
"metric_name": "sales", "label": "Average sales", "expressionType": "SIMPLE",
|
||||
"aggregate": "AVG", "column": {"column_name": "amount"},
|
||||
}]})
|
||||
distinct_specs = await discover_reference_dashboard(client, "preprod", 42, url, max_queries=2)
|
||||
assert [item["recommended"] for item in distinct_specs["coordinates"]] == [True, True]
|
||||
# #endregion Test.ReferenceDiscovery.DistinctMetricRecommendations
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.Rejection [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Foreign origins and dashboard identities make no chart-data call.
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://evil.example/superset/dashboard/42/?native_filters=%7B%7D",
|
||||
"https://superset.example/superset/dashboard/43/",
|
||||
"https://superset.example/superset/dashboard/p/bad/?native_filters=%7B%7D",
|
||||
])
|
||||
async def test_invalid_reference_rejected_before_execution(url):
|
||||
client = _client()
|
||||
with pytest.raises(ValueError):
|
||||
await discover_reference_dashboard(client, "preprod", 42, url)
|
||||
client.execute_chart_data_raw.assert_not_awaited()
|
||||
if "evil.example" in url:
|
||||
client.get_dashboard.assert_not_awaited()
|
||||
# #endregion Test.ReferenceDiscovery.Rejection
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.StateVariants [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Key and direct URL states bind identical filter values; failures remain visible.
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://superset.example/superset/dashboard/42/?native_filters_key=key123",
|
||||
'https://superset.example/superset/dashboard/42/?native_filters=%7B%22region%22%3A%7B%22filterState%22%3A%7B%22value%22%3A%5B%22East%22%5D%7D%7D%7D',
|
||||
])
|
||||
async def test_key_and_direct_state_with_visible_execution_failure(url):
|
||||
client = _client()
|
||||
client.execute_chart_data_raw.side_effect = RuntimeError("chart unavailable")
|
||||
result = await discover_reference_dashboard(client, "preprod", 42, url, max_queries=1)
|
||||
assert result["filters"]["filters"][0]["value"]["values"] == ["East"]
|
||||
assert result["coordinates"][0]["error"] == "chart unavailable"
|
||||
assert result["coordinates"][0]["execution_status"] == "error"
|
||||
assert result["coordinates"][0]["value"] is None
|
||||
# #endregion Test.ReferenceDiscovery.StateVariants
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.BudgetAndScope [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT A selected dataset is authoritative; the preview executes at most its explicit budget.
|
||||
@pytest.mark.asyncio
|
||||
async def test_budget_dataset_selection_and_unknown_filter_rejection():
|
||||
client = _client()
|
||||
url = "https://superset.example/superset/dashboard/42/"
|
||||
result = await discover_reference_dashboard(client, "preprod", 42, url, dataset_ids={7}, max_queries=1)
|
||||
assert result["truncated"] is True
|
||||
assert result["coordinate_count"] == 2
|
||||
assert result["skipped_count"] == 1
|
||||
assert len(result["coordinates"]) == 2
|
||||
assert result["coordinates"][1]["execution_status"] == "skipped_budget"
|
||||
assert result["coordinates"][1]["chart_id"] == 12
|
||||
assert result["native_filters"][0]["filter_id"] == "region"
|
||||
assert result["datasets"][0]["dataset_name"] == "sales_fact"
|
||||
assert client.execute_chart_data_raw.await_count == 1
|
||||
with pytest.raises(ValueError, match="Selected dataset"):
|
||||
await discover_reference_dashboard(client, "preprod", 42, url, dataset_ids={999})
|
||||
with pytest.raises(ValueError, match="Unknown or invalid native filter"):
|
||||
await discover_reference_dashboard(client, "preprod", 42, url + "?native_filters=%7B%22rogue%22%3A%7B%7D%7D")
|
||||
assert client.execute_chart_data_raw.await_count == 1
|
||||
# #endregion Test.ReferenceDiscovery.BudgetAndScope
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.InactiveKeyState [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Seven inactive key entries are skipped before scope checks while all eight filter definitions remain visible.
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_with_seven_inactive_entries_and_eight_definitions():
|
||||
client = _client()
|
||||
dashboard = client.get_dashboard.return_value["result"]
|
||||
metadata = json.loads(dashboard["json_metadata"])
|
||||
metadata["native_filter_configuration"].extend({
|
||||
"id": f"inactive-{index}", "name": f"Inactive {index}",
|
||||
"filterType": "filter_time_grain" if index == 0 else "filter_select",
|
||||
"targets": [{"datasetId": 999, "column": {"name": "missing"}}],
|
||||
"scope": {"rootPath": ["ROOT"], "excluded": []},
|
||||
} for index in range(7))
|
||||
dashboard["json_metadata"] = json.dumps(metadata)
|
||||
client.get_native_filter_state.return_value = {"result": {"value": json.dumps({
|
||||
f"inactive-{index}": {"extraFormData": {}, "filterState": {}}
|
||||
for index in range(7)
|
||||
})}}
|
||||
url = "https://superset.example/superset/dashboard/42/?standalone=true&native_filters_key=key123"
|
||||
preview = await discover_reference_dashboard(client, "preprod", 42, url, max_queries=1)
|
||||
assert preview["native_filter_count"] == 8
|
||||
assert len(preview["native_filters"]) == 8
|
||||
assert preview["filters"]["filters"] == []
|
||||
assert preview["coordinates"][0]["execution_status"] == "executed"
|
||||
assert client.execute_chart_data_raw.await_count == 1
|
||||
# #endregion Test.ReferenceDiscovery.InactiveKeyState
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.ActiveTargetless [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Active time-grain state without authoritative chart scope aborts before chart-data execution.
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_targetless_time_grain_fails_closed():
|
||||
client = _client()
|
||||
dashboard = client.get_dashboard.return_value["result"]
|
||||
metadata = json.loads(dashboard["json_metadata"])
|
||||
metadata["native_filter_configuration"].append({
|
||||
"id": "time-grain", "name": "Time grain", "filterType": "filter_time_grain",
|
||||
"targets": [{"datasetId": 999, "column": {"name": "missing"}}],
|
||||
"scope": {"rootPath": ["ROOT"], "excluded": []},
|
||||
})
|
||||
dashboard["json_metadata"] = json.dumps(metadata)
|
||||
client.get_native_filter_state.return_value = {"result": {"value": json.dumps({
|
||||
"time-grain": {"extraFormData": {"time_grain_sqla": "P1D"}, "filterState": {"value": "P1D"}},
|
||||
})}}
|
||||
with pytest.raises(ValueError, match="no verified chart scope"):
|
||||
await discover_reference_dashboard(
|
||||
client, "preprod", 42,
|
||||
"https://superset.example/superset/dashboard/42/?native_filters_key=key123",
|
||||
)
|
||||
client.execute_chart_data_raw.assert_not_awaited()
|
||||
# #endregion Test.ReferenceDiscovery.ActiveTargetless
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.DefaultsAndOverrides [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Dashboard defaults apply without URL state; URL values replace or explicitly clear them.
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_default_and_url_override():
|
||||
client = _client()
|
||||
dashboard = client.get_dashboard.return_value["result"]
|
||||
metadata = json.loads(dashboard["json_metadata"])
|
||||
metadata["native_filter_configuration"][0]["defaultDataMask"] = {
|
||||
"filterState": {"value": ["West"]},
|
||||
}
|
||||
dashboard["json_metadata"] = json.dumps(metadata)
|
||||
base = "https://superset.example/superset/dashboard/42/"
|
||||
|
||||
default = await discover_reference_dashboard(client, "preprod", 42, base, max_queries=1)
|
||||
assert default["filters"]["filters"][0]["value"]["values"] == ["West"]
|
||||
|
||||
overridden = await discover_reference_dashboard(
|
||||
client, "preprod", 42, base + "?native_filters_key=key123", max_queries=1,
|
||||
)
|
||||
assert overridden["filters"]["filters"][0]["value"]["values"] == ["East"]
|
||||
|
||||
client.get_native_filter_state.return_value = {"result": {"value": json.dumps({
|
||||
"region": {"filterState": {"value": []}, "extraFormData": {}},
|
||||
})}}
|
||||
cleared = await discover_reference_dashboard(
|
||||
client, "preprod", 42, base + "?native_filters_key=key123", max_queries=1,
|
||||
)
|
||||
assert cleared["filters"]["filters"] == []
|
||||
# #endregion Test.ReferenceDiscovery.DefaultsAndOverrides
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.UnavailableState [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Missing or invalid cache state and unsupported defaults fail before chart execution.
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("response", [
|
||||
{}, {"result": {}}, {"result": {"value": "bad-json"}},
|
||||
{"result": {"value": "null"}}, {"result": {"value": '{"region": null}'}},
|
||||
])
|
||||
async def test_expired_or_invalid_key_fails_closed(response):
|
||||
client = _client()
|
||||
client.get_native_filter_state.return_value = response
|
||||
with pytest.raises(ValueError, match="[Nn]ative filter key state"):
|
||||
await discover_reference_dashboard(
|
||||
client, "preprod", 42,
|
||||
"https://superset.example/superset/dashboard/42/?native_filters_key=key123",
|
||||
)
|
||||
client.execute_chart_data_raw.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_active_default_fails_closed():
|
||||
client = _client()
|
||||
dashboard = client.get_dashboard.return_value["result"]
|
||||
metadata = json.loads(dashboard["json_metadata"])
|
||||
metadata["native_filter_configuration"][0]["filterType"] = "filter_unknown"
|
||||
metadata["native_filter_configuration"][0]["defaultDataMask"] = {
|
||||
"filterState": {"value": ["East"]},
|
||||
}
|
||||
dashboard["json_metadata"] = json.dumps(metadata)
|
||||
with pytest.raises(ValueError, match="Unsupported active native filter type"):
|
||||
await discover_reference_dashboard(
|
||||
client, "preprod", 42, "https://superset.example/superset/dashboard/42/",
|
||||
)
|
||||
client.execute_chart_data_raw.assert_not_awaited()
|
||||
# #endregion Test.ReferenceDiscovery.UnavailableState
|
||||
|
||||
|
||||
# #region Test.ReferenceDiscovery.PermalinkUuid [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Superset permalink UUID must match the authoritative dashboard UUID.
|
||||
@pytest.mark.asyncio
|
||||
async def test_permalink_uses_authoritative_dashboard_uuid():
|
||||
client = _client()
|
||||
state = client.get_dashboard_permalink_state.return_value["result"]
|
||||
state.pop("dashboard_id")
|
||||
state["dashboardId"] = "d9f663dd-5b0c-4e3b-9a55-4742de6f5ea8"
|
||||
preview = await discover_reference_dashboard(
|
||||
client, "preprod", 42, "http://superset.example/superset/dashboard/p/abc123/",
|
||||
max_queries=1,
|
||||
)
|
||||
assert preview["dashboard_id"] == 42
|
||||
|
||||
state["dashboardId"] = "804bfb78-3195-4ef5-bddc-d1acb68c4cba"
|
||||
with pytest.raises(ValueError, match="dashboard differs"):
|
||||
await discover_reference_dashboard(
|
||||
client, "preprod", 42, "https://superset.example/superset/dashboard/p/abc123/",
|
||||
)
|
||||
# #endregion Test.ReferenceDiscovery.PermalinkUuid
|
||||
# #endregion Test.ReferenceDiscovery
|
||||
@@ -0,0 +1,84 @@
|
||||
# #region Test.ReferenceLifecycle [C:4] [TYPE Module] [SEMANTICS baseline,reference,approval,catalog,pin,test]
|
||||
# @TEST_INVARIANT A protected reference URL snapshot survives candidate approval, catalog
|
||||
# materialization, publication validation and admission pin unchanged.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.agent_run import DraftArtifact
|
||||
from src.schemas.dashboard_testing import (
|
||||
ApprovalDecisionRequest, ApprovalGateRequest, CandidateRequest, ComparisonPolicy,
|
||||
NormalizedFilterContext, NormalizedValue, Provenance, ValueKind,
|
||||
)
|
||||
from src.scripts.publish_catalog import validate_catalog
|
||||
from src.services.dashboard_testing.candidates import create_candidate, request_approval, decide_approval, consume_approval
|
||||
from src.services.dashboard_testing.catalog_revision_log import current_head
|
||||
from src.services.dashboard_testing.execution.baseline_resolver import resolve_baseline_pin
|
||||
|
||||
_CONSUME_REL = "v1.0.0"
|
||||
_CONSUME_COMMIT = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b"
|
||||
_EMPTY_FILTERS_HASH = "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"
|
||||
|
||||
|
||||
def _make_gate_request(run_id: str) -> ApprovalGateRequest:
|
||||
return ApprovalGateRequest(agent_run_id=run_id, release_version=_CONSUME_REL, release_commit_hash=_CONSUME_COMMIT)
|
||||
|
||||
|
||||
def _make_request_with_capture(run_id: str, capture_artifact_id: str) -> CandidateRequest:
|
||||
return CandidateRequest.model_construct(
|
||||
environment_id="ss-preprod", dashboard_id=42, repository_key="my-repo", dashboard_key="FI-0080",
|
||||
chart_id=128, result_key="sum__revenue", label="SUM(revenue)",
|
||||
normalized_filters=NormalizedFilterContext(filters=[], filters_hash=_EMPTY_FILTERS_HASH),
|
||||
candidate_value=NormalizedValue(kind=ValueKind.DECIMAL, canonical_value="50000.00"),
|
||||
source_response_hash="abc123def456abc123def456abc123def456abc123def456abc123def456abcf",
|
||||
comparison_policy=ComparisonPolicy(type="exact"),
|
||||
provenance=Provenance(environment="ss-preprod", actor="qa_analyst"),
|
||||
agent_run_id=run_id, kind="metric", capture_artifact_ref=capture_artifact_id,
|
||||
)
|
||||
|
||||
|
||||
def test_reference_survives_candidate_to_published_pin(db_session, user_id, run_id, capture_artifact_id, tmp_path):
|
||||
candidate = create_candidate(db_session, user_id, _make_request_with_capture(run_id, capture_artifact_id))
|
||||
draft = db_session.get(DraftArtifact, candidate.draft_artifact_ref)
|
||||
source = deepcopy(draft.capture_meta["reference_source"])
|
||||
assert source["reference_digest"] == "40039d90117e8386535c71eeb68090e8f2b20e3809653698a483b9e04a81981b"
|
||||
|
||||
gate = request_approval(db_session, user_id, draft.id, _make_gate_request(run_id))
|
||||
decide_approval(db_session, user_id, gate["gate_id"], ApprovalDecisionRequest(decision="confirm", reason="Evidence reviewed"), candidate_id=draft.id)
|
||||
consume_approval(db_session, user_id, gate["gate_id"], candidate_id=draft.id,
|
||||
release_version=_CONSUME_REL, release_commit_hash=_CONSUME_COMMIT,
|
||||
catalog_base_path=tmp_path)
|
||||
|
||||
revision = deepcopy(current_head(tmp_path / draft.intended_path))
|
||||
assert revision["entry_revisions"][0]["reference_source"] == source
|
||||
revision["publication"] = {
|
||||
"state": "published", "commit_hash": _CONSUME_COMMIT,
|
||||
"published_receipt_id": "receipt-1",
|
||||
}
|
||||
envelope = {
|
||||
"baseline_set_id": "test-set", "baseline_set_version": "1",
|
||||
"release_id": "release-1", "baseline_family": "a" * 64,
|
||||
"catalog_digest": revision["catalog_digest"], "catalog_revision": revision,
|
||||
}
|
||||
validate_catalog(json.dumps(envelope).encode())
|
||||
pin = resolve_baseline_pin(
|
||||
graph_or_plan={"steps": [{"action": "compare_to_baseline"}]},
|
||||
baseline_set="test-set", baseline_set_version="1",
|
||||
published_catalog=envelope, environment_id="ss-preprod", dashboard_id=42,
|
||||
)
|
||||
assert pin["entries"][0]["reference_source"] == source
|
||||
assert pin["entries"][0]["source_response_hash"] == "abc123def456abc123def456abc123def456abc123def456abc123def456abcf"
|
||||
|
||||
|
||||
def test_legacy_candidate_stays_inspectable_but_cannot_open_approval(db_session, user_id, run_id, capture_artifact_id):
|
||||
capture = db_session.get(DraftArtifact, capture_artifact_id)
|
||||
capture.capture_meta = {key: value for key, value in capture.capture_meta.items() if key != "reference_source"}
|
||||
db_session.commit()
|
||||
candidate = create_candidate(db_session, user_id, _make_request_with_capture(run_id, capture_artifact_id))
|
||||
assert candidate.status == "draft"
|
||||
with pytest.raises(ValueError, match="REFERENCE_SOURCE_REQUIRED"):
|
||||
request_approval(db_session, user_id, candidate.draft_artifact_ref, _make_gate_request(run_id))
|
||||
# #endregion Test.ReferenceLifecycle
|
||||
@@ -0,0 +1,186 @@
|
||||
# #region Test.ReferenceSelection [C:4] [TYPE Module] [SEMANTICS baseline,reference,selection,capture,test]
|
||||
# @defgroup Tests Selection capture drift guard and draft candidate handoff.
|
||||
# @RELATION VERIFIES -> [BaselineEngine.ReferenceSelection.Capture]
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.dashboard_release import DashboardRelease
|
||||
from src.models.deployment import DeploymentRecord
|
||||
from src.models.git import GitRepository
|
||||
from src.services.dashboard_testing.reference_selection import _latest_release, capture_reference_selection
|
||||
|
||||
_FINGERPRINT = "sha256:" + "a" * 64
|
||||
_FILTER_HASH = "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"
|
||||
_DIGEST = "sha256:" + "b" * 64
|
||||
_VALUE_DIGEST = "sha256:" + "d" * 64
|
||||
|
||||
|
||||
def _selected(value_digest=_VALUE_DIGEST, status="executed"):
|
||||
return SimpleNamespace(chart_id=11, dataset_id=7, metric="sum_sales", evidence_digest=_DIGEST, value_digest=value_digest, execution_status=status)
|
||||
|
||||
|
||||
def _preview(value_digest=_VALUE_DIGEST, status="executed", raw_digest=_DIGEST):
|
||||
return {
|
||||
"query_model_fingerprint": _FINGERPRINT,
|
||||
"filters": {"filters": [], "filters_hash": _FILTER_HASH},
|
||||
"coordinates": [{
|
||||
"chart_id": 11, "dataset_id": 7, "metric": "sum_sales",
|
||||
"chart_name": "Revenue", "metric_label": "Sales",
|
||||
"evidence_digest": raw_digest, "value_digest": value_digest,
|
||||
"execution_status": status,
|
||||
"error": None,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
|
||||
def add(self, value):
|
||||
self.added.append(value)
|
||||
|
||||
def flush(self):
|
||||
self.added[0].id = "manual-run-1"
|
||||
|
||||
|
||||
# #region Test.ReferenceSelection.Success [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT A successful selected preview creates a manual owner-scoped run and hands only server filters to capture.
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_coordinate_creates_draft_candidate():
|
||||
db = _DB()
|
||||
selected = [_selected()]
|
||||
with (
|
||||
patch("src.services.dashboard_testing.reference_selection.discover_reference_dashboard", new=AsyncMock(return_value=_preview(raw_digest="sha256:" + "e" * 64))) as discover,
|
||||
patch("src.services.dashboard_testing.reference_selection._latest_release", return_value=SimpleNamespace(id="release-1")),
|
||||
patch("src.services.dashboard_testing.reference_selection.get_draft_storage", return_value=SimpleNamespace(store=lambda run_id, sha256, data: f"draft:{run_id}:{sha256}")),
|
||||
patch("src.services.dashboard_testing.reference_selection.capture_and_create_candidate", new=AsyncMock(return_value=SimpleNamespace(candidate={"candidate_id": "candidate-1"}, capture_artifact_id="artifact-1"))) as capture,
|
||||
):
|
||||
result = await capture_reference_selection(
|
||||
db, "user-1", SimpleNamespace(env=SimpleNamespace(url="https://superset.example")), "preprod", 42, "https://superset.example/dashboard/42/",
|
||||
selected, _FINGERPRINT, _FILTER_HASH,
|
||||
)
|
||||
assert result == {
|
||||
"agent_run_id": "manual-run-1", "release_id": "release-1",
|
||||
"candidate_ids": ["candidate-1"], "capture_artifact_ids": ["artifact-1"],
|
||||
}
|
||||
assert db.added[0].user_id == "user-1"
|
||||
assert db.added[0].intent == "manual_reference_capture"
|
||||
assert db.added[0].context_snapshot["filters_hash"] == _FILTER_HASH
|
||||
fresh_digest = "sha256:" + "e" * 64
|
||||
assert selected[0].evidence_digest == _DIGEST
|
||||
assert db.added[0].context_snapshot["selected_preview_evidence"][0]["source_response_hash"] == fresh_digest
|
||||
assert db.added[0].context_snapshot["reference_source"]["source_url_ref"].startswith("draft:manual-run-1:")
|
||||
assert db.added[0].context_snapshot["reference_source"]["resolved_native_filters"] == []
|
||||
assert discover.await_args.kwargs == {"selected_coordinates": {(11, 7, "sum_sales")}, "max_queries": 1}
|
||||
request = capture.await_args.args[2]
|
||||
assert request.chart_id == 11 and request.dataset_id == 7 and request.result_key == "sum_sales"
|
||||
assert "evidence_digest" not in request.model_dump()
|
||||
assert result["capture_artifact_ids"] == ["artifact-1"]
|
||||
assert request.normalized_filters.filters_hash == _FILTER_HASH
|
||||
assert request.query_model_fingerprint == _FINGERPRINT
|
||||
assert request.max_rows == 1
|
||||
# #endregion Test.ReferenceSelection.Success
|
||||
|
||||
|
||||
# #region Test.ReferenceSelection.Drift [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Changed evidence or failed preview aborts before AgentRun/candidate creation.
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("selected,preview,match", [
|
||||
(_selected(value_digest="sha256:" + "c" * 64), _preview(), "value changed"),
|
||||
(_selected(), _preview(status="error"), "no successful"),
|
||||
(_selected(status="skipped_budget"), _preview(), "Only successfully"),
|
||||
])
|
||||
async def test_drift_and_failed_preview_rejected_without_write(selected, preview, match):
|
||||
db = _DB()
|
||||
with (
|
||||
patch("src.services.dashboard_testing.reference_selection.discover_reference_dashboard", new=AsyncMock(return_value=preview)),
|
||||
patch("src.services.dashboard_testing.reference_selection.capture_and_create_candidate", new=AsyncMock()) as capture,
|
||||
):
|
||||
with pytest.raises(ValueError, match=match):
|
||||
await capture_reference_selection(
|
||||
db, "user-1", "client", "preprod", 42,
|
||||
"https://superset.example/dashboard/42/", [selected], _FINGERPRINT, _FILTER_HASH,
|
||||
)
|
||||
assert db.added == []
|
||||
capture.assert_not_awaited()
|
||||
# #endregion Test.ReferenceSelection.Drift
|
||||
|
||||
|
||||
# #region Test.ReferenceSelection.Release [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT No release produces a prerequisite error and no run or candidate.
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_release_is_prerequisite_error():
|
||||
db = _DB()
|
||||
with (
|
||||
patch("src.services.dashboard_testing.reference_selection.discover_reference_dashboard", new=AsyncMock(return_value=_preview())),
|
||||
patch("src.services.dashboard_testing.reference_selection._latest_release", return_value=None),
|
||||
patch("src.services.dashboard_testing.reference_selection.capture_and_create_candidate", new=AsyncMock()) as capture,
|
||||
):
|
||||
with pytest.raises(LookupError, match="No approved or published release"):
|
||||
await capture_reference_selection(
|
||||
db, "user-1", "client", "preprod", 42,
|
||||
"https://superset.example/dashboard/42/", [_selected()], _FINGERPRINT, _FILTER_HASH,
|
||||
)
|
||||
assert db.added == []
|
||||
capture.assert_not_awaited()
|
||||
# #endregion Test.ReferenceSelection.Release
|
||||
|
||||
|
||||
# #region Test.ReferenceSelection.Identity [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Model/filter drift and unknown chart coordinates abort before AgentRun creation.
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("preview,match", [
|
||||
({**_preview(), "query_model_fingerprint": "sha256:" + "f" * 64}, "query model changed"),
|
||||
({**_preview(), "filters": {"filters": [], "filters_hash": "sha256:" + "f" * 64}}, "filter state changed"),
|
||||
({**_preview(), "coordinates": []}, "no successful authoritative preview"),
|
||||
])
|
||||
async def test_model_filter_and_coordinate_drift(preview, match):
|
||||
db = _DB()
|
||||
with patch("src.services.dashboard_testing.reference_selection.discover_reference_dashboard", new=AsyncMock(return_value=preview)):
|
||||
with pytest.raises(ValueError, match=match):
|
||||
await capture_reference_selection(
|
||||
db, "user-1", "client", "preprod", 42,
|
||||
"https://superset.example/dashboard/42/", [_selected()], _FINGERPRINT, _FILTER_HASH,
|
||||
)
|
||||
assert db.added == []
|
||||
# #endregion Test.ReferenceSelection.Identity
|
||||
|
||||
|
||||
# #region Test.ReferenceSelection.ReleaseBinding [C:4] [TYPE Function]
|
||||
# @TEST_INVARIANT Latest release excludes other environments and non-approved statuses for the same dashboard.
|
||||
def test_latest_release_is_bound_to_dashboard_environment_and_status():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
tables = [GitRepository.__table__, DeploymentRecord.__table__, DashboardRelease.__table__]
|
||||
GitRepository.metadata.create_all(engine, tables=tables)
|
||||
with Session(engine) as db:
|
||||
db.add(GitRepository(id="repo-1", dashboard_id=42, config_id="cfg-1",
|
||||
remote_url="https://git.example/repo", local_path="/tmp/repo"))
|
||||
for deployment_id, environment in ((1, "preprod"), (2, "other"), (3, "preprod")):
|
||||
db.add(DeploymentRecord(id=deployment_id, repository_id="repo-1", environment_id=environment,
|
||||
commit_hash="a" * 40, content_hash="b" * 64))
|
||||
for release_id, deployment_id, status in (
|
||||
("approved-1", 1, "approved"), ("foreign-1", 2, "published"),
|
||||
("draft-1", 3, "ready_to_publish"),
|
||||
):
|
||||
db.add(DashboardRelease(
|
||||
id=release_id, repository_id="repo-1", deployment_id=deployment_id,
|
||||
name=release_id, version=f"v1.0.{deployment_id}", notes="test",
|
||||
commit_hash="a" * 40, content_hash="b" * 64,
|
||||
status=status, created_at=datetime(2026, 1, deployment_id, tzinfo=UTC),
|
||||
created_by="tester",
|
||||
))
|
||||
db.flush()
|
||||
assert _latest_release(db, 42, "preprod").id == "approved-1"
|
||||
assert _latest_release(db, 43, "preprod") is None
|
||||
assert _latest_release(db, 42, "other").id == "foreign-1"
|
||||
engine.dispose()
|
||||
# #endregion Test.ReferenceSelection.ReleaseBinding
|
||||
# #endregion Test.ReferenceSelection
|
||||
@@ -0,0 +1,63 @@
|
||||
# #region Test.ReferenceSource [C:3] [TYPE Module] [SEMANTICS baseline,reference,snapshot,test]
|
||||
# @TEST_INVARIANT BaselineEngine.ReferenceSource: resolved semantics are immutable and a missing or
|
||||
# altered snapshot cannot acquire approval or pin authority.
|
||||
from copy import deepcopy
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.dashboard_testing.reference_source import build_reference_source, require_reference_source
|
||||
from src.scripts.publish_catalog import PublishError, validate_catalog
|
||||
|
||||
|
||||
def _source():
|
||||
return build_reference_source(
|
||||
url="https://superset.example/superset/dashboard/42/",
|
||||
url_kind="dashboard",
|
||||
source_url_ref="draft:run:" + "1" * 64,
|
||||
environment_id="preprod", dashboard_id=42,
|
||||
filters={"filters": [], "filters_hash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"},
|
||||
query_model_fingerprint="sha256:" + "c" * 64,
|
||||
)
|
||||
|
||||
|
||||
def test_frozen_reference_has_expected_server_identity():
|
||||
source = _source()
|
||||
assert source["reference_digest"] == "4cb1188ff74d85e2c54d81d40457f97f2db193dd35bdc45b43f1b722d21a0c9a"
|
||||
assert source["source_url_sha256"] == "e6919b071bb03b4f68ef6ce0586184e6b1a1ff30e0837067328ea8702cc4cf6d"
|
||||
assert source["resolved_native_filters"] == []
|
||||
assert require_reference_source(source, environment_id="preprod", dashboard_id=42) == source
|
||||
with pytest.raises(ValueError, match="REFERENCE_SOURCE_MISMATCH"):
|
||||
require_reference_source(source, agent_run_id="another-run")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", [
|
||||
lambda value: value.pop("source_url_ref"),
|
||||
lambda value: value.update(environment_id="other"),
|
||||
lambda value: value.update(resolved_native_filters=[{"filter_id": "x"}]),
|
||||
lambda value: value.update(query_model_fingerprint="sha256:" + "d" * 64),
|
||||
])
|
||||
def test_missing_or_changed_reference_is_rejected(mutation):
|
||||
source = deepcopy(_source())
|
||||
mutation(source)
|
||||
with pytest.raises(ValueError, match="REFERENCE_SOURCE"):
|
||||
require_reference_source(source, environment_id="preprod", dashboard_id=42)
|
||||
|
||||
|
||||
def test_publication_rejects_legacy_entry_and_accepts_frozen_reference():
|
||||
entry = {
|
||||
"dashboard_id": 42,
|
||||
"provenance": {"environment": "preprod"},
|
||||
"normalized_filters": {"filters_hash": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"},
|
||||
}
|
||||
envelope = {
|
||||
"baseline_set_id": "set-1", "baseline_set_version": "1",
|
||||
"release_id": "release-1", "baseline_family": "a" * 64,
|
||||
"catalog_digest": "b" * 64,
|
||||
"catalog_revision": {"entry_revisions": [{"status": "approved", "entry": entry}]},
|
||||
}
|
||||
with pytest.raises(PublishError, match="no valid resolved reference URL"):
|
||||
validate_catalog(json.dumps(envelope).encode())
|
||||
envelope["catalog_revision"]["entry_revisions"][0]["reference_source"] = _source()
|
||||
assert validate_catalog(json.dumps(envelope).encode())["baseline_set_id"] == "set-1"
|
||||
# #endregion Test.ReferenceSource
|
||||
@@ -0,0 +1,13 @@
|
||||
# #region Test.BaselineRuntimeSchemas [C:2] [TYPE Module] [SEMANTICS test,baseline,packaging,schema]
|
||||
# @BRIEF Prove runtime schema loaders resolve real source files; Dockerfile validates copies at build.
|
||||
# @TEST_INVARIANT Existing catalog and capture schemas are readable JSON through production paths.
|
||||
import json
|
||||
|
||||
from src.services.dashboard_testing.baseline_catalog import _load_contract_schema
|
||||
from src.services.dashboard_testing.scenario.capture_profile import _SCHEMA
|
||||
|
||||
|
||||
def test_runtime_schema_paths_resolve() -> None:
|
||||
assert _load_contract_schema()["$schema"].startswith("https://json-schema.org/")
|
||||
assert json.loads(_SCHEMA.read_text(encoding="utf-8"))["$schema"].startswith("https://json-schema.org/")
|
||||
# #endregion Test.BaselineRuntimeSchemas
|
||||
@@ -0,0 +1,71 @@
|
||||
# #region Test.VisualReferenceSource [C:3] [TYPE Module] [SEMANTICS test,visual,baseline,reference]
|
||||
# @TEST_INVARIANT Caller-writable screenshot metadata cannot establish the verified
|
||||
# URL state required for visual-baseline approval.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.agent_run import DraftArtifact
|
||||
from src.schemas.dashboard_testing import CandidateRequest, ComparisonPolicy, NormalizedFilterContext, Provenance
|
||||
from src.services.dashboard_testing.approvals import _require_candidate_reference
|
||||
from src.services.dashboard_testing.candidates import create_candidate
|
||||
|
||||
_EMPTY_HASH = "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"
|
||||
|
||||
|
||||
# #region Test.VisualReferenceSource.Request [C:2] [TYPE Function]
|
||||
# @BRIEF Build one visual request whose expected image belongs to the named AgentRun.
|
||||
def _request(run_id: str, content_ref: str) -> CandidateRequest:
|
||||
return CandidateRequest.model_construct(
|
||||
environment_id="ss-preprod", dashboard_id=42, repository_key="my-repo",
|
||||
dashboard_key="FI-0080", result_key="TAB-main", label="Dashboard screenshot",
|
||||
normalized_filters=NormalizedFilterContext(filters=[], filters_hash=_EMPTY_HASH),
|
||||
source_response_hash="a" * 64, comparison_policy=ComparisonPolicy(type="visual_exact"),
|
||||
provenance=Provenance(environment="ss-preprod", actor="qa_analyst"),
|
||||
agent_run_id=run_id, kind="visual", tab_identifier="TAB-main",
|
||||
expected_image_sha256="b" * 64, expected_image_content_ref=content_ref,
|
||||
captured_at=datetime.now(UTC),
|
||||
fingerprints={"query": "q", "dataset": "d", "filter": "f", "layout": "l"},
|
||||
approval={"by": "qa_analyst", "at": datetime.now(UTC).isoformat()},
|
||||
)
|
||||
# #endregion Test.VisualReferenceSource.Request
|
||||
|
||||
|
||||
# #region Test.VisualReferenceSource.UntrustedMetadata [C:3] [TYPE Function]
|
||||
# @BRIEF A screenshot's caller-writable reference_source is ignored by the approval gate.
|
||||
def test_visual_candidate_does_not_trust_screenshot_metadata(db_session, user_id, run_id, capture_artifact_id):
|
||||
source = db_session.get(DraftArtifact, capture_artifact_id).capture_meta["reference_source"]
|
||||
screenshot = DraftArtifact(
|
||||
run_id=run_id, kind="screenshot_evidence", name="reference screenshot",
|
||||
intended_path="", content_ref=f"draft:{run_id}:screenshot", sha256="b" * 64,
|
||||
validation_status="valid", capture_meta={"reference_source": source},
|
||||
)
|
||||
db_session.add(screenshot)
|
||||
db_session.commit()
|
||||
|
||||
candidate = create_candidate(db_session, user_id, _request(run_id, screenshot.content_ref))
|
||||
draft = db_session.get(DraftArtifact, candidate.draft_artifact_ref)
|
||||
assert "reference_source" not in draft.capture_meta
|
||||
with pytest.raises(ValueError, match="REFERENCE_SOURCE_REQUIRED"):
|
||||
_require_candidate_reference(db_session, draft, draft.capture_meta)
|
||||
# #endregion Test.VisualReferenceSource.UntrustedMetadata
|
||||
|
||||
|
||||
# #region Test.VisualReferenceSource.Legacy [C:3] [TYPE Function]
|
||||
# @BRIEF A screenshot without a verified URL remains a draft but cannot open the approval gate.
|
||||
def test_visual_legacy_screenshot_cannot_open_reference_approval(db_session, user_id, run_id):
|
||||
screenshot = DraftArtifact(
|
||||
run_id=run_id, kind="screenshot_evidence", name="legacy screenshot",
|
||||
intended_path="", content_ref=f"draft:{run_id}:legacy-screenshot", sha256="b" * 64,
|
||||
validation_status="valid", capture_meta={},
|
||||
)
|
||||
db_session.add(screenshot)
|
||||
db_session.commit()
|
||||
candidate = create_candidate(db_session, user_id, _request(run_id, screenshot.content_ref))
|
||||
draft = db_session.get(DraftArtifact, candidate.draft_artifact_ref)
|
||||
with pytest.raises(ValueError, match="REFERENCE_SOURCE_REQUIRED"):
|
||||
_require_candidate_reference(db_session, draft, draft.capture_meta)
|
||||
# #endregion Test.VisualReferenceSource.Legacy
|
||||
# #endregion Test.VisualReferenceSource
|
||||
153
backend/tests/test_mcp_reference_url_tools.py
Normal file
153
backend/tests/test_mcp_reference_url_tools.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# #region Test.McpReferenceUrlTools [C:4] [TYPE Module] [SEMANTICS mcp,reference,url,rbac,test]
|
||||
# @BRIEF MCP reference URL calls require the REST grant set and forward the typed scenario request.
|
||||
# @RELATION VERIFIES -> [McpServer.ToolsReview.ReferenceUrl]
|
||||
# @TEST_INVARIANT A missing grant hides and denies the corresponding tool before route dispatch.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-mcp-reference-url")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_mcp_reference_url_test.db")
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
from src.mcp_server import server as mcp_server # noqa: E402
|
||||
import src.mcp_server.rbac_server as rbac_module # noqa: E402
|
||||
import src.mcp_server.tools_review as review_module # noqa: E402
|
||||
from src.models.auth import Permission, Role, User # noqa: E402
|
||||
from src.models.mapping import Base # noqa: E402
|
||||
|
||||
URL = "https://superset.example/dashboard/42/?native_filters_key=secret-key"
|
||||
SHA = "sha256:" + "a" * 64
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reference_db(tmp_path, monkeypatch):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'reference.db'}")
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(rbac_module, "SessionLocal", factory)
|
||||
monkeypatch.setattr(review_module, "SessionLocal", factory)
|
||||
monkeypatch.setattr(rbac_module.RbacFastMCP, "_record", lambda *args, **kwargs: None)
|
||||
with factory() as db:
|
||||
role = Role(name="reference-test", permissions=[
|
||||
Permission(resource="dashboard:testing", action=action)
|
||||
for action in ("READ", "EXECUTE", "WRITE")
|
||||
])
|
||||
db.add(User(username="reference-user", is_active=True, roles=[role]))
|
||||
db.commit()
|
||||
yield factory
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _token(principal_type="user"):
|
||||
return mcp_server._access_token_context.set(mcp_server.AccessToken(
|
||||
token="reference-token", client_id="reference-client", scopes=["mcp", "mcp:read"],
|
||||
subject="reference-user" if principal_type == "user" else None,
|
||||
claims={"principal_type": principal_type},
|
||||
))
|
||||
|
||||
|
||||
def _unwrap(result):
|
||||
return result[1] if isinstance(result, tuple) else result
|
||||
|
||||
|
||||
def _capture_request():
|
||||
return {"scenario_id": "scenario-1", "url": URL,
|
||||
"selected": [{"chart_id": 11, "dataset_id": 7, "metric": "sum_sales",
|
||||
"evidence_digest": SHA, "value_digest": SHA,
|
||||
"execution_status": "executed"}],
|
||||
"expected_query_model_fingerprint": SHA, "expected_filters_hash": SHA}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_tools_forward_typed_requests_to_rest_handlers(reference_db, monkeypatch):
|
||||
preview = AsyncMock(return_value={"environment_id": "preprod", "dashboard_id": 42})
|
||||
capture = AsyncMock(return_value={"candidate_ids": ["candidate-1"]})
|
||||
monkeypatch.setattr(review_module, "reference_discovery", preview)
|
||||
monkeypatch.setattr(review_module, "capture_reference_selection_endpoint", capture)
|
||||
server = mcp_server._build_probe_server()
|
||||
token = _token()
|
||||
try:
|
||||
names = {tool.name for tool in await server.list_tools()}
|
||||
assert {"preview_reference_dashboard", "capture_reference_selection"} <= names
|
||||
assert _unwrap(await server.call_tool("preview_reference_dashboard", {"request": {
|
||||
"scenario_id": "scenario-1", "url": URL, "max_queries": 2,
|
||||
}})) == {"environment_id": "preprod", "dashboard_id": 42}
|
||||
assert _unwrap(await server.call_tool("capture_reference_selection", {"request": _capture_request()})) == {
|
||||
"candidate_ids": ["candidate-1"]}
|
||||
finally:
|
||||
mcp_server._access_token_context.reset(token)
|
||||
assert preview.await_args.args[0].scenario_id == "scenario-1"
|
||||
assert preview.await_args.args[0].url == URL
|
||||
assert preview.await_args.kwargs["_reader"] is preview.await_args.kwargs["_executor"]
|
||||
assert capture.await_args.args[0].url == URL
|
||||
assert capture.await_args.kwargs["_writer"].id == preview.await_args.kwargs["_reader"].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_grants_and_service_principal_fail_closed(reference_db, monkeypatch):
|
||||
preview = AsyncMock(return_value={"unexpected": True})
|
||||
capture = AsyncMock(return_value={"unexpected": True})
|
||||
monkeypatch.setattr(review_module, "reference_discovery", preview)
|
||||
monkeypatch.setattr(review_module, "capture_reference_selection_endpoint", capture)
|
||||
server = mcp_server._build_probe_server()
|
||||
with reference_db() as db:
|
||||
role = db.query(Role).filter(Role.name == "reference-test").one()
|
||||
role.permissions = [Permission(resource="dashboard:testing", action="READ")]
|
||||
db.commit()
|
||||
token = _token()
|
||||
try:
|
||||
names = {tool.name for tool in await server.list_tools()}
|
||||
assert "preview_reference_dashboard" not in names
|
||||
assert "capture_reference_selection" not in names
|
||||
with pytest.raises(PermissionError):
|
||||
await server.call_tool("preview_reference_dashboard", {"request": {"scenario_id": "scenario-1", "url": URL}})
|
||||
with pytest.raises(PermissionError):
|
||||
await server.call_tool("capture_reference_selection", {"request": _capture_request()})
|
||||
finally:
|
||||
mcp_server._access_token_context.reset(token)
|
||||
with reference_db() as db:
|
||||
role = db.query(Role).filter(Role.name == "reference-test").one()
|
||||
role.permissions = [Permission(resource="dashboard:testing", action=action)
|
||||
for action in ("READ", "EXECUTE")]
|
||||
db.commit()
|
||||
token = _token()
|
||||
try:
|
||||
names = {tool.name for tool in await server.list_tools()}
|
||||
assert "preview_reference_dashboard" in names
|
||||
assert "capture_reference_selection" not in names
|
||||
with pytest.raises(PermissionError):
|
||||
await server.call_tool("capture_reference_selection", {"request": _capture_request()})
|
||||
finally:
|
||||
mcp_server._access_token_context.reset(token)
|
||||
token = _token("service")
|
||||
try:
|
||||
assert not server._can_use_tool("preview_reference_dashboard")
|
||||
assert not server._can_use_tool("capture_reference_selection")
|
||||
finally:
|
||||
mcp_server._access_token_context.reset(token)
|
||||
preview.assert_not_awaited()
|
||||
capture.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_origin_error_retains_typed_code(reference_db, monkeypatch):
|
||||
preview = AsyncMock(side_effect=HTTPException(status_code=422, detail={
|
||||
"code": "UNKNOWN_REFERENCE_ORIGIN", "message": "Reference URL origin is not configured"}))
|
||||
monkeypatch.setattr(review_module, "reference_discovery", preview)
|
||||
server = mcp_server._build_probe_server()
|
||||
token = _token()
|
||||
try:
|
||||
result = _unwrap(await server.call_tool("preview_reference_dashboard", {"request": {
|
||||
"scenario_id": "scenario-1", "url": URL,
|
||||
}}))
|
||||
finally:
|
||||
mcp_server._access_token_context.reset(token)
|
||||
assert result == {"status": "rejected", "error": "UNKNOWN_REFERENCE_ORIGIN",
|
||||
"message": "Reference URL origin is not configured"}
|
||||
# #endregion Test.McpReferenceUrlTools
|
||||
@@ -316,6 +316,8 @@ def test_mcp_catalog_is_explicit_and_unique() -> None:
|
||||
"superset_copy_dashboard",
|
||||
"superset_create_dataset",
|
||||
"capture_baseline_candidate",
|
||||
"preview_reference_dashboard",
|
||||
"capture_reference_selection",
|
||||
"request_baseline_approval",
|
||||
"decide_baseline_approval",
|
||||
"consume_baseline_approval",
|
||||
|
||||
@@ -30,8 +30,6 @@ services:
|
||||
args:
|
||||
INSTALL_PLAYWRIGHT_BROWSERS: "0"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -41,6 +39,7 @@ services:
|
||||
RESET_DATABASE_SCHEMA: ${RESET_DATABASE_SCHEMA:-false}
|
||||
BACKEND_PORT: 8000
|
||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env.e2e}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env.e2e}
|
||||
SERVICE_JWT: ${SERVICE_JWT:?Set SERVICE_JWT in .env.e2e}
|
||||
STORAGE_ROOT_PATH: /app/storage
|
||||
INITIAL_ADMIN_CREATE: "true"
|
||||
@@ -74,20 +73,64 @@ services:
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
# One-shot fixture for the 047 disposition browser flow; backend has completed migrations.
|
||||
e2e-seed:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
args:
|
||||
INSTALL_PLAYWRIGHT_BROWSERS: "0"
|
||||
entrypoint: ["python", "-m", "src.scripts.seed_scenario_disposition_e2e"]
|
||||
working_dir: /app/backend
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
|
||||
DB_SCHEMA: ${DB_SCHEMA:-public}
|
||||
STORAGE_ROOT_PATH: /app/storage
|
||||
volumes:
|
||||
- e2e_storage:/app/storage
|
||||
|
||||
# Dashboard 10 reference capture uses an explicitly synthetic release in the isolated E2E DB.
|
||||
e2e-reference-seed:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
args:
|
||||
INSTALL_PLAYWRIGHT_BROWSERS: "0"
|
||||
entrypoint: ["python", "-m", "src.scripts.seed_reference_url_e2e"]
|
||||
working_dir: /app/backend
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
|
||||
DB_SCHEMA: ${DB_SCHEMA:-public}
|
||||
STORAGE_ROOT_PATH: /app/storage
|
||||
BASELINE_REFERENCE_E2E: "1"
|
||||
volumes:
|
||||
- e2e_storage:/app/storage
|
||||
|
||||
# Playwright runner (CI mode)
|
||||
e2e-runner:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.60.0-noble
|
||||
working_dir: /workspace/frontend
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
e2e-seed:
|
||||
condition: service_completed_successfully
|
||||
e2e-reference-seed:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8000
|
||||
FRONTEND_URL: http://frontend:80
|
||||
E2E_USERNAME: admin
|
||||
E2E_PASSWORD: admin123
|
||||
SS_DEV_PASSWORD: ${SS_DEV_PASSWORD:-}
|
||||
GITEA_URL: https://git.bebesh.ru
|
||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||
CI: "true"
|
||||
|
||||
@@ -34,6 +34,13 @@ RUN if [ "$INSTALL_PLAYWRIGHT_BROWSERS" = "1" ]; then python -m playwright insta
|
||||
|
||||
# Исходный код
|
||||
COPY backend/ /app/backend/
|
||||
# Runtime JSON schemas used by catalog publication and capture profiles.
|
||||
# baseline-pin.schema.json is referenced by baseline_resolver but absent from the source tree;
|
||||
# its separate execution-path defect cannot be resolved by image packaging.
|
||||
COPY specs/037-superset-baseline-engine/contracts/baseline-catalog.schema.json /app/specs/037-superset-baseline-engine/contracts/baseline-catalog.schema.json
|
||||
COPY specs/038-dashboard-scenario-model/contracts/capture-profile.schema.json /app/specs/038-dashboard-scenario-model/contracts/capture-profile.schema.json
|
||||
RUN test -s /app/specs/037-superset-baseline-engine/contracts/baseline-catalog.schema.json \
|
||||
&& test -s /app/specs/038-dashboard-scenario-model/contracts/capture-profile.schema.json
|
||||
|
||||
# Копируем shared certs.sh и entrypoint
|
||||
COPY docker/certs.sh /app/certs.sh
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# ADR — Sed-мутации датасетов (viewer + migration)
|
||||
|
||||
## Цель
|
||||
Дать пользователю возможность править датасеты literal-заменой (а-ля `sed`):
|
||||
- в модуле просмотра датасетов: ко всем / выбранным / одному датасету;
|
||||
- в модуле миграции: как именованное сохраняемое правило, применяемое к датасетам
|
||||
внутри экспортного ZIP перед импортом.
|
||||
|
||||
## Решение
|
||||
- **Единая семантика правила**: `{ name, find, replace }`, literal-замена подстроки.
|
||||
`find` пустой → операция no-op (безопасный fallback).
|
||||
- **Миграция**: черновик уже добавил `literal_find`/`literal_replace` в
|
||||
`MigrationEngine.transform_zip` и `DashboardSelection`, но импортировал
|
||||
несуществующий `src/core/literal_replace.py`. Создаём этот модуль и применяем
|
||||
замену к raw-тексту каждого `datasets/**/*.yaml` после DB-UUID-трансформа.
|
||||
Плагин пробрасывает поля из `params` в оба вызова `transform_zip`.
|
||||
- **Просмотр (live mutation)**: `POST /api/datasets/mutate` + `POST /api/datasets/mutate/preview`.
|
||||
GET полного датасета → literal-замена → PUT обратно (`override_columns=false`).
|
||||
`dataset_ids` пустой = все датасеты env.
|
||||
- **Цели замены (`targets`)**: `sql` (SQL датасета) / `metrics` (expression, verbose_name) /
|
||||
`yaml` (полная рекурсивная замена всех строковых полей — опасно, эксклюзивно,
|
||||
с явным предупреждением в UI и 422 при комбинации с другими).
|
||||
- **Обязательный предпросмотр**: в просмотре apply заблокирован до валидного preview
|
||||
(fingerprint-гейт); preview возвращает per-dataset `field: before → after` без записи.
|
||||
В миграции предпросмотр не требуется (по явному решению пользователя).
|
||||
- **Именованные правила**: общий frontend-store `sedRules` (localStorage,
|
||||
ключ `dsh.datasetSedRules.v1`) + переиспользуемый `SedRuleEditor.svelte`.
|
||||
|
||||
## Отклонено
|
||||
- Regex-`sed` (полный синтаксис s///) — отклонили в пользу literal-замены:
|
||||
детерминированно, предсказуемо, покрывает заявленный кейс
|
||||
`dm_view.account_debt → dm_view.account_debt_final`. Regex легко добавить позже.
|
||||
- Слепой рекурсивный replace по всем строковым полям live-датасета — отклонён:
|
||||
задел бы описания/комментарии. Ограничились идентификаторными полями.
|
||||
- Хранение правил в БД — отклонено для MVP: localStorage достаточно, правила
|
||||
приватны пользователю и не требуют миграций схемы.
|
||||
@@ -12,84 +12,17 @@
|
||||
|
||||
## Decision
|
||||
|
||||
The repository uses a **two‑platform, top‑level separation**:
|
||||
The repository keeps separate Python and Svelte application roots:
|
||||
|
||||
```
|
||||
superset-tools/ # Repository root
|
||||
├── backend/ # Python 3.13+ / FastAPI application
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # FastAPI route modules (one file per route group)
|
||||
│ │ ├── core/ # Core services: task_manager, auth, migration, plugin_loader
|
||||
│ │ ├── models/ # SQLAlchemy ORM models (one file per entity group)
|
||||
│ │ ├── services/ # Business logic (orchestration, validators, transformers)
|
||||
│ │ ├── schemas/ # Pydantic request/response schemas
|
||||
│ │ └── app.py # FastAPI application factory
|
||||
│ ├── tests/ # pytest test suite (mirrors src/ structure)
|
||||
│ ├── pyproject.toml # Build & tool configuration
|
||||
│ └── requirements.txt # Production dependencies
|
||||
├── frontend/ # SvelteKit application
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # SvelteKit page routes (file‑based routing)
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── components/ # Reusable Svelte 5 components
|
||||
│ │ │ ├── stores/ # Svelte 5 rune stores ($state)
|
||||
│ │ │ └── api/ # API client modules (typed fetch wrappers)
|
||||
│ │ └── i18n/ # Internationalization dictionaries
|
||||
│ ├── tests/ # vitest test suite
|
||||
│ ├── package.json # Runtime + dev dependencies
|
||||
│ ├── svelte.config.js # SvelteKit configuration
|
||||
│ └── vite.config.ts # Vite build configuration
|
||||
├── docker/ # Dockerfiles for backend, frontend, nginx
|
||||
├── docker-compose.yml # Development Docker Compose
|
||||
├── docs/
|
||||
│ ├── adr/ # Architecture Decision Records (this file)
|
||||
│ ├── design/ # Detailed design documents
|
||||
│ └── ... # Operational docs (installation, settings, etc.)
|
||||
├── specs/ # Feature specifications (speckit artifacts)
|
||||
│ └── NNN‑feature‑name/
|
||||
│ ├── spec.md
|
||||
│ ├── plan.md
|
||||
│ ├── research.md
|
||||
│ ├── data-model.md
|
||||
│ ├── quickstart.md
|
||||
│ ├── contracts/
|
||||
│ │ └── modules.md
|
||||
│ └── tasks.md
|
||||
├── scripts/ # Shell utility scripts
|
||||
├── .specify/ # Speckit workflow framework
|
||||
│ ├── memory/constitution.md # Repository constitution
|
||||
│ ├── templates/ # Speckit artifact templates
|
||||
│ └── scripts/bash/ # Speckit helper scripts
|
||||
├── .opencode/ # OpenCode AI agent configuration
|
||||
│ ├── command/ # Speckit command definitions
|
||||
│ ├── skills/ # GRACE semantic protocol skills
|
||||
│ └── agents/ # Specialized agent definitions
|
||||
└── .github/ # GitHub Actions workflows
|
||||
```
|
||||
- `backend/src/` contains the FastAPI app, API routes, core infrastructure, domain services, models, schemas, MCP tools and in-process plugins. `backend/tests/` contains Python tests.
|
||||
- `frontend/src/` contains Svelte routes, reusable `lib/` components, models, stores and API clients. Frontend tests live beside components and under `frontend/e2e/`.
|
||||
- `specs/` contains feature contracts and evidence; `docs/adr/` contains durable architectural decisions. `docker/`, Compose files and root scripts define deployment and local development.
|
||||
- `.agents/skills/` and `.agents/agents/` are canonical semantic sources; `.kilo/` contains synchronized runtime copies. See root `AGENTS.md` for current startup, verification and sync commands.
|
||||
|
||||
## Module Boundary Rules
|
||||
Import direction is the durable rule: backend domain services must not depend on FastAPI route modules; frontend `lib/` code must not depend on route files. Domain models and schemas should stay independent of API transport. New files follow the language toolchain's normal naming conventions.
|
||||
|
||||
1. **`backend/src/api/`** — route handlers only. Must not contain business logic, ORM queries, or schema definitions. Each file = one FastAPI `APIRouter` group.
|
||||
2. **`backend/src/core/`** — singleton services with application‑scoped lifetime (auth manager, task scheduler, plugin loader, migration engine). Must not import from `api/`.
|
||||
3. **`backend/src/services/`** — stateless or request‑scoped business logic. May depend on `models/`, `schemas/`, and `core/`. Must not depend on `api/`.
|
||||
4. **`backend/src/models/`** — SQLAlchemy ORM declarations only. No business logic, no API dependencies.
|
||||
5. **`backend/src/schemas/`** — Pydantic models for validation/serialization. No ORM dependencies, no business logic.
|
||||
6. **`frontend/src/routes/`** — SvelteKit pages. Import components from `lib/components/`, state from `lib/stores/`, API clients from `lib/api/`.
|
||||
7. **`frontend/src/lib/components/`** — reusable components. Must not import from `routes/` (prevents circular dependencies).
|
||||
8. **`frontend/src/lib/stores/`** — Svelte 5 `$state` rune stores. Must not import from `routes/` or `components/`.
|
||||
## Current limits
|
||||
|
||||
## File Naming Conventions
|
||||
|
||||
- **Python modules**: `snake_case.py` (e.g., `task_manager.py`, `auth_provider.py`)
|
||||
- **Svelte components**: `PascalCase.svelte` (e.g., `DashboardGrid.svelte`, `MappingTable.svelte`)
|
||||
- **Test files**: `test_<module_name>.py` or `<ComponentName>.test.ts`
|
||||
- **ADR files**: `ADR-NNNN-short-description.md` (zero‑padded, 4 digits, kebab‑case)
|
||||
|
||||
## Enforcement
|
||||
|
||||
- Speckit `/speckit.plan` command reads this ADR to validate proposed module placement.
|
||||
- Speckit `/speckit.implement` rejects files written outside canonical boundaries unless justified in feature‑local `research.md`.
|
||||
- Направление импортов проверяется в code review; отдельный статический verifier
|
||||
не является частью текущего репозитория.
|
||||
The old fixed tree, Python-version pin, `.opencode/`-only agent layout and claim that `/speckit.implement` automatically rejects misplaced files described an earlier plan. They are not enforcement mechanisms today. Placement is checked in review and by the relevant feature contract; this ADR does not require every feature to have the same set of Speckit files. Existing modules may need incremental extraction before the ideal import direction is fully enforced, so status remains `PARTIALLY IMPLEMENTED`.
|
||||
|
||||
## @} Doc.Adr.ADR0001
|
||||
|
||||
@@ -37,7 +37,7 @@ superset-tools operates as a **standalone orchestrator microservice** with these
|
||||
| Property | Decision |
|
||||
|----------|----------|
|
||||
| **Topology** | External orchestrator above all Superset instances |
|
||||
| **Communication** | Superset REST API (bearer token auth per environment) |
|
||||
| **Communication** | Superset REST API through a configured client and environment credentials |
|
||||
| **Database** | Dedicated PostgreSQL 16 instance (not Superset metadata DB) |
|
||||
| **Frontend** | SvelteKit SPA (not integrated into Superset UI) |
|
||||
| **Release cadence** | Independent Docker image releases |
|
||||
@@ -50,8 +50,8 @@ superset-tools operates as a **standalone orchestrator microservice** with these
|
||||
- **Negative:** Users have two separate UIs (Superset for BI, superset-tools for DevOps). Mitigated by consistent UX design and cross‑linking.
|
||||
- **Risk:** API compatibility if Superset changes its REST API. Mitigated by versioned API client with automated compatibility tests.
|
||||
|
||||
## Migration from Existing Document
|
||||
## Decision history
|
||||
|
||||
This ADR supersedes and formalizes `docs/architecture_decision_superset_migration.md`. The original document contained the same recommendation and rationale but lacked the legacy DEF contract anchor, making it invisible to the semantic index and decision‑memory audit chain.
|
||||
This ADR incorporates the earlier unnumbered standalone-versus-Superset recommendation. That duplicate document was removed from `docs/` after the decision was formalized here.
|
||||
|
||||
## @} Doc.Adr.ADR0003
|
||||
|
||||
@@ -7,263 +7,23 @@
|
||||
# @RATIONALE Svelte 5 introduces a fundamentally different reactivity model (runes: `$state`, `$derived`, `$effect`, `$props`) compared to Svelte 4's `$:` reactive statements. This ADR locks in the Svelte 5 runes approach and prevents backsliding into legacy patterns when new developers or AI agents contribute code.
|
||||
# @RATIONALE SvelteKit with static adapter (`@sveltejs/adapter-static`) was chosen over SvelteKit SSR because: (a) superset-tools is a Docker‑deployed SPA behind nginx — server‑side rendering provides no latency benefit, (b) static SPA simplifies Docker deployment (no Node.js server needed, just nginx serving static files), (c) all data is fetched via REST API, not server‑side load functions.
|
||||
# @RATIONALE Svelte 5 runes (`$state`, `$derived`, `$effect`) are mandated over Svelte 4 `$:` reactivity and `writable` stores because: (a) runes are the forward path — Svelte 4 patterns are deprecated, (b) runes provide fine‑grained reactivity without subscription boilerplate, (c) `$state` can be used outside `.svelte` files in `.svelte.js` modules, enabling reusable reactive logic.
|
||||
# @RATIONALE Design tokens (semantic colors in `tailwind.config.js`) were introduced in 2026‑06‑02 (PR 0‑1) to eliminate raw Tailwind classes (`bg‑blue‑600`, `text‑gray‑900`) from page‑level code. This prevents visual drift and gives AI agents a single source of truth for color choices. See §"Design Token System" below.
|
||||
# @RATIONALE Screen Model pattern (`[TYPE Model]` in `.svelte.ts` files) was formalised in 2026‑06‑02 (PR 3‑4) to extract cross‑widget state from page files. Models reduce page files from 2700+ lines to <1400 lines and enable unit‑testing of invariants without DOM render (~10ms). See §"State Management Pattern" and ADR‑0010.
|
||||
# @RATIONALE Design tokens (semantic colors in `tailwind.config.js`) were introduced in 2026‑06‑02 (PR 0‑1) to eliminate raw Tailwind classes (`bg‑blue‑600`, `text‑gray‑900`) from page‑level code. This prevents visual drift and gives AI agents a single source of truth for color choices. The implementation uses semantic color classes in frontend components.
|
||||
# @RATIONALE Screen Model pattern (`[TYPE Model]` in `.svelte.ts` files) was formalised in 2026‑06‑02 (PR 3‑4) to extract cross‑widget state from page files. Models reduce page files from 2700+ lines to <1400 lines and enable unit‑testing of invariants without DOM render (~10ms). ADR-0010 records the decomposition boundary for those models.
|
||||
# @REJECTED React/Next.js — rejected by ADR-0003 (technology stack independence from Superset). Svelte 5 was chosen for its compile‑time approach, smaller bundle size, and superior DX for this project's scale.
|
||||
# @REJECTED Svelte 4 legacy patterns (`$:`, `writable`/`readable` stores) — rejected because Svelte 5 runes are the actively maintained reactivity model. Mixing two models creates confusion and potential reactivity bugs.
|
||||
# @REJECTED Server‑Side Rendering (SSR) with SvelteKit — rejected because the data is entirely API‑driven (no server‑side page data loading), and SSR adds deployment complexity (Node.js process) without latency benefit for authenticated SPA users.
|
||||
# @REJECTED Raw Tailwind color classes (`bg‑blue‑600`, `text‑gray‑900`, `border‑indigo‑300`, etc.) in page and component code — rejected because they create visual drift across 70+ files (3892 violations catalogued 2026‑06‑02). Replaced by semantic token system (see §"Design Token System").
|
||||
# @REJECTED Inline `$state` + handler functions in complex pages (>5 `$state` atoms) — rejected because they produce monolithic, untestable page files (DashboardHub was 2765 lines before extraction). Replaced by Screen Model pattern (see §"State Management Pattern").
|
||||
# @REJECTED Raw Tailwind color classes (`bg‑blue‑600`, `text‑gray‑900`, `border‑indigo‑300`, etc.) in new page and component code — rejected because they create visual drift; use the semantic tokens configured in `frontend/tailwind.config.js`.
|
||||
# @REJECTED Keeping cross-widget `$state` and handlers inside complex page files — rejected because it creates monolithic, hard-to-test pages; extract cohesive Screen Models and components.
|
||||
|
||||
## Decision
|
||||
|
||||
### Technology Stack
|
||||
- The application uses Svelte 5 runes and SvelteKit routes under `frontend/src/routes/`. The static adapter builds assets served by nginx; API access goes through `frontend/src/lib/api/`.
|
||||
- Reusable presentation belongs in `frontend/src/lib/components/` and the shared UI atoms. Cross-widget state and actions may live in `.svelte.ts` Screen Models under `frontend/src/lib/models/`; [ADR-0010](ADR-0010-model-decomposition-gate.md) owns their size/decomposition rule.
|
||||
- Components use semantic design tokens and the existing UI atoms. Interaction states, feedback and recovery are described in the canonical [Svelte semantic skill](../../.agents/skills/semantics-svelte/SKILL.md) and feature UX contracts.
|
||||
- Existing legacy stores and components can be migrated incrementally. New features should use runes and avoid creating another page-level state monolith.
|
||||
|
||||
| Layer | Choice | Version |
|
||||
|-------|--------|---------|
|
||||
| Framework | SvelteKit | 2.x |
|
||||
| UI Library | Svelte | 5.x (runes mode) |
|
||||
| Build | Vite | 7.x |
|
||||
| Styling | Tailwind CSS 3.x + semantic design tokens | 3.x |
|
||||
| Adapter | @sveltejs/adapter-static | 3.x (SPA mode) |
|
||||
| Testing | vitest + @testing-library/svelte | 4.x / 5.x |
|
||||
## Verification boundary
|
||||
|
||||
### Reactivity Model (Svelte 5 Runes)
|
||||
|
||||
```
|
||||
Legacy (FORBIDDEN) Svelte 5 Runes (REQUIRED)
|
||||
──────────────────────── ──────────────────────────
|
||||
let count = 0; let count = $state(0);
|
||||
$: doubled = count * 2; let doubled = $derived(count * 2);
|
||||
$: { /* side effect */ } $effect(() => { /* side effect */ });
|
||||
export let name; let { name } = $props();
|
||||
import { writable } from ... ❌ new stores MUST use .svelte.ts with $state
|
||||
⚠️ 8 existing writable stores are legacy — migrate, don't extend
|
||||
```
|
||||
|
||||
**Note on legacy stores:** 7 stores (`sidebar.ts`, `taskDrawer.ts`, `environmentContext.ts`, `assistantChat.ts`, `health.ts`, `activity.ts`, `translationRun.ts`) still use `writable()` from `svelte/store`. These predate Svelte 5 adoption. New reactive state MUST use `.svelte.ts` files with `$state`. Existing stores are read via `$store` syntax in `.svelte` files or `get()` in `.svelte.ts` modules. ADR‑0007 documents the `fromStore` + `$derived` rejection. The former `datasetReviewSession.ts` store was removed with rejected Dataset Review; see ADR-0018.
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
**Four tiers of state**, strictly separated:
|
||||
|
||||
| Tier | Pattern | File | Use case | Example |
|
||||
|------|---------|------|----------|---------|
|
||||
| 1. Component‑local | `$state()` in `.svelte` | `<Component>.svelte` | Accordion open, tooltip visible, input focus | `let isOpen = $state(false)` |
|
||||
| 2. Screen Model | `class` with `$state` + methods | `.svelte.ts` in `lib/models/` | **Cross‑widget state with invariants** (filters, pagination, search, multi‑step) | `DashboardHubModel`, `MigrationModel` |
|
||||
| 3. Global store | `$state` in `.svelte.ts` module | `.svelte.ts` in `lib/stores/` | Cross‑route state (auth, notifications, sidebar) | `maintenance.svelte.ts` |
|
||||
| 4. Server state | Fetched via API, held in Model or component | N/A | HTTP responses from backend | `api.getDashboards()` → model atom |
|
||||
|
||||
**Screen Model (tier 2) — the model‑first pattern:**
|
||||
|
||||
For any page with **>5 `$state` atoms** or **cross‑widget invariants** (filter resets pagination, selection survives search), create a `[TYPE Model]` in `lib/models/` BEFORE implementing components. The Model declares `@STATE`, `@ACTION`, `@INVARIANT` tags and is testable without DOM render.
|
||||
|
||||
```typescript
|
||||
// frontend/src/lib/models/DashboardHubModel.svelte.ts
|
||||
export class DashboardHubModel {
|
||||
// Atoms
|
||||
dashboards: DashboardRow[] = $state([]);
|
||||
page: number = $state(1);
|
||||
searchQuery: string = $state("");
|
||||
// ... < 40 atoms
|
||||
|
||||
// Actions
|
||||
async loadDashboards(): Promise<void> { ... }
|
||||
setPage(n: number): void { ... }
|
||||
// ... < 40 methods
|
||||
}
|
||||
```
|
||||
|
||||
Page file becomes a thin render layer:
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { DashboardHubModel } from "$lib/models/DashboardHubModel.svelte.ts";
|
||||
const m = new DashboardHubModel();
|
||||
</script>
|
||||
<!-- template uses m.dashboards, m.page, onClick={() => m.setPage(2)} -->
|
||||
```
|
||||
|
||||
**Decomposition gate** (ADR‑0010): If a Model exceeds **400 lines** or **40 public methods**, split into submodels by responsibility domain (e.g. `DashboardFiltersModel`, `DashboardSelectionModel`, `DashboardGitModel`).
|
||||
|
||||
### Design Token System
|
||||
|
||||
**Raw Tailwind color classes are DEPRECATED in page and component code.** All colors MUST use semantic tokens defined in `frontend/tailwind.config.js`. This is the single source of truth for the visual system.
|
||||
|
||||
**Token families** (from `tailwind.config.js`):
|
||||
|
||||
| Purpose | Token | Maps to |
|
||||
|---------|-------|---------|
|
||||
| Primary action | `bg-primary text-white` | blue‑600 |
|
||||
| Page background | `bg-surface-page` | slate‑50 |
|
||||
| Card surface | `bg-surface-card` | white |
|
||||
| Muted surface | `bg-surface-muted` | slate‑100 |
|
||||
| Default border | `border-border` | slate‑200 |
|
||||
| Strong border (inputs) | `border-border-strong` | slate‑300 |
|
||||
| Primary text | `text-text` | slate‑900 |
|
||||
| Muted text | `text-text-muted` | slate‑500 |
|
||||
| Subtle text (placeholders) | `text-text-subtle` | slate‑400 |
|
||||
| Destructive / error | `bg-destructive-light text-destructive border-destructive-ring` | red family |
|
||||
| Success | `bg-success-light text-success` | green family |
|
||||
| Warning | `bg-warning-light text-warning` | amber family |
|
||||
| Info | `bg-info-light text-info` | sky family |
|
||||
|
||||
**Correct vs. wrong:**
|
||||
|
||||
```svelte
|
||||
// ✅ CORRECT — semantic tokens
|
||||
<div class="bg-surface-card border border-border text-text">
|
||||
<Button variant="primary">
|
||||
|
||||
// ❌ WRONG — raw Tailwind (forbidden in page/component code)
|
||||
<div class="bg-white border border-gray-200 text-gray-900">
|
||||
<button class="bg-blue-600 text-white">
|
||||
```
|
||||
|
||||
**Enforcement:** `scripts/audit-frontend-style.mjs` scans `src/routes/**` and `src/lib/components/**` for raw color violations. CI rejects PRs with raw colors. Canonical token reference and copy‑paste examples live in `semantics‑svelte` §VII.
|
||||
|
||||
### UI Atom Reuse
|
||||
|
||||
**Page‑level UI MUST use `$lib/ui` atoms.** Raw `<button>` elements and manual `<div class="bg-white rounded...">` cards in page files are a violation.
|
||||
|
||||
| Atom | Import | Variants |
|
||||
|------|--------|----------|
|
||||
| `Button` | `from "$lib/ui"` | `primary` (default), `secondary`, `destructive` (канонический), `ghost`. `danger` — deprecated alias |
|
||||
| `Card` | `from "$lib/ui"` | `padding="md"` (default), `none`, `sm`, `lg`; optional `title` |
|
||||
| `Input` | `from "$lib/ui"` | `label`, `error`, `disabled`, `type`; optional `id` |
|
||||
| `Select` | `from "$lib/ui"` | `label`, `options`, `disabled`; optional `id` |
|
||||
| `PageHeader` | `from "$lib/ui"` | `title`, optional `subtitle` slot, `actions` slot |
|
||||
| `EmptyState` | `from "$lib/ui"` | `title`, `description`, optional `actionLabel` + `onAction` |
|
||||
| `Icon` | `from "$lib/ui"` | `name`, `size`, `class`, `strokeWidth` |
|
||||
| `HelpTooltip` | `from "$lib/ui"` | text tooltip |
|
||||
| `LanguageSwitcher` | `from "$lib/ui"` | lang toggle |
|
||||
|
||||
Atoms are the ONLY place where raw Tailwind is allowed — they encapsulate design tokens so pages don't need to.
|
||||
|
||||
### Component File Structure
|
||||
|
||||
Three directories, different rules:
|
||||
|
||||
```
|
||||
frontend/src/lib/ui/ # UI atoms (9 files) — MANDATORY for pages
|
||||
├── Button.svelte, Card.svelte, Input.svelte, Select.svelte
|
||||
├── PageHeader.svelte, EmptyState.svelte, Icon.svelte
|
||||
├── HelpTooltip.svelte, LanguageSwitcher.svelte
|
||||
└── index.ts # Barrel export
|
||||
|
||||
frontend/src/lib/components/ # Domain components (30+ files) — NEW components go HERE
|
||||
├── layout/ (Sidebar, TopNavbar, TaskDrawer, Breadcrumbs)
|
||||
├── translate/ (TranslationPreview, BulkReplaceModal, ...)
|
||||
├── reports/ (ReportsList, ReportCard, ReportDetailPanel)
|
||||
├── health/ (HealthMatrix, ScheduleAtAGlance, PolicyForm)
|
||||
├── llm/ (ValidationTaskForm, UrlParser, ValidationTaskReport)
|
||||
├── ui/ (SearchableMultiSelect, MultiSelect, DatasetSearchCombobox)
|
||||
├── assistant/ (AssistantChatPanel, AssistantClarificationCard)
|
||||
├── settings/ (ApiKeysTab)
|
||||
└── (DashboardMaintenanceBadge, MaintenanceEventsTable, ...)
|
||||
|
||||
frontend/src/components/ # LEGACY FROZEN (40 files) — do NOT add, migrate out
|
||||
├── auth/ git/ tasks/ tools/ backups/ storage/ llm/
|
||||
├── EnvSelector, DashboardGrid, MappingTable, TaskRunner, TaskHistory
|
||||
├── TaskLogViewer, Toast, Navbar, Footer, PasswordPrompt
|
||||
└── StartupEnvironmentWizard, DynamicForm, MissingMappingModal
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- New domain components → `lib/components/<domain>/`
|
||||
- `src/components/` — заморожено. Новые файлы запрещены. Существующие — мигрировать в `lib/components/` при рефакторинге.
|
||||
- `svelte.config.js` alias `$components → src/components` помечен `@DEPRECATED`.
|
||||
|
||||
### API Client Convention
|
||||
|
||||
API client modules live under `frontend/src/lib/api/`. Each module wraps `fetch` calls to the FastAPI backend, attaches the JWT access token from the auth store, and normalises errors into a typed `ApiError`. Contract annotations follow the rules defined in the `semantics-core` and `semantics-contracts` skills.
|
||||
|
||||
### Component Complexity & UX Contracts
|
||||
|
||||
Svelte components with side effects (API calls, WebSocket subscriptions, file operations) MUST carry UX contract tags as defined by the `semantics‑svelte` skill (`.opencode/skills/semantics‑svelte/SKILL.md`). The skill is the canonical source for tag inventory (`@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY`, `@UX_REACTIVITY`, `@UX_TEST`), syntax, and per‑complexity requirements.
|
||||
|
||||
### Routing
|
||||
|
||||
SvelteKit file‑based routing under `frontend/src/routes/`. Protected routes check auth in `+layout.svelte` (redirect to `/login` if no token).
|
||||
|
||||
**Actual route structure** (verified 2026‑06‑02):
|
||||
|
||||
| Route | Page | Complexity |
|
||||
|-------|------|------------|
|
||||
| `/login` | Login page | C1 |
|
||||
| `/` (redirect → `/dashboards`) | Dashboard hub | C4 → C4+Model |
|
||||
| `/dashboards` | Dashboard hub with git status, validation badges, bulk actions | C4 |
|
||||
| `/dashboards/[id]` | Dashboard detail: metadata, charts, datasets, git, task history | C4 |
|
||||
| `/dashboards/[id]/validation` | Validation status and run history | C3 |
|
||||
| `/dashboards/health` | Dashboard health matrix | C3 |
|
||||
| `/datasets` | Dataset hub with mapping progress, split view | C4 |
|
||||
| `/datasets/[id]` | Dataset detail | C3 |
|
||||
| `/migration` | Migration wizard (env selection → dashboards → review → execute) | C4 |
|
||||
| `/migration/mappings` | Migration database mappings | C3 |
|
||||
| `/reports` | Unified reports with task‑type and status filtering | C3 |
|
||||
| `/reports/llm/[taskId]` | LLM validation report | C3 |
|
||||
| `/validation‑tasks` | Validation tasks list with status filter, pagination | C3 |
|
||||
| `/validation‑tasks/new` | Create validation task | C3 |
|
||||
| `/validation‑tasks/[policyId]` | Task detail + runs | C3 |
|
||||
| `/validation‑tasks/[policyId]/edit` | Edit validation task | C3 |
|
||||
| `/validation‑tasks/[policyId]/runs/[runId]` | Run detail with logs | C4 |
|
||||
| `/validation‑tasks/history` | Task run history | C3 |
|
||||
| `/settings` | Consolidated settings (environments, logging, LLM, migration, storage, features, system) | C4 |
|
||||
| `/settings/git` | Git configuration | C3 |
|
||||
| `/settings/notifications` | Notification preferences | C2 |
|
||||
| `/settings/automation` | Automation rules | C3 |
|
||||
| `/profile` | User profile and preferences | C2 |
|
||||
| `/translate` | Translation hub: dictionaries + configs | C4 |
|
||||
| `/translate/[id]` | Translation config detail | C4 |
|
||||
| `/translate/dictionaries` | Dictionary list | C3 |
|
||||
| `/translate/dictionaries/[id]` | Dictionary detail with entries | C4 |
|
||||
| `/translate/history` | Translation run history | C3 |
|
||||
| `/admin/users` | User management (RBAC) | C3 |
|
||||
| `/admin/roles` | Role management | C3 |
|
||||
| `/admin/settings` | Admin‑level settings | C3 |
|
||||
| `/admin/settings/llm` | LLM provider configuration | C3 |
|
||||
| `/git` | Git operations | C3 |
|
||||
| `/storage` | Storage management | C3 |
|
||||
| `/storage/repos` | Repository list | C3 |
|
||||
| `/storage/backups` | Backup management | C3 |
|
||||
| `/tools/mapper` | Data mapper tool | C3 |
|
||||
| `/tools/debug` | Debug tool | C3 |
|
||||
| `/tools/storage` | Storage tool | C3 |
|
||||
| `/tools/backups` | Backup tool | C3 |
|
||||
| `/maintenance` | Maintenance events and settings | C3 |
|
||||
|
||||
### Guardrail Enforcement
|
||||
|
||||
`scripts/audit-frontend-style.mjs` — CI‑ready script that checks:
|
||||
|
||||
1. **Raw color violations** — `bg‑blue‑*`, `text‑gray‑*`, `border‑indigo‑*`, etc. in page and component files
|
||||
2. **Oversized pages** — pages > 400 lines (INV_7)
|
||||
3. **Model‑first violations** — pages with >5 `$state` atoms and no Model import
|
||||
4. **Manual `<button>` warnings** — pages with `<button>` but no `<Button>` import
|
||||
5. **Legacy directory** — files remaining in frozen `src/components/`
|
||||
|
||||
Exit code 1 on violations → CI blocks merge.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Design tokens eliminate visual drift — AI agents get a single color palette.
|
||||
- Screen Models make complex pages testable without DOM (~10ms unit tests).
|
||||
- Guardrail catches violations before merge.
|
||||
- UI atoms ensure consistent interaction patterns (loading states, error borders, focus rings).
|
||||
|
||||
### Negative
|
||||
- Legacy writable stores require `$store` syntax in `.svelte` files and `get()` in `.svelte.ts` — a bridge between Svelte 4 and 5 that must be maintained until full migration.
|
||||
- `src/components/` still exists as a frozen directory — 40 files to migrate.
|
||||
- Screen Model `this`‑context requires arrow wrappers (`onclick={() => m.method()}`) — a pitfall documented in ai‑native‑design‑audit.md §10.
|
||||
|
||||
## References
|
||||
|
||||
- ADR‑0001 — Module Layout
|
||||
- ADR‑0002 — Semantic Protocol
|
||||
- ADR‑0007 — `fromStore` + `$derived` rejection
|
||||
- ADR‑0010 — Model Decomposition Gate
|
||||
- ADR-0018 - Rejected Dataset Review
|
||||
- `semantics‑svelte` skill — UI implementation rules, canonical template
|
||||
- `ai‑native‑design‑audit.md` — design audit findings and execution log
|
||||
`npm run lint`, `npm run build`, component tests and browser checks are the available frontend gates. The former route inventory, package-version table, measured legacy counts and references to `scripts/audit-frontend-style.mjs` were snapshots or plans; that script does not exist, so this ADR makes no claim of an automated color/model-size CI gate. Inspect actual routes and package versions from the repository when needed.
|
||||
|
||||
## @} Doc.Adr.ADR0006
|
||||
|
||||
@@ -1,120 +1,24 @@
|
||||
## @{ Doc.Adr.ADR0010 [C:1] [TYPE ADR]
|
||||
# @STATUS ACCEPTED
|
||||
# @STATUS PARTIALLY IMPLEMENTED
|
||||
# @BRIEF Define the model decomposition gate for Svelte 5 Screen Models: maximum size thresholds, split strategy, and enforcement mechanism to prevent god‑object anti‑pattern in model‑first architecture.
|
||||
# @RELATION DEPENDS_ON -> [Doc.Adr.ADR0006]
|
||||
# @RELATION DEPENDS_ON -> [Std.Semantics.Svelte]
|
||||
# @RELATION CALLS -> [Doc.Adr.DashboardHubModel]
|
||||
# @RATIONALE Screen Models (`[TYPE Model]` in `.svelte.ts` files) aggregate all screen‑level state and actions. Without a size gate, models accumulate methods indefinitely as features grow — reproducing the same monolith problem that model‑first architecture was designed to solve. The DashboardHubModel (2026‑06‑02, PR 4) is the first model to approach the threshold at 350 lines and 35 public methods. This ADR locks in the decomposition rule before the model crosses the boundary.
|
||||
# @RATIONALE Screen Models (`[TYPE Model]` in `.svelte.ts` files) aggregate all screen‑level state and actions. Without a size gate, models accumulate methods indefinitely as features grow — reproducing the same monolith problem that model‑first architecture was designed to solve. The current DashboardHubModel exceeds the line threshold, so the split remains outstanding.
|
||||
# @RATIONALE 400‑line threshold mirrors INV_7 (module <400 lines) from `semantics‑core` §I, creating a consistent rule across both page files and model files.
|
||||
# @RATIONALE 40‑method threshold is based on practical cognitive load: beyond ~40 public methods, an AI agent or human developer cannot hold the full model API in working memory when reasoning about invariants.
|
||||
# @REJECTED No size gate — rejected because DashboardHubModel already demonstrates the accumulation pattern (35 atoms, 35 methods from one refactoring pass). Without a gate, future features (batch‑delete, export, saved‑filters) would push it past 600 lines.
|
||||
# @REJECTED No size gate — rejected because Screen Models can reproduce the monolith problem at the model layer.
|
||||
# @REJECTED Arbitrary split (e.g. file‑size only) — rejected because splitting by line count alone ignores method cohesion. The split MUST follow responsibility boundaries (filters, selection, git) to keep invariants within each submodel locally verifiable.
|
||||
|
||||
# ADR-0010: Model Decomposition Gate
|
||||
|
||||
## Status
|
||||
ACTIVE — enforced for all C4+ Screen Models as of 2026‑06‑02.
|
||||
|
||||
## Context
|
||||
|
||||
Screen Models (defined in `semantics‑svelte` §IIIa) are the single source of truth for screen‑level state. They use Svelte 5 runes (`$state`, `$derived`) in `.svelte.ts` files and declare `@STATE`, `@ACTION`, and `@INVARIANT` annotations.
|
||||
|
||||
The first wave of model extraction (PR 4, June 2026) produced `DashboardHubModel`: 350 lines, 35 `$state` atoms, and 35 public methods. While this is a 51% reduction from the original 2765‑line page, the model already bundles four distinct responsibility domains:
|
||||
|
||||
1. **Core data** — pagination, loading/error state, environment context
|
||||
2. **Filters & sort** — column filter dropdowns, search, sort direction
|
||||
3. **Selection & bulk actions** — checkboxes, select‑all, migrate/backup modals
|
||||
4. **Git operations** — init, sync, commit, pull, push, status batch
|
||||
|
||||
Adding future features (batch‑delete, saved‑filters, export) to the same model would push it past 600 lines — reproducing the monolith anti‑pattern at the model layer.
|
||||
|
||||
## Decision
|
||||
|
||||
### Thresholds
|
||||
Screen Models split by responsibility when a model reaches the GRACE-Poly module limit of 400 lines or approximately 40 public methods. A split should keep cohesive state and actions together and define cross-model invariants at the composing boundary. A mechanical split by line number is not an acceptable substitute.
|
||||
|
||||
| Criterion | Threshold | Action |
|
||||
|-----------|-----------|--------|
|
||||
| Model file > **400 lines** | Mandatory | MUST decompose before adding new methods |
|
||||
| Model > **40 public methods** (excluding private `_` helpers) | Mandatory | MUST split into submodels by responsibility |
|
||||
| Either threshold crossed | Hard gate | New PRs adding methods to the model MUST include the split |
|
||||
## Current enforcement gap
|
||||
|
||||
### Split Strategy
|
||||
|
||||
When a model crosses either threshold, split into submodels by **responsibility domain**. Each submodel:
|
||||
|
||||
- Lives in its own `.svelte.ts` file: `<Domain>Model.svelte.ts`
|
||||
- Declares `@RELATION DEPENDS_ON -> [ParentModel]` on the parent model
|
||||
- Exports a class with `$state` atoms and public methods for its domain only
|
||||
- Is instantiated as a property of the parent model
|
||||
|
||||
**Concrete split plan for `DashboardHubModel` (when threshold is crossed):**
|
||||
|
||||
```
|
||||
lib/models/
|
||||
├── DashboardHubModel.svelte.ts # Core: pagination, load, environment, composes submodels
|
||||
├── DashboardFiltersModel.svelte.ts # Search, column filters, sort
|
||||
├── DashboardSelectionModel.svelte.ts # Checkbox, select all/visible, bulk modals
|
||||
├── DashboardGitModel.svelte.ts # Git init, sync, commit, pull, push, status batch
|
||||
```
|
||||
|
||||
**Parent model composition pattern:**
|
||||
|
||||
```typescript
|
||||
// #region Example.Dashboardhubmodel [C:4] [TYPE Model] [SEMANTICS dashboard,hub,core]
|
||||
// @RELATION DEPENDS_ON -> [DashboardFiltersModel]
|
||||
// @RELATION DEPENDS_ON -> [DashboardSelectionModel]
|
||||
// @RELATION DEPENDS_ON -> [DashboardGitModel]
|
||||
export class DashboardHubModel {
|
||||
readonly filters = new DashboardFiltersModel();
|
||||
readonly selection = new DashboardSelectionModel();
|
||||
readonly git = new DashboardGitModel();
|
||||
|
||||
// Core atoms only:
|
||||
selectedEnv = $state<string | null>(null);
|
||||
allDashboards = $state<DashboardRow[]>([]);
|
||||
isLoading = $state(true);
|
||||
// ... < 10 atoms
|
||||
}
|
||||
// #endregion Example.Dashboardhubmodel
|
||||
```
|
||||
|
||||
### Enforcement
|
||||
|
||||
1. **Documentation** — every C4+ model MUST declare `@INVARIANT DECOMPOSITION GATE: 400 lines, 40 methods` with a comment referencing this ADR.
|
||||
2. **Guardrail** — `scripts/audit-frontend-style.mjs` already checks page size; extend with model size check (`>400 lines OR >40 public methods`).
|
||||
3. **Code review** — new PRs adding methods to a model near 300+ lines MUST include either a split plan in the PR description or a `@RATIONALE` explaining why split is deferred.
|
||||
|
||||
### Relationship to Page Size Gate
|
||||
|
||||
| Level | Threshold | Documented in |
|
||||
|-------|-----------|---------------|
|
||||
| Page file (`+page.svelte`) | 400 lines (INV_7) | `semantics‑core` §I |
|
||||
| Screen Model (`.svelte.ts`) | 400 lines OR 40 methods | This ADR |
|
||||
|
||||
A page that exceeds 400 lines is decomposed into Model + Components. A Model that exceeds 400 lines is decomposed into submodels. The invariant cascades down.
|
||||
This is an architectural target, not an existing automatic CI gate. `scripts/audit-frontend-style.mjs` does not exist. At this audit, `frontend/src/lib/models/DashboardHubModel.svelte.ts` is 532 lines and `DatasetsHubModel.svelte.ts` is 463 lines, both over the 400-line limit. Status is `PARTIALLY IMPLEMENTED`; the first follow-up is to decompose these models along their actual responsibilities, then add an executable size check if the team wants a hard merge gate. The Svelte semantic skill remains the source for current contract syntax and complexity rules.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Models stay reviewable and testable (unit tests for each submodel in isolation).
|
||||
- AI agents receive a clear instruction via `@INVARIANT DECOMPOSITION GATE` in the model header.
|
||||
- Future features map directly to submodels — no ambiguity about where to add code.
|
||||
|
||||
### Negative
|
||||
- Cross‑submodel invariants become harder to declare (e.g., "changing filter resets selection" spans two submodels). Mitigation: the parent model composes submodels and declares cross‑cutting invariants.
|
||||
- Slightly more indirection (`m.filters.searchQuery` vs `m.searchQuery`). Mitigation: TypeScript autocomplete resolves this in IDEs.
|
||||
|
||||
## Migration
|
||||
|
||||
1. **Done** — `DashboardHubModel` created (350 lines, below threshold). `@INVARIANT DECOMPOSITION GATE` added with split plan.
|
||||
2. **Next** — when model crosses 400 lines or 40 methods, execute the split plan documented in the model header.
|
||||
3. **Future models** — all new C4+ Screen Models created after this ADR MUST include the `@INVARIANT DECOMPOSITION GATE` annotation.
|
||||
|
||||
## References
|
||||
|
||||
- `semantics‑core` §I INV_7 — module < 400 lines
|
||||
- `semantics‑svelte` §IIIa — Screen Model contract template
|
||||
- `semantics‑svelte` §IIIa.1 — Model Decomposition Gate (added 2026‑06‑02)
|
||||
- `ai‑native‑design‑audit.md` §10 — Lessons learned from PR 4
|
||||
Review of new model work must account for the affected model's current size and existing responsibility boundaries. A passing build alone does not prove this decomposition rule. Do not add synthetic `@INVARIANT` lines merely to claim compliance; preserve concrete state and action contracts.
|
||||
|
||||
## @} Doc.Adr.ADR0010
|
||||
|
||||
@@ -1,58 +1,24 @@
|
||||
## @{ Doc.Adr.ADR0011 [C:1] [TYPE ADR]
|
||||
# @STATUS PARTIALLY IMPLEMENTED
|
||||
# @BRIEF Документировать async-first направление backend: `httpx.AsyncClient` для новых внешних вызовов, bounded executors для блокирующих операций и Task Manager на asyncio.
|
||||
# @RATIONALE superset-tools одновременно взаимодействует с несколькими внешними системами (Superset REST API, LLM API, SMTP, Telegram, Slack) и выполняет блокирующие операции (Git, файловый I/O, SQLAlchemy). При одном воркере uvicorn синхронные блокирующие вызовы останавливают все входящие HTTP-запросы. Единственный способ обеспечить конкурентность — сделать весь I/O неблокирующим.
|
||||
# @RATIONALE superset-tools одновременно взаимодействует с Superset и другими внешними системами. Долгие синхронные вызовы внутри event loop задерживают соседние запросы; новые конкурентные пути используют async I/O, а блокирующая работа выносится в ограниченные executors.
|
||||
# @REJECTED aiohttp — отвергнут в пользу httpx, который уже был в зависимостях (0.28.1) и имеет лучший API для async HTTP.
|
||||
# @REJECTED Полный переход на async SQLAlchemy — потребовал бы переписывания всех моделей и репозиториев, что выходит за рамки этой фичи. `run_blocking` с bounded executor достаточен.
|
||||
# @REJECTED Dual-stack (sync + async) — потребовал бы поддержки двух транспортов, увеличивая surface для ошибок и усложняя код.
|
||||
# @REJECTED Бессрочное развитие двух независимых sync/async транспортов для одной новой функции — расходящиеся правила авторизации и ошибок усложняют сопровождение. Существующий sync-код остаётся до отдельной миграции.
|
||||
# @REJECTED Wrapper-only (`asyncio.to_thread` на всё) — rejected, т.к. `asyncio.to_thread` не принимает executor и использует default пул без backpressure.
|
||||
# @REJECTED Multiprocessing / `--workers N` — rejected, т.к. увеличение воркеров не решает проблему блокировки внутри одного воркера и увеличивает потребление памяти.
|
||||
|
||||
## Decision
|
||||
|
||||
Новые конкурентные пути backend строятся по async-first модели. Утверждение о
|
||||
полной миграции всех существующих путей снято: в репозитории остаются
|
||||
синхронные совместимые клиенты и адаптеры, пока они не будут переведены
|
||||
отдельными изменениями с тестами.
|
||||
New concurrent backend paths should use async HTTP I/O and keep blocking Git, file or synchronous database work out of the event loop. `backend/src/core/utils/async_network.py` provides `AsyncAPIClient`; `backend/src/core/utils/client_registry.py` manages Superset clients; `backend/src/core/utils/executors.py` provides bounded executor dispatch. The task manager and event bus contain asyncio-based components under `backend/src/core/task_manager/`.
|
||||
|
||||
### Transport Layer
|
||||
The existing sync client and compatibility paths remain in the repository. This ADR does not claim that every Superset mixin, API route, plugin or SQLAlchemy access is async. Each conversion needs a specific call-chain audit so that cancellation, transaction ownership, rate limits and error mapping remain correct.
|
||||
|
||||
1. **HTTP-клиент**: `requests.Session` → `httpx.AsyncClient` через класс `AsyncAPIClient` в `core/utils/async_network.py`.
|
||||
2. **SupersetClient**: sync + AsyncSupersetClient объединены в единый асинхронный клиент. Все 13 миксинов — `async def`.
|
||||
3. **Семафоры**: Глобальный per‑env `asyncio.Semaphore` через синглтон `SupersetClientRegistry` (`core/utils/client_registry.py`). При исчерпании — таймаут 30s → 503.
|
||||
4. **LLM-клиент**: Унифицированный `LLMHttpClient` в `plugins/translate/_llm_async_http.py` на `httpx.AsyncClient`. Rate-limit backoff через `asyncio.sleep`.
|
||||
## Consequences and limits
|
||||
|
||||
### Blocking Operations
|
||||
|
||||
5. **Bounded executors**: Именованные `ThreadPoolExecutor` (db, file, git) через helper `run_blocking(kind, fn)` в `core/utils/executors.py`. Вместо `asyncio.to_thread`.
|
||||
6. **Graceful shutdown**: Все клиенты закрываются при shutdown через `SupersetClientRegistry.shutdown()`. Незавершённые задачи помечаются CANCELLED.
|
||||
|
||||
### Task Manager
|
||||
|
||||
7. **Запуск задач**: `ThreadPoolExecutor` → `asyncio.create_task`. Задачи разделяют основной event loop.
|
||||
8. **EventBus**: `queue.Queue` + `call_soon_threadsafe` → `asyncio.Queue(maxsize=10000)` с fan‑out на подписчиков.
|
||||
9. **Lifecycle**: Thread-based → async context manager.
|
||||
|
||||
### Конфигурация
|
||||
|
||||
10. **Per‑env**: `connection_pool_size`, `connection_pool_timeout`, `request_timeout`, `llm_timeout` в `EnvironmentConfig`.
|
||||
11. **App‑level**: `db_executor_workers`, `file_executor_workers`, `git_executor_workers`, `graceful_shutdown_timeout` в `AppAsyncRuntimeConfig`.
|
||||
|
||||
### Тестирование
|
||||
|
||||
12. `requests_mock` → `pytest-httpx` для мокирования асинхронных HTTP-вызовов.
|
||||
13. Тесты конкурентности с `asyncio.gather` для всех C3+ контрактов.
|
||||
14. Rejected-path regression тесты для каждого запрещённого паттерна.
|
||||
|
||||
### Модули, затронутые рефакторингом
|
||||
|
||||
- `core/utils/`: `async_network.py` (+semaphore), `client_registry.py` (new), `executors.py` (new), `fileio.py` (+async wrappers), `network.py` → Tombstone, `superset_compilation_adapter.py` → async
|
||||
- `core/superset_client/`: `_base.py` (+13 mixins) → все async
|
||||
- `core/task_manager/`: `manager.py`, `event_bus.py`, `lifecycle.py` → все async
|
||||
- `api/routes/`: dashboards, assistant, migration, datasets, git, environments, profile → все async
|
||||
- `plugins/translate/`: `_llm_async_http.py` (new), все файлы → async
|
||||
- `plugins/`: `backup.py`, `git_plugin.py`, `llm_analysis/plugin.py`, `storage/plugin.py` → async
|
||||
- `services/`: `notifications/providers.py`, `notifications/service.py`, `git/_base.py` → async
|
||||
- `services/`: `git/_base.py`, `git/_branch.py`, `git/_sync.py`, `git/_status.py`, `git/_merge.py` → async
|
||||
- A new async route must not call a blocking dependency directly on the event loop. Use an existing async client or a bounded executor where that dependency remains synchronous.
|
||||
- Do not create an independent second transport for a new feature when the shared client already exposes the operation.
|
||||
- Client shutdown and task cancellation must be handled at their owning lifecycle boundary.
|
||||
- The earlier numbered migration checklist, specific pool settings and claim that `core/utils/network.py` was a tombstone were a plan, not verified repository state. `network.py` still exists. Status remains `PARTIALLY IMPLEMENTED`; feature specs and code own the remaining migration tasks.
|
||||
|
||||
## @} Doc.Adr.ADR0011
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
## @{ Doc.Adr.ADR0021 [C:4] [TYPE ADR]
|
||||
# @STATUS ACCEPTED
|
||||
# @STATUS PARTIALLY IMPLEMENTED
|
||||
# @BRIEF EXPLORE-событие самодиагностируемо: contract_id/claim/error_code/loc + токен-экономика лога + единственная детекция тихих отказов в axiom-mcp.
|
||||
# @RELATION DEPENDS_ON -> [Doc.Adr.ADR0017]
|
||||
# @RELATION DEPENDS_ON -> [Doc.Adr.ADR0022]
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
| Категория | Статус | Где |
|
||||
|---|---|---|
|
||||
| K0 swallowed exceptions (except/catch без raise/log; contextlib.suppress; except*) | аудит | axiom-mcp `audit_belief_runtime`: `BELIEF_SILENT_EXCEPT`, `BELIEF_SILENT_SUPPRESS` (warning); baseline `docs/audit/cot_silence_baseline.json` в ss-tools; escape `# cot-audit: allow` |
|
||||
| K0 swallowed exceptions (except/catch без raise/log; contextlib.suppress; except*) | аудит | axiom-mcp `audit_belief_runtime`: `BELIEF_SILENT_EXCEPT`, `BELIEF_SILENT_SUPPRESS` (warning); файл baseline `docs/audit/cot_silence_baseline.json` пока отсутствует; escape `# cot-audit: allow` |
|
||||
| K1 lookup-miss → return None без EXPLORE | advisory | axiom `BELIEF_SILENT_FALLBACK` (info) |
|
||||
| K3-стиль (f-string intent, нет error=) | advisory | axiom `BELIEF_EXPLORE_UNSTRUCTURED` (info) |
|
||||
| K2/K3-семантика | не автоматизируется | конвенция «лог на месте нарушенного предположения» + ground truth |
|
||||
@@ -80,7 +80,7 @@ Enriched-поля persist'ятся в `task_logs.payload` (без миграци
|
||||
marker mix, матрица переходов внутри trace, orphan-EXPLORE ratio, REFLECT pairing rate,
|
||||
top intent families, coverage новых полей, payload-cap hits, флаг insufficient sample (N<2k —
|
||||
порог устойчивости распределений из Molecular §C.2). Референсный bond-профиль калибруется
|
||||
пост-sweep прогоном (`docs/audit/bond_profile_baseline.json`), не хардкодится из литературы.
|
||||
пост-sweep прогоном; предложенный `docs/audit/bond_profile_baseline.json` пока отсутствует и не служит текущим порогом.
|
||||
Фронт-панели: Reports→Logs (AnalyticsStatsPanel), Reports→Tasks (TaskGapPanel, клик по gap-
|
||||
задаче переиспользует существующий cross-filter).
|
||||
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
## @{ Doc.Adr.ADR0024 [C:5] [TYPE ADR]
|
||||
# @STATUS IMPLEMENTED (2026-09-07: test-honesty remediation AND the MCP creation/read surface landed —
|
||||
# 050 Phase 2d T029i/T029j, catalog 2.2.0, full backend suite 11357 passed; the live-stand replay
|
||||
# `E2E-EXT-002` remains tracked as T029m)
|
||||
# @BRIEF The 050 initial-bootstrap chain is code-complete but externally unreachable: `register_draft_pack`
|
||||
# requires a server-owned `AgentRun` belonging to the authenticated principal, yet after the chat
|
||||
# decommission (050 T041) no MCP operation creates or resolves one — the only creation boundary
|
||||
# (`POST /api/agent/runs`) is a web-session REST route that MCP access tokens cannot authenticate
|
||||
# against. The vertical E2E masked this by raw-ORM-seeding the prerequisite, so the 2026-09-07 field
|
||||
# run against ss-prod (docs/2026-09-07-sales-prod-mcp-run.md) was the first observer of the gap.
|
||||
# Decision: an explicit typed MCP `create_agent_run`/`get_agent_run` surface, an external-reachability
|
||||
# invariant for all durable MCP prerequisites, and a binding test-honesty rule.
|
||||
# @STATUS IMPLEMENTED
|
||||
# @BRIEF Give external MCP principals a reachable, owned AgentRun prerequisite for scenario bootstrap.
|
||||
# @RELATION DEPENDS_ON -> [Doc.Adr.ADR0023]
|
||||
# @RELATION BINDS_TO -> [backend/src/mcp_server/tools_scenario.py]
|
||||
# @RELATION BINDS_TO -> [backend/src/mcp_server/rbac_server.py]
|
||||
@@ -91,31 +82,11 @@ The same field run documented two adjacent gaps that are specified alongside thi
|
||||
`tests/test_mcp_agent_run_reachability.py` pins both the reachability requirement (strict xfail until
|
||||
T029i) and the typed zero-side-effect denial of the current state.
|
||||
|
||||
## Consequences
|
||||
## Consequences and closure
|
||||
|
||||
- The external happy path stays **NO-GO** until T029i lands *(landed 2026-09-07 — see Closure below)*; the
|
||||
E2E-AUTH/SC-001 evidence rows for the initial-bootstrap chain read with this caveat (050 spec.md
|
||||
release-gate table gains explicit field-run remediation rows).
|
||||
- After T029i: `test_mcp_initial_scenario_e2e.py` converts to the fully external chain
|
||||
(`create_agent_run` → `register_draft_pack` → `bootstrap_authoring_scenario` → `start_scenario_run`) and
|
||||
the strict-xfail pin is removed; the reachability test suite documents both states so the flip is loud.
|
||||
- The REST `POST /api/agent/runs` route remains unchanged for its surviving internal/UI consumers; the MCP
|
||||
tool is a second curated window on the same service, not a fork.
|
||||
- Catalog growth is additive (minor bump); `PINNED_CATALOG_MAJOR=2` ritual untouched; RBAC visibility tests
|
||||
(SC-004 exact sets) and `tests/api/test_admin_mcp_catalog.py` mirror constants must be updated with the
|
||||
new tool names.
|
||||
- **Closure (2026-09-07, same day):** `create_agent_run`/`get_agent_run` implemented
|
||||
(`src/mcp_server/tools_agent_run.py`, 155 LOC; catalog `2.2.0`; EXECUTE/READ permissions, human-only);
|
||||
`test_mcp_initial_scenario_e2e.py` converted to the fully external chain (AgentRun minted over
|
||||
`tools/call`, zero non-MCP seeding; `E2E-EXT-001` CLOSED); the strict-xfail reachability pin flipped
|
||||
exactly as designed and was unmarked into a hard requirement test; SC-004/admin mirror tests needed no
|
||||
constant changes (derived from the live catalog; analyst grants exclude EXECUTE/READ). Companion gaps
|
||||
closed the same day: T029k capability derivation (`CAP-001`) and T029l disposition clarity (`DISP-001`).
|
||||
Gates at closure: full backend suite **11357 passed, 0 failed** (the first full-suite rerun since the
|
||||
`4d5ef6be`/`58c5ae39` batches — it also surfaced and fixed two pre-existing HEAD regressions unrelated
|
||||
to this ADR: the stale SC-007 `resource` pin in `test_mcp_client_flow_http.py` vs the canonical
|
||||
post-redirect `/mcp/` identifier, and app-lifespan test re-entry against the run-once
|
||||
StreamableHTTPSessionManager singleton), frontend **3507 passed**, lint 0 errors, build OK. Remaining:
|
||||
live-stand replay `E2E-EXT-002` (T029m).
|
||||
- The MCP catalog exposes `create_agent_run` and `get_agent_run` over the existing AgentRun service with human-principal ownership and RBAC. The REST route remains a separate transport onto the same service.
|
||||
- The external bootstrap test now obtains AgentRun through MCP instead of raw ORM seeding. The reachability requirement is a regular passing test; the temporary strict-xfail pin described in the decision is historical.
|
||||
- `E2E-EXT-001` and the live external replay `E2E-EXT-002` (050 T029m) are **CLOSED**. The latter ran on the test stand on 2026-09-11 with a complete MCP chain, a PROD gate, browser action and a separate human checkpoint loop. Its exact evidence and remaining baseline-specific limits are in [the replay report](../2026-09-11-sales-prod-mcp-replay.md) and [050 traceability](../../specs/050-mcp-interface/traceability.md).
|
||||
- The 2026-09-07 backend/frontend suite counts describe that historical implementation checkpoint; they are not current release verification. This ADR remains binding for new MCP write prerequisites and for honest external-client tests.
|
||||
|
||||
## @} Doc.Adr.ADR0024
|
||||
|
||||
27
docs/adr/ADR-0025-reference-dashboard-url-baseline.md
Normal file
27
docs/adr/ADR-0025-reference-dashboard-url-baseline.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## @{ Doc.Adr.ADR0025 [C:4] [TYPE ADR]
|
||||
# @STATUS PARTIALLY IMPLEMENTED
|
||||
# @BRIEF Bind every new baseline to a verified same-dashboard Superset URL and frozen effective native-filter state.
|
||||
# @RELATION DEPENDS_ON -> [Doc.Adr.ADR0003]
|
||||
# @RELATION DEPENDS_ON -> [Doc.Adr.ADR0024]
|
||||
# @RATIONALE A permalink or native_filters_key addresses mutable or expiring Superset state. A value without the parsed dashboard and filter context cannot be reproduced, reviewed or safely replayed on a schedule.
|
||||
# @REJECTED Treating a bare expected value or unparsed URL as a baseline was rejected because it hides the active filter context and can approve a value for the wrong dashboard.
|
||||
# @REJECTED Re-fetching the permalink or native_filters_key during a scheduled run was rejected because changed or expired link state would alter the expected truth after human approval.
|
||||
# @REJECTED Trusting browser-supplied metric values, formula specs or evidence hashes was rejected because only server-executed chart data and stored evidence bytes establish capture provenance.
|
||||
|
||||
## Context
|
||||
|
||||
The analyst selects native filters on the scenario's dashboard in Superset, then supplies that dashboard URL to the scenario editor or an external MCP agent. The editor must identify chart, dataset and metric relationships and allow the analyst to choose a subset for a baseline. Both an ordinary dashboard link with `native_filters_key` and a Superset permalink are valid inputs when they resolve to the same dashboard and configured environment.
|
||||
|
||||
## Decision
|
||||
|
||||
1. The server resolves the URL only through a configured Superset environment and checks the scenario's authoritative dashboard identity. Another dashboard or environment is a typed input error.
|
||||
2. The server combines URL overrides with applicable dashboard defaults, freezes normalized native filters, and executes bounded server-owned chart/dataset metric queries under those filters. Each offered metric names its chart and dataset. A chart metric retains **both** `chart_id` and `dataset_id`; catalog and revision schemas require at least one concrete ID and permit both.
|
||||
3. Selection creates draft candidates with protected URL bytes/hash, URL kind, environment/dashboard identity, query-model fingerprint and normalized filter snapshot. Capture re-executes selected coordinates and records its freshly observed evidence digest. Human source review and reasoned approval precede catalog materialization; discovery never approves a value.
|
||||
4. Approval, catalog revision, Git publication and BaselineSelectionPin carry the verified reference identity. Scheduled execution consumes the frozen pin and never resolves the link again. Entries without verified URL provenance remain inspection-only until recaptured and reviewed.
|
||||
5. The detailed wire shape, error taxonomy and lifecycle gates live in [ReferenceUrl](../../specs/037-superset-baseline-engine/contracts/reference-url.md), its [schema](../../specs/037-superset-baseline-engine/contracts/reference-url.schema.json) and the [production acceptance matrix](../../specs/PRODUCTION-ACCEPTANCE-MATRIX-043-050.md). This ADR records the cross-domain choice; those contracts are the implementation source of truth.
|
||||
|
||||
## Evidence and remaining work (2026-09-25)
|
||||
|
||||
The supplied `ss-dev` dashboard 10 URL resolved `platform IN ["3DS"]`; ten chart metrics executed and six distinct formulas were suggested. An isolated browser journey passed 2/2 for URL input, chart attribution, six draft captures, source checksum review, reasoned approval into the local catalog and a different-dashboard field error. The catalog revision retained verified URL bytes/hash and the filter snapshot for chart 96/dataset 20 metric `count`. The fixture used a synthetic test-only release. Its revision is `materialized` without a Git publication receipt. Real release acceptance, Git publication, run pin, scheduled replay and visual/scenario-transform URL capture are still open. See [handoff](../../specs/agent-handoffs/reference-url-to-scheduled-baseline-2026-09-25.md).
|
||||
|
||||
## @} Doc.Adr.ADR0025
|
||||
19
docs/adr/ADR-0026-literal-dataset-mutation.md
Normal file
19
docs/adr/ADR-0026-literal-dataset-mutation.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## @{ Doc.Adr.ADR0026 [C:3] [TYPE ADR]
|
||||
# @STATUS PARTIALLY IMPLEMENTED
|
||||
# @BRIEF Use one literal replacement rule for live Superset dataset edits and migration ZIP transforms.
|
||||
# @RELATION DEPENDS_ON -> [Doc.Adr.ADR0003]
|
||||
# @RATIONALE A plain substring replacement is deterministic across both surfaces and avoids changing regex meaning between a preview and an apply operation.
|
||||
# @REJECTED Full sed/regex syntax was rejected because pattern expansion can change more metadata than the analyst selected.
|
||||
# @REJECTED Applying an unscoped recursive replacement to every live dataset field was rejected as the default because it can rewrite descriptions and unrelated metadata; full YAML replacement is an explicit exclusive target.
|
||||
|
||||
## Context and decision
|
||||
|
||||
The dataset viewer and migration flow need the same `{find, replace}` literal semantics. `backend/src/core/literal_replace.py` performs the shared operation; empty `find` is a no-op in that helper. The live mutation API rejects empty `find`, accepts explicit IDs or all datasets in one configured environment, and scopes changes to `sql`, metric `expression`/`verbose_name`, or an exclusive full `yaml` target. It returns per-dataset failures. Migration applies the same helper to exported dataset YAML before import. Named rules are browser-local in `frontend/src/lib/stores/sedRules.svelte.ts` under `dsh.datasetSedRules.v1`.
|
||||
|
||||
The viewer presents a read-only preview before enabling Apply. `DatasetsHubModel.svelte.ts` binds preview validity to selected IDs, replacement text and targets. **This fingerprint gate currently lives in the frontend only:** `POST /api/datasets/mutate` accepts an otherwise valid direct API request without a server-issued preview token or state comparison. Therefore the product must not claim a server-enforced preview prerequisite. A server-side preview/confirm contract requires a separate implementation decision and evidence.
|
||||
|
||||
## Status and consequence
|
||||
|
||||
The literal operation, target scoping, migration transform and browser preview are implemented. The backend preview is read-only and computes edits from the same target selection as apply; live dataset contents may change between requests. The earlier unnumbered `docs/adr-sed-dataset-mutations.md` was a proposal written before these modules existed and is replaced by this verified record.
|
||||
|
||||
## @} Doc.Adr.ADR0026
|
||||
@@ -9,6 +9,7 @@ ADR фиксирует принятое и проверяемое архитек
|
||||
| Статус | Значение |
|
||||
|---|---|
|
||||
| `ACCEPTED` | Решение действует и соответствует реализации. |
|
||||
| `IMPLEMENTED` | Закрытое решение с подтверждённой реализацией; исторические счётчики тестов не являются текущим release gate. |
|
||||
| `PARTIALLY IMPLEMENTED` | Направление принято, но в репозитории есть существенная незавершённая часть. |
|
||||
| `SUPERSEDED` | Решение заменено указанным ADR; исторический файл не меняет действующую архитектуру. |
|
||||
| `REJECTED` | Альтернатива запрещена; это не описание действующей реализации. |
|
||||
@@ -18,17 +19,17 @@ ADR фиксирует принятое и проверяемое архитек
|
||||
|
||||
| ADR | Статус | Проверенное содержание |
|
||||
|---|---|---|
|
||||
| [0001](ADR-0001-module-layout.md) | `PARTIALLY IMPLEMENTED` | Базовое разделение backend/frontend/docs/specs сохранено; точная схема файла устарела. |
|
||||
| [0001](ADR-0001-module-layout.md) | `PARTIALLY IMPLEMENTED` | Границы backend/frontend/docs/specs и направление импортов; старое фиксированное дерево и фиктивное автообеспечение удалены. |
|
||||
| [0002](ADR-0002-semantic-protocol.md) | `ACCEPTED` | Семантические skills и контракты в исходном коде остаются правилом репозитория. |
|
||||
| [0003](ADR-0003-orchestrator-pattern.md) | `ACCEPTED` | Сервис остаётся внешним оркестратором Superset. |
|
||||
| [0004](ADR-0004-plugin-architecture.md) | `SUPERSEDED` | Заменён ADR-0016: исходный subprocess/TOML-контракт не был реализован. |
|
||||
| [0005](ADR-0005-auth-rbac.md) | `ACCEPTED` | Локальная аутентификация, RBAC и ADFS JIT provisioning реализованы. |
|
||||
| [0006](ADR-0006-frontend-architecture.md) | `ACCEPTED` | Svelte 5, Screen Models и UI atoms применяются в frontend. |
|
||||
| [0006](ADR-0006-frontend-architecture.md) | `ACCEPTED` | Svelte 5, Screen Models и UI atoms применяются; вымышленный CI style-аудитор и устаревший список маршрутов удалены. |
|
||||
| [0007](ADR-0007-rejected-fromStore-derived.md) | `REJECTED` | Комбинация `fromStore` с несколькими `$derived` запрещена. |
|
||||
| [0008](ADR-0008-assistant-tool-registry.md) | `ACCEPTED` | Декораторный registry реализован в `backend/src/api/routes/assistant/_tool_registry.py`. |
|
||||
| [0009](ADR-0009-ssl-certificate-management.md) | `ACCEPTED` | Управление корпоративными CA реализовано в Docker и HTTP-клиентах. |
|
||||
| [0010](ADR-0010-model-decomposition-gate.md) | `ACCEPTED` | Порог декомпозиции Screen Model остаётся архитектурным ограничением. |
|
||||
| [0011](ADR-0011-async-backend.md) | `PARTIALLY IMPLEMENTED` | Async Task Manager, EventBus и bounded executors внедрены; утверждение о полном отказе от sync-кода не подтверждено. |
|
||||
| [0010](ADR-0010-model-decomposition-gate.md) | `PARTIALLY IMPLEMENTED` | Порог 400 строк / около 40 методов остаётся целью; два действующих Screen Model превышают предел, автоматического gate нет. |
|
||||
| [0011](ADR-0011-async-backend.md) | `PARTIALLY IMPLEMENTED` | Async-клиент, registry и bounded executors существуют; существующие sync-пути остаются, прежний план полной миграции удалён. |
|
||||
| [0012](ADR-0012-superset-testcontainers.md) | `ACCEPTED` | Интеграционные Superset-тесты используют Postgres, init/web контейнеры и `superset_config.py`. |
|
||||
| [0013](ADR-0013-coverage-reporting.md) | `ACCEPTED` | `scripts/coverage-summary.sh` существует и собирает сводный отчёт. |
|
||||
| [0014](ADR-0014-agent-source-copy-strategy.md) | `SUPERSEDED` | Заменён [ADR-0015](ADR-0015-agent-shared-package-boundaries.md). |
|
||||
@@ -38,10 +39,12 @@ ADR фиксирует принятое и проверяемое архитек
|
||||
| [0018](ADR-0018-rejected-dataset-review.md) | `REJECTED` | Dataset Review is removed from the active product; `specs/027-dataset-llm-orchestration/` is retained only as archived history. |
|
||||
| [0019](ADR-0019-dashboard-import-mechanism.md) | `ACCEPTED` | UUID-трансформация БД (не strip_databases), cross-filter patching через IdMappingService, password injection flow (await_input→wait_for_input→retry), разделение dry-run/execute, парсинг имён YAML-файлов БД. |
|
||||
| [0020](ADR-0020-maintenance-virtual-dataset-discovery.md) | `ACCEPTED` | Обнаружение виртуальных (SQL) датасетов в maintenance discovery через серверный фильтр `sql is_not_null` (fallback — клиентский скан по непустому `sql`); `AsyncAPIClient.request` поднимает не-2xx ответы (`raise_for_status`); sqlparse token-limit (10000) fallback к regex-only для гигантских виртуальных датасетов. |
|
||||
| [0021](ADR-0021-self-diagnosing-explore.md) | `ACCEPTED` | Самодиагностируемый EXPLORE: опциональные `contract_id`/`claim`/`error_code`/`loc` в wire-формате; токен-экономика (payload-cap 2KB, heartbeat-правило, bond-метрики единым движком `src/core/log_stats.py`); ground-truth триангуляция task-log-gaps (T1/T2/T3); детекция тихих отказов — только в axiom-mcp (`BELIEF_SILENT_*`), без pytest-гейта. |
|
||||
| [0021](ADR-0021-self-diagnosing-explore.md) | `PARTIALLY IMPLEMENTED` | EXPLORE и аналитика реализованы; заявленные baseline-файлы для тишины и bond-профиля отсутствуют, поэтому их пороги не считаются действующими. |
|
||||
| [0022](ADR-0022-absorb-shared-into-backend.md) | `ACCEPTED` | Пакет `shared/` поглощён backend'ом после удаления агента: `cot_logger` → `src/core/`, `CotJsonFormatter` → `src/core/cot_formatter.py`, `_llm_*`/`ssl` → `src/core/utils/`; единый фасад `src/core/logger.py`; run.sh/Dockerfile без `pip install -e ../shared`. |
|
||||
| [0023](ADR-0023-mcp-scenario-pipeline-handle-gap.md) | `IMPLEMENTED` | MCP-стадии inspect/compile/validate/resolve/draft-pack изначально не создавали серверные `*Handle`, а `create_scenario`/`create_initial` писали в `graph_snapshot` только provenance-метаданные (риск вакуумного false PASS, закрыт guard'ом `BOOTSTRAP_REVISION_NOT_RUNNABLE`). Реализован полный handle-слой: `CompiledScenarioHandle`/`ValidationResultHandle`/`DraftPackHandle` (migrations 0019–0021), canonical-bytes authority, single consumption под `FOR UPDATE` (PostgreSQL-proven), материализация графа в create-транзакции, outbox-worker в scheduler poll loop, MCP `register_draft_pack` + handle-only bootstrap, гибрид T029h (`inspect_dashboard_context` + `context_authority` binding + PROD gate). Guard демотирован до defense-in-depth. |
|
||||
| [0024](ADR-0024-mcp-agent-run-external-boundary.md) | `IMPLEMENTED` | Полевой MCP-прогон 2026-09-07 (ss-prod Sales Dashboard) доказал: `register_draft_pack` требует principal-owned `AgentRun`, но после демонтажа чата (050 T041) ни одна MCP-операция его не создаёт — external initial-bootstrap цепочка недостижима, хотя все звенья реализованы. Вертикальный E2E маскировал разрыв raw-ORM-seed'ом предусловия. Реализовано в тот же день: MCP `create_agent_run`/`get_agent_run` (`tools_agent_run.py`, каталог 2.2.0, EXECUTE/READ human-only), инвариант внешней достижимости MCPX-FR-027 с hard-green пином (`test_mcp_agent_run_reachability.py`), test-honesty remediation (E2E-вертикаль конвертирована в fully external chain, zero non-MCP seeding), companion-gaps T029k (derived capabilities, CAP-001) и T029l (disposition clarity, DISP-001). Полные гейты: backend 11357 passed / frontend 3507 passed. Open: live-stand replay E2E-EXT-002 (T029m). |
|
||||
| [0024](ADR-0024-mcp-agent-run-external-boundary.md) | `IMPLEMENTED` | MCP `create_agent_run`/`get_agent_run`, внешний bootstrap без ORM-seed и live replay `E2E-EXT-002` закрыты; правило достижимости предусловий и честного E2E остаётся обязательным. |
|
||||
| [0025](ADR-0025-reference-dashboard-url-baseline.md) | `PARTIALLY IMPLEMENTED` | Baseline определяется проверенным URL того же дашборда и замороженными native filters; browser→draft→local catalog подтверждён, Git receipt/run pin/scheduled replay открыты. |
|
||||
| [0026](ADR-0026-literal-dataset-mutation.md) | `PARTIALLY IMPLEMENTED` | Общая literal-замена для viewer/migration реализована; preview fingerprint сейчас проверяет фронтенд, API не требует серверного подтверждения preview. Заменяет удалённый ненумерованный черновик. |
|
||||
|
||||
## Правила сопровождения
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Архитектурное решение: Standalone сервис vs Интеграция в Superset
|
||||
|
||||
## Рекомендация: Оставить отдельным сервисом
|
||||
|
||||
Хотя интеграция непосредственно в Superset может показаться привлекательной для создания "единого интерфейса", **настоятельно рекомендуется оставить `superset-tools` отдельным сервисом** по следующим архитектурным и техническим причинам.
|
||||
|
||||
### 1. Архитектурный паттерн: Оркестратор против Инстанса
|
||||
* **Проблема:** `superset-tools` действует как **"Менеджер менеджеров"**. Его основная ценность заключается в подключении и перемещении данных *между* различными окружениями Superset (Dev → Stage → Prod).
|
||||
* **Почему разделение лучше:** Если встроить эту логику в Superset, придется сделать один конкретный инстанс (например, "Dev") "главным контроллером" для остальных. Если этот инстанс упадет, вы потеряете возможности управления. Отдельный инструмент находится *над* окружениями, рассматривая их все как равные endpoints.
|
||||
|
||||
### 2. Несовместимость технологического стека
|
||||
* **Superset:** Бэкенд на **Flask** (Python) с фронтендом на **React**.
|
||||
* **superset-tools:** Бэкенд на **FastAPI** (Python) с фронтендом на **SvelteKit**.
|
||||
* **Цена миграции:** Перенос в Superset потребует **полного переписывания** всего фронтенда с Svelte на React, чтобы он мог работать как плагин или расширение Superset. Также потребуется рефакторинг внедрения зависимостей (Dependency Injection) из FastAPI, чтобы соответствовать структуре Flask-AppBuilder в Superset.
|
||||
|
||||
### 3. Цикл релизов и гибкость
|
||||
* **Superset:** Имеет сложный и медленный цикл релизов. Внедрение кастомных фич в основной репозиторий (upstream) затруднительно, а поддержка собственного "форка" Superset — это кошмар сопровождения (конфликты слияния при каждом обновлении).
|
||||
* **superset-tools:** Как отдельный микросервис, вы можете развертывать новые функции (например, новые LLM плагины или Git-процессы) мгновенно, не беспокоясь о том, что сломаете основную BI-платформу.
|
||||
|
||||
### 4. Безопасность и изоляция
|
||||
* **Права доступа:** `superset-tools` имеет свои собственные страницы администрирования (`AdminRolesPage` и `AdminUsersPage`), управляющие правами специально для задач *деплоя* и *резервного копирования*. Это задачи DevOps, а не бизнес-аналитики (BI). Смешивание привилегий развертывания с привилегиями просмотра данных в одной системе увеличивает "радиус поражения" в случае проблем с безопасностью.
|
||||
|
||||
### Техническая реализуемость (если все же решиться)
|
||||
Это технически возможно, но процесс будет выглядеть так:
|
||||
1. **Frontend:** Придется переписать все компоненты Svelte (`DashboardGrid`, `MappingTable` и т.д.) как компоненты React, чтобы они могли жить внутри `superset-frontend`.
|
||||
2. **Backend:** Вероятно, придется реализовать это как серверное **расширение Superset** (используя Flask Blueprints), создав новые API endpoints, которые будет вызывать фронтенд на React.
|
||||
3. **Database:** Придется добавить таблицы проекта (`task_records`, `connection_configs`) в базу данных метаданных Superset через миграции Alembic.
|
||||
|
||||
### Итоговая таблица
|
||||
|
||||
| Характеристика | Отдельный сервис (Текущий) | Интеграция в Superset |
|
||||
| :--- | :--- | :--- |
|
||||
| **Скорость разработки** | 🚀 Высокая (Независимая) | 🐢 Низкая (Связана с монолитом) |
|
||||
| **Технологический стек** | Современный (FastAPI + SvelteKit) | Legacy/Строгий (Flask + React) |
|
||||
| **Управление мульти-окружением** | ✅ Нативно (Спроектирован для этого) | ⚠️ Неудобно (Один инстанс управляет всеми) |
|
||||
| **Поддержка (Maintenance)** | ✅ Низкая (Обновление в любое время) | ❌ Высокая (Rebase при каждом обновлении Superset) |
|
||||
|
||||
**Вывод:** Проект уже хорошо спроектирован как специализированный инструмент DevOps (слой `DevOps/Tooling` в вашей семантической карте). Слияние его с Superset, скорее всего, приведет к техническому долгу и регрессии функциональности.
|
||||
83
frontend/e2e/tests/baseline-review.e2e.js
Normal file
83
frontend/e2e/tests/baseline-review.e2e.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// #region Test.E2E.BaselineReview [C:4] [TYPE Module] [SEMANTICS e2e,baseline,review,approval]
|
||||
// @BRIEF Seeded editor review exercises reason-required approval, publication, stale comparison and denial.
|
||||
// @TEST_INVARIANT A published entry matches the approved capture; a different capture at the
|
||||
// same coordinate is stale; a denied gate records the analyst's reason.
|
||||
// @RELATION VERIFIES -> [ScenarioEditor.BaselineReview]
|
||||
// @RELATION DEPENDS_ON -> [Scripts.SeedBaselineReviewE2E]
|
||||
import { test, expect } from "../fixtures/auth.fixture.js";
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://127.0.0.1:8103";
|
||||
const SCENARIO_ID = "0b45e100-0000-4000-8000-000000000001";
|
||||
const APPROVE_CANDIDATE_ID = "0b45e100-0000-4000-8000-000000000011";
|
||||
const REJECT_CANDIDATE_ID = "0b45e100-0000-4000-8000-000000000012";
|
||||
const STALE_CANDIDATE_ID = "0b45e100-0000-4000-8000-000000000015";
|
||||
|
||||
test.use({ trace: "on", screenshot: "on" });
|
||||
|
||||
async function reviews(page) {
|
||||
const token = await page.evaluate(() => localStorage.getItem("auth_token"));
|
||||
const response = await page.request.get(
|
||||
`${BACKEND_URL}/api/dashboard-testing/baseline-candidates?scenario_id=${SCENARIO_ID}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function overview(page) {
|
||||
const token = await page.evaluate(() => localStorage.getItem("auth_token"));
|
||||
const response = await page.request.get(
|
||||
`${BACKEND_URL}/api/dashboard-testing/baseline-overview?scenario_id=${SCENARIO_ID}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test("analyst reviews, approves and rejects release-bound captures", async ({ authPage: page }) => {
|
||||
const seeded = await reviews(page);
|
||||
expect(seeded.map((candidate) => candidate.candidate_id)).toEqual(expect.arrayContaining([
|
||||
APPROVE_CANDIDATE_ID, REJECT_CANDIDATE_ID, STALE_CANDIDATE_ID,
|
||||
]));
|
||||
expect(seeded.find((candidate) => candidate.candidate_id === APPROVE_CANDIDATE_ID).catalog_status).toBe("missing");
|
||||
expect((await overview(page)).state).toBe("missing");
|
||||
|
||||
await page.goto(`/dashboard-testing/scenarios/${SCENARIO_ID}/edit`);
|
||||
const review = page.getByRole("region", { name: /Выбрать baseline|Choose a baseline/ });
|
||||
await expect(review).toBeVisible();
|
||||
const approve = review.locator("li").filter({ hasText: "E2E approve baseline" });
|
||||
await expect(approve.getByText("125", { exact: true })).toBeVisible();
|
||||
await expect(approve.getByText(/v1\.0\.0/)).toBeVisible();
|
||||
await expect(approve.getByText(/нет утверждённой записи|no approved catalog entry/i)).toBeVisible();
|
||||
await approve.getByRole("textbox", { name: /Причина решения|Review reason/ }).fill("Verified source and expected total 125");
|
||||
await expect(approve.getByRole("button", { name: /Утвердить и опубликовать|Approve and publish/ })).toBeDisabled();
|
||||
await approve.getByRole("button", { name: /Посмотреть источник|View source/ }).click();
|
||||
await expect(approve.getByText(/контрольная сумма совпадает|checksum matches/i)).toBeVisible();
|
||||
await expect(approve.locator("pre")).toContainText('"approved_total":125');
|
||||
await expect(approve.getByRole("button", { name: /Утвердить и опубликовать|Approve and publish/ })).toBeEnabled();
|
||||
await approve.getByRole("button", { name: /Утвердить и опубликовать|Approve and publish/ }).click();
|
||||
await expect.poll(async () => (await reviews(page)).find(
|
||||
(candidate) => candidate.candidate_id === APPROVE_CANDIDATE_ID,
|
||||
)?.catalog_status).toBe("approved");
|
||||
await expect.poll(async () => (await overview(page)).state).toBe("ready");
|
||||
expect((await overview(page)).entries).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ result_key: "approved_total", state: "current" }),
|
||||
]));
|
||||
await expect(review.getByText(/Опубликованные значения доступны|Published values are available/)).toBeVisible();
|
||||
await expect(approve.getByText(/Утверждённая запись совпадает|Approved catalog entry matches/)).toBeVisible();
|
||||
|
||||
const stale = review.locator("li").filter({ hasText: "E2E stale candidate" });
|
||||
await expect(stale.getByText(/В каталоге другой захват|Catalog has a different capture/)).toBeVisible();
|
||||
await expect(stale.getByText(/Изменение от утверждённого|Change from approved/)).toBeVisible();
|
||||
await expect(stale.getByText("+5")).toBeVisible();
|
||||
|
||||
const reject = review.locator("li").filter({ hasText: "E2E reject baseline" });
|
||||
await expect(reject.getByRole("button", { name: /Отклонить|Reject/ })).toBeDisabled();
|
||||
await reject.getByRole("textbox", { name: /Причина решения|Review reason/ }).fill("Source total needs reconciliation");
|
||||
await reject.getByRole("button", { name: /Отклонить|Reject/ }).click();
|
||||
await expect.poll(async () => (await reviews(page)).find(
|
||||
(candidate) => candidate.candidate_id === REJECT_CANDIDATE_ID,
|
||||
)?.gate_status).toBe("denied");
|
||||
await expect(reject.getByText("Source total needs reconciliation")).toBeVisible();
|
||||
});
|
||||
// #endregion Test.E2E.BaselineReview
|
||||
138
frontend/e2e/tests/reference-url.e2e.js
Normal file
138
frontend/e2e/tests/reference-url.e2e.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// #region Test.E2E.ReferenceUrl [C:4] [TYPE Module] [SEMANTICS e2e,reference,url,capture]
|
||||
// @BRIEF Prove the supplied ss-dev dashboard URL resolves filters and metric coordinates through human review.
|
||||
// @TEST_INVARIANT Scenario dashboard and environment bind reference preview, capture and approval; a different dashboard produces an input error.
|
||||
// @TEST_FIXTURE Scripts.SeedReferenceUrlE2E -> backend/src/scripts/seed_reference_url_e2e.py
|
||||
// @TEST_EDGE dashboard_mismatch -> A dashboard 11 URL is rejected in the editor URL field.
|
||||
// @RELATION VERIFIES -> [BaselineEngine.ReferenceUrl]
|
||||
// @RELATION DEPENDS_ON -> [Scripts.SeedReferenceUrlE2E]
|
||||
import { test, expect } from "../fixtures/auth.fixture.js";
|
||||
test.use({ trace: "on", screenshot: "on" });
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://127.0.0.1:8103";
|
||||
const SCENARIO_ID = "5a240e10-0000-4000-8000-000000000010";
|
||||
const REFERENCE_URL = "https://ss-dev.bebesh.ru/superset/dashboard/10/?native_filters_key=A4_rwhfQta0";
|
||||
const OTHER_DASHBOARD_URL = "https://ss-dev.bebesh.ru/superset/dashboard/11/";
|
||||
const PASSWORD = process.env.SS_DEV_PASSWORD;
|
||||
|
||||
// #region Test.E2E.ReferenceUrl.AuthHeaders [C:2] [TYPE Function]
|
||||
// @BRIEF Build authorization headers from the live admin session.
|
||||
async function authHeaders(page) {
|
||||
const token = await page.evaluate(() => localStorage.getItem("auth_token"));
|
||||
expect(token).toBeTruthy();
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
// #endregion Test.E2E.ReferenceUrl.AuthHeaders
|
||||
|
||||
// #region Test.E2E.ReferenceUrl.ConfigureEnvironment [C:3] [TYPE Function]
|
||||
// @BRIEF Register ss-dev in the backend process through its settings API for this isolated test run.
|
||||
async function configureEnvironment(page) {
|
||||
expect(PASSWORD, "SS_DEV_PASSWORD must be provided through ignored .env.e2e").toBeTruthy();
|
||||
const headers = await authHeaders(page);
|
||||
const listResponse = await page.request.get(`${BACKEND_URL}/api/settings/environments`, { headers });
|
||||
expect(listResponse.status()).toBe(200);
|
||||
const configured = await listResponse.json();
|
||||
const environment = {
|
||||
id: "ss-dev", name: "ss-dev", url: "https://ss-dev.bebesh.ru",
|
||||
username: "admin", password: PASSWORD, stage: "DEV", verify_ssl: false,
|
||||
timeout: 30, request_timeout: 30, is_default: true, is_production: false,
|
||||
};
|
||||
const exists = configured.some((item) => item.id === environment.id);
|
||||
const response = exists
|
||||
? await page.request.put(`${BACKEND_URL}/api/settings/environments/ss-dev`, { headers, data: environment })
|
||||
: await page.request.post(`${BACKEND_URL}/api/settings/environments`, { headers, data: environment });
|
||||
expect(response.status(), await response.text()).toBe(200);
|
||||
const resolved = await response.json();
|
||||
expect(resolved.id).toBe("ss-dev");
|
||||
}
|
||||
// #endregion Test.E2E.ReferenceUrl.ConfigureEnvironment
|
||||
|
||||
// #region Test.E2E.ReferenceUrl.OpenEditor [C:3] [TYPE Function]
|
||||
// @BRIEF Open the seeded dashboard 10 editor after live ss-dev configuration.
|
||||
async function openEditor(page) {
|
||||
await configureEnvironment(page);
|
||||
await page.goto(`/dashboard-testing/scenarios/${SCENARIO_ID}/edit`);
|
||||
const editor = page.getByRole("region", { name: /Explore a Superset reference dashboard|Просмотр эталонного дашборда Superset/ });
|
||||
await expect(editor).toBeVisible();
|
||||
return editor;
|
||||
}
|
||||
// #endregion Test.E2E.ReferenceUrl.OpenEditor
|
||||
|
||||
// #region Test.E2E.ReferenceUrl.PreviewAndCapture [C:2] [TYPE Function]
|
||||
// @BRIEF Verify active filters, chart attribution, six drafts and source-checked human publication.
|
||||
test("real dashboard URL previews, captures and publishes a reviewed candidate", async ({ authPage: page }) => {
|
||||
const editor = await openEditor(page);
|
||||
await editor.getByLabel(/Reference dashboard URL|URL эталонного дашборда/).fill(REFERENCE_URL);
|
||||
const previewResponse = page.waitForResponse((response) =>
|
||||
response.url().includes("/api/dashboard-testing/reference-discovery") && response.request().method() === "POST");
|
||||
await editor.getByRole("button", { name: /Preview reference|Посмотреть эталон/ }).click();
|
||||
const preview = await (await previewResponse).json();
|
||||
|
||||
expect(preview.dashboard_id).toBe(10);
|
||||
expect(preview.environment_id).toBe("ss-dev");
|
||||
expect(preview.coordinates).toHaveLength(10);
|
||||
expect(preview.coordinates.filter((item) => item.recommended === true)).toHaveLength(6);
|
||||
expect(preview.filters.filters).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
column: "platform", operator: "IN", value: expect.objectContaining({ values: ["3DS"] }),
|
||||
}),
|
||||
]));
|
||||
await expect(editor.getByText(/platform.*3DS/i)).toBeVisible();
|
||||
await expect(editor.getByText(/Selected coordinates: 6|Выбрано координат: 6/)).toBeVisible();
|
||||
await expect(editor.locator("li").filter({ hasText: / → .* → / })).toHaveCount(10);
|
||||
|
||||
const captureResponse = page.waitForResponse((response) =>
|
||||
response.url().includes("/api/dashboard-testing/reference-selection/capture") && response.request().method() === "POST");
|
||||
await editor.getByRole("button", { name: /Create candidates for review|Создать кандидатов для проверки/ }).click();
|
||||
const captured = await (await captureResponse).json();
|
||||
expect(captured.candidate_ids).toHaveLength(6);
|
||||
await expect(editor.getByRole("status")).toContainText(/6/);
|
||||
|
||||
const reviewsResponse = await page.request.get(
|
||||
`${BACKEND_URL}/api/dashboard-testing/baseline-candidates?scenario_id=${SCENARIO_ID}`,
|
||||
{ headers: await authHeaders(page) },
|
||||
);
|
||||
expect(reviewsResponse.status()).toBe(200);
|
||||
const reviews = await reviewsResponse.json();
|
||||
const capturedReviews = reviews.filter((item) => captured.candidate_ids.includes(item.candidate_id));
|
||||
expect(capturedReviews).toHaveLength(6);
|
||||
expect(capturedReviews.every((item) => item.environment_name === "ss-dev (E2E test-only release fixture)")).toBe(true);
|
||||
expect(capturedReviews.every((item) => item.request.normalized_filters.filters.some(
|
||||
(filter) => filter.column === "platform" && filter.operator === "IN" && filter.value?.values?.includes("3DS"),
|
||||
))).toBe(true);
|
||||
const candidate = capturedReviews.find((item) => item.request.kind === "metric");
|
||||
expect(candidate).toBeTruthy();
|
||||
const review = page.getByRole("region", { name: /Выбрать baseline|Choose a baseline/ });
|
||||
const card = review.locator("li").filter({ hasText: candidate.request.label }).first();
|
||||
await expect(card).toBeVisible();
|
||||
await card.getByRole("textbox", { name: /Причина решения|Review reason/ }).fill("Verified dashboard 10 source and platform 3DS filter");
|
||||
await expect(card.getByRole("button", { name: /Утвердить и опубликовать|Approve and publish/ })).toBeDisabled();
|
||||
await card.getByRole("button", { name: /Посмотреть источник|View source/ }).click();
|
||||
await expect(card.getByText(/контрольная сумма совпадает|checksum matches/i)).toBeVisible();
|
||||
await card.getByRole("button", { name: /Утвердить и опубликовать|Approve and publish/ }).click();
|
||||
await expect.poll(async () => {
|
||||
const response = await page.request.get(
|
||||
`${BACKEND_URL}/api/dashboard-testing/baseline-candidates?scenario_id=${SCENARIO_ID}`,
|
||||
{ headers: await authHeaders(page) },
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
return (await response.json()).find((item) => item.candidate_id === candidate.candidate_id)?.catalog_status;
|
||||
}).toBe("approved");
|
||||
await page.screenshot({ path: "/tmp/reference-url-e2e-preview-captured.png", fullPage: true });
|
||||
});
|
||||
// #endregion Test.E2E.ReferenceUrl.PreviewAndCapture
|
||||
|
||||
// #region Test.E2E.ReferenceUrl.DashboardMismatch [C:2] [TYPE Function]
|
||||
// @BRIEF Show a typed input error when the reference URL names dashboard 11 instead of the seeded dashboard 10.
|
||||
test("URL for another dashboard is shown as a field error", async ({ authPage: page }) => {
|
||||
const editor = await openEditor(page);
|
||||
await editor.getByLabel(/Reference dashboard URL|URL эталонного дашборда/).fill(OTHER_DASHBOARD_URL);
|
||||
const previewResponse = page.waitForResponse((response) =>
|
||||
response.url().includes("/api/dashboard-testing/reference-discovery") && response.request().method() === "POST");
|
||||
await editor.getByRole("button", { name: /Preview reference|Посмотреть эталон/ }).click();
|
||||
const response = await previewResponse;
|
||||
expect(response.status()).toBe(422);
|
||||
await expect(editor.getByRole("alert")).toContainText(/dashboard differs|не совпадает|другой дашборд/i);
|
||||
await page.screenshot({ path: "/tmp/reference-url-e2e-dashboard-mismatch.png", fullPage: true });
|
||||
});
|
||||
// #endregion Test.E2E.ReferenceUrl.DashboardMismatch
|
||||
// #endregion Test.E2E.ReferenceUrl
|
||||
@@ -1,104 +1,101 @@
|
||||
// #region Test.E2E.ScenarioDisposition [C:3] [TYPE Module] [SEMANTICS e2e,scenario,investigation,case,disposition]
|
||||
// @defgroup ScenarioDisposition Browser E2E for the 047 investigation case disposition flow.
|
||||
// @BRIEF Queued investigation item -> open case -> disposition through the case workspace UI.
|
||||
// @BRIEF Seeded queue -> correct case -> linked run/evidence -> resolved UI -> stale CAS.
|
||||
// @RELATION BINDS_TO -> [ScenarioAnalytics.Component.CaseWorkspace]
|
||||
// @RELATION DEPENDS_ON -> [ScenarioAnalytics.Routes.Queue]
|
||||
// @TEST_FIXTURE: queue item produced by the backend investigation signal path (no creation API exists).
|
||||
// @TEST_EDGE: no_queued_item -> skipped with an explicit reason (queue seeding needs a failing run).
|
||||
// @TEST_EDGE: cas_conflict -> disposition with a stale version is refused (STALE_CASE).
|
||||
// @TEST_INVARIANT: disposition is explicit and single-write: the case reaches a terminal status once.
|
||||
// @RELATION DEPENDS_ON -> [Scripts.SeedScenarioDispositionE2E]
|
||||
// @TEST_FIXTURE: docker-compose.e2e.yml runs the seed after backend migrations.
|
||||
// @TEST_EDGE: missing_seed -> hard failure with a fixture-specific assertion.
|
||||
// @TEST_EDGE: cas_conflict -> an earlier decision_version is refused with STALE_CASE.
|
||||
// @TEST_INVARIANT: resolved disposition cites active evidence of the linked run and closes the queue row.
|
||||
|
||||
import { test, expect } from "../fixtures/auth.fixture.js";
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://127.0.0.1:8101";
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://127.0.0.1:8103";
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:8102";
|
||||
const BASE = "/api/scenario-analytics";
|
||||
const SCENARIO_ID = "047e2e00-0000-4000-8000-000000000001";
|
||||
const RUN_ID = "047e2e00-0000-4000-8000-000000000002";
|
||||
const CONTENT_SHA256 = "8beff3d9e15ffc1557dd9b3ccd945a3ec4fbc7132ffbe502e8bbe475bbf267fc";
|
||||
const CONTENT_REF = `draft:${RUN_ID}:${CONTENT_SHA256}`;
|
||||
const WORKSPACE_LABEL = "Рабочее пространство кейса";
|
||||
const CLOSE_LABEL = "Закрыть кейс";
|
||||
const NO_ITEM = "no queued investigation item: the isolated stand has no queue-seeding path (a failing run produces the signal)";
|
||||
|
||||
async function authed(
|
||||
page: { evaluate: (fn: () => unknown) => Promise<unknown>; request: { fetch: (url: string, init: Record<string, unknown>) => Promise<any> } },
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; payload: any }> {
|
||||
test.use({ trace: "on", screenshot: "on" });
|
||||
|
||||
async function authed(page, method, path, body) {
|
||||
const token = await page.evaluate(() => localStorage.getItem("auth_token"));
|
||||
expect(token, "the authenticated browser must expose an auth token").toBeTruthy();
|
||||
const response = await page.request.fetch(`${BACKEND_URL}${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
data: body,
|
||||
...(body === undefined ? {} : { data: body }),
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload: unknown = null;
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try { payload = JSON.parse(text); } catch { payload = text; }
|
||||
}
|
||||
return { status: response.status(), payload };
|
||||
}
|
||||
|
||||
async function firstQueuedItem(page: any): Promise<{ fingerprint: string; scenario_id: string } | null> {
|
||||
const { status, payload } = await authed(page, "GET", `${BASE}/queue?status=queued&limit=20`);
|
||||
if (status !== 200 || !Array.isArray(payload)) return null;
|
||||
const item = payload.find((row: any) => row?.status === "queued" && row?.fingerprint);
|
||||
return item ? { fingerprint: item.fingerprint, scenario_id: item.scenario_id } : null;
|
||||
}
|
||||
// #region Test.E2E.ScenarioDisposition.CaseFlow [C:3] [TYPE Function] [SEMANTICS e2e,case,disposition,ui,cas]
|
||||
test("seeded queue opens the correct case, resolves with linked evidence, and rejects stale CAS", async ({ authPage: page }) => {
|
||||
const queueBefore = await authed(page, "GET", `${BASE}/queue?status=queued&scenario_id=${SCENARIO_ID}`);
|
||||
expect(queueBefore.status).toBe(200);
|
||||
expect(Array.isArray(queueBefore.payload?.items)).toBe(true);
|
||||
const item = queueBefore.payload.items.find((row) => row.scenario_id === SCENARIO_ID && row.run_id === RUN_ID);
|
||||
expect(item, "the isolated E2E seed must supply the known queued run").toBeTruthy();
|
||||
expect(item.status).toBe("queued");
|
||||
expect(item.fingerprint).toMatch(/^scenario-terminal:/);
|
||||
|
||||
async function openCase(page: any, item: { fingerprint: string; scenario_id: string }): Promise<any> {
|
||||
const { status, payload } = await authed(
|
||||
page,
|
||||
"POST",
|
||||
`${BASE}/queue/${encodeURIComponent(item.fingerprint)}/open?scenario_id=${encodeURIComponent(item.scenario_id)}`,
|
||||
await page.goto(`${FRONTEND_URL}/dashboard-testing/analytics`);
|
||||
const queueRow = page.locator("li").filter({ hasText: "E2E investigation disposition" });
|
||||
await expect(queueRow).toBeVisible();
|
||||
const openResponse = page.waitForResponse((response) =>
|
||||
response.url().includes(`/queue/${encodeURIComponent(item.fingerprint)}/open`) && response.request().method() === "POST",
|
||||
);
|
||||
expect(status, `open case should succeed: ${JSON.stringify(payload)}`).toBe(200);
|
||||
return payload;
|
||||
}
|
||||
await queueRow.getByRole("button").click();
|
||||
const opened = await (await openResponse).json();
|
||||
expect(opened.scenario_id).toBe(SCENARIO_ID);
|
||||
expect(opened.fingerprint).toBe(item.fingerprint);
|
||||
expect(opened.status).toBe("open");
|
||||
await expect(page).toHaveURL(new RegExp(`/dashboard-testing/analytics/cases/${opened.id}$`));
|
||||
|
||||
// #region Test.E2E.ScenarioDisposition.CaseFlow [C:2] [TYPE Function] [SEMANTICS e2e,case,disposition,ui]
|
||||
test("queued -> case -> disposition closes the case through the UI", async ({ page }) => {
|
||||
const item = await firstQueuedItem(page);
|
||||
test.skip(item === null, NO_ITEM);
|
||||
if (!item) return;
|
||||
const detail = await authed(page, "GET", `${BASE}/cases/${opened.id}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.payload.case.id).toBe(opened.id);
|
||||
expect(detail.payload.linked_run_ids).toContain(RUN_ID);
|
||||
expect(detail.payload.evidence_refs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ run_id: RUN_ID, content_ref: CONTENT_REF, sha256: CONTENT_SHA256 }),
|
||||
]));
|
||||
|
||||
const opened = await openCase(page, item);
|
||||
const caseId = opened.case_id ?? opened.id;
|
||||
expect(caseId).toBeTruthy();
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/dashboard-testing/analytics/cases/${caseId}`);
|
||||
const workspace = page.locator(`section[aria-label="${WORKSPACE_LABEL}"]`);
|
||||
await expect(workspace).toBeVisible();
|
||||
await expect(workspace.getByRole("link", { name: RUN_ID })).toBeVisible();
|
||||
await expect(workspace.getByText("evidence", { exact: true })).toBeVisible();
|
||||
await workspace.getByRole("button", { name: "Просмотреть доказательство" }).click();
|
||||
await expect(workspace.getByRole("link", { name: /Скачать|Download/ })).toBeVisible();
|
||||
await workspace.locator("select").first().selectOption("resolved");
|
||||
await workspace.locator("select").nth(1).selectOption("0");
|
||||
await workspace.getByRole("button", { name: CLOSE_LABEL }).click();
|
||||
await expect(workspace.getByText("resolved", { exact: true }).first()).toBeVisible();
|
||||
|
||||
await workspace.locator("select").selectOption("resolved");
|
||||
await page.getByRole("button", { name: CLOSE_LABEL }).click();
|
||||
await expect.poll(async () => {
|
||||
const result = await authed(page, "GET", `${BASE}/cases/${opened.id}`);
|
||||
return [result.payload?.case?.status, result.payload?.case?.disposition, result.payload?.case?.decision_version];
|
||||
}).toEqual(["resolved", "resolved", opened.decision_version + 1]);
|
||||
|
||||
// The case reaches a terminal disposition (never stays open after a successful close).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { payload } = await authed(page, "GET", `${BASE}/cases/${caseId}`);
|
||||
return payload?.case?.status;
|
||||
})
|
||||
.not.toBe("open");
|
||||
const queueAfter = await authed(page, "GET", `${BASE}/queue?scenario_id=${SCENARIO_ID}`);
|
||||
expect(queueAfter.status).toBe(200);
|
||||
expect(queueAfter.payload.items.some((row) => row.fingerprint === item.fingerprint)).toBe(false);
|
||||
|
||||
const stale = await authed(page, "POST", `${BASE}/cases/${opened.id}/disposition`, {
|
||||
disposition: "resolved",
|
||||
expected_version: opened.decision_version,
|
||||
verification_evidence: { content_ref: CONTENT_REF, sha256: CONTENT_SHA256 },
|
||||
});
|
||||
expect(stale.status).toBe(409);
|
||||
expect(stale.payload?.detail?.code).toBe("STALE_CASE");
|
||||
});
|
||||
// #endregion Test.E2E.ScenarioDisposition.CaseFlow
|
||||
|
||||
// #region Test.E2E.ScenarioDisposition.CasConflict [C:2] [TYPE Function] [SEMANTICS e2e,case,disposition,cas]
|
||||
test("a stale disposition version is refused as a CAS conflict", async ({ page }) => {
|
||||
const item = await firstQueuedItem(page);
|
||||
test.skip(item === null, NO_ITEM);
|
||||
if (!item) return;
|
||||
|
||||
const opened = await openCase(page, item);
|
||||
const caseId = opened.case_id ?? opened.id;
|
||||
const staleVersion = Number(opened.decision_version ?? 1) + 5;
|
||||
const { status, payload } = await authed(page, "POST", `${BASE}/cases/${caseId}/disposition`, {
|
||||
disposition: "resolved",
|
||||
expected_version: staleVersion,
|
||||
rationale: "stale CAS probe",
|
||||
});
|
||||
|
||||
expect(status).toBe(409);
|
||||
expect(JSON.stringify(payload)).toContain("STALE_CASE");
|
||||
});
|
||||
// #endregion Test.E2E.ScenarioDisposition.CasConflict
|
||||
// #endregion Test.E2E.ScenarioDisposition
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<!-- #region ScenarioEditor.BaselineReview [C:4] [TYPE Component] [SEMANTICS baseline,candidate,review,approval] -->
|
||||
<!-- @BRIEF Review server-captured baseline candidates through the existing 037 approval gate. -->
|
||||
<!-- @UX_STATE loading -> announces candidate retrieval; empty -> explains missing candidate; ready -> source, value, provenance and gate status visible. -->
|
||||
<!-- @UX_STATE conflict -> action is stopped and current candidate state can be reloaded. -->
|
||||
<!-- @UX_STATE draft/pending -> reason is required before approve or reject; confirmed -> publication retry; consumed -> read-only approved evidence. -->
|
||||
<!-- @UX_STATE catalog missing/stale/approved -> current catalog entry and value are shown beside the candidate where available. -->
|
||||
<!-- @UX_STATE every lifecycle status -> localized analyst label and next action, never a raw gate enum in the primary summary. -->
|
||||
<!-- @UX_STATE no candidate -> release-bound catalog overview remains visible and distinguishes no release, missing catalog, ready and stale. -->
|
||||
<!-- @UX_STATE source evidence -> explicit load, digest verified preview | unavailable | corrupt; raw reference remains technical. -->
|
||||
<!-- @UX_FEEDBACK Failed requests retain the candidate and show an alert. -->
|
||||
<!-- @UX_RECOVERY Reload reviews after a stale gate or catalog conflict. -->
|
||||
<!-- @INVARIANT Release pins are read-only server values; approve and reject require a reason. -->
|
||||
<!-- @INVARIANT Metric approval requires a viewed capture whose bytes match the server-issued digest. -->
|
||||
<script lang="ts">
|
||||
import { fetchApi, fetchApiBlob, requestApi } from "$lib/api";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
import ReferenceDiscovery from "./ReferenceDiscovery.svelte";
|
||||
|
||||
interface CandidateReview {
|
||||
candidate_id: string;
|
||||
agent_run_id: string;
|
||||
status: string;
|
||||
gate_id: string | null;
|
||||
gate_status: string | null;
|
||||
gate_reason: string | null;
|
||||
catalog_status: "missing" | "approved" | "stale";
|
||||
current_baseline: { expected: NormalizedValue; release_version: string; comparison_policy: ComparisonPolicy } | null;
|
||||
release_version: string | null;
|
||||
release_commit_hash: string | null;
|
||||
environment_name: string | null;
|
||||
created_at: string;
|
||||
request: {
|
||||
label: string;
|
||||
kind: string;
|
||||
result_key: string;
|
||||
candidate_value: NormalizedValue | null;
|
||||
source_response_hash: string;
|
||||
capture_artifact_ref?: string | null;
|
||||
comparison_policy: ComparisonPolicy;
|
||||
provenance: { environment: string; actor: string; agent_run_id?: string };
|
||||
scenario_run_id?: string | null;
|
||||
transform_expression?: string | null;
|
||||
repository_key: string;
|
||||
dashboard_key: string;
|
||||
};
|
||||
}
|
||||
interface NormalizedValue { kind: string; display_value?: string | null; canonical_value?: string | null; format?: string | null }
|
||||
interface ComparisonPolicy { type: string; amount?: string | null; ratio?: string | null; min?: string | null; max?: string | null }
|
||||
interface BaselineOverview {
|
||||
state: "no_release" | "missing" | "ready" | "stale";
|
||||
release_version: string | null;
|
||||
environment_name: string | null;
|
||||
entries: { label: string; result_key: string; expected: NormalizedValue; release_version: string; state: "current" | "stale" }[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
let { scenarioId, canApprove, dashboardId, environments = [] }: { scenarioId: string; canApprove: boolean; dashboardId?: number; environments?: { id: string; name: string; dashboard_url: string | null }[] } = $props();
|
||||
const copy = $derived(($t.dashboard_testing ?? {}) as Record<string, string>);
|
||||
let candidates = $state<CandidateReview[]>([]);
|
||||
let overview = $state<BaselineOverview | null>(null);
|
||||
let loading = $state(false);
|
||||
let busy = $state("");
|
||||
let error = $state("");
|
||||
let reason = $state<Record<string, string>>({});
|
||||
let source = $state<Record<string, { status: "loading" | "ready" | "unavailable" | "corrupt"; preview?: string }>>({});
|
||||
let loadedId = $state("");
|
||||
|
||||
function valueText(value: NormalizedValue | null): string {
|
||||
if (!value) return "—";
|
||||
return value.display_value || value.canonical_value || "—";
|
||||
}
|
||||
|
||||
function statusText(candidate: CandidateReview): string {
|
||||
const status = candidate.gate_status ?? candidate.status;
|
||||
return copy[`baseline_review_state_${status}`] || copy.baseline_review_state_unknown;
|
||||
}
|
||||
|
||||
function actionText(candidate: CandidateReview): string {
|
||||
const status = candidate.gate_status ?? candidate.status;
|
||||
return copy[`baseline_review_action_${status}`] || copy.baseline_review_action_unknown;
|
||||
}
|
||||
|
||||
function policyText(policy: ComparisonPolicy): string {
|
||||
if (policy.type === "exact") return copy.baseline_review_exact;
|
||||
if (policy.type === "absolute_tolerance") return `${copy.baseline_review_absolute} ±${policy.amount ?? "?"}`;
|
||||
if (policy.type === "relative_tolerance") return `${copy.baseline_review_relative} ±${policy.ratio ?? "?"}`;
|
||||
if (policy.type === "range") return `${copy.baseline_review_range} ${policy.min ?? "?"}–${policy.max ?? "?"}`;
|
||||
return policy.type.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function numericDelta(candidate: NormalizedValue | null, approved: NormalizedValue | null): string | null {
|
||||
if (!candidate?.canonical_value || !approved?.canonical_value) return null;
|
||||
const delta = Number(candidate.canonical_value) - Number(approved.canonical_value);
|
||||
return Number.isFinite(delta) ? `${delta > 0 ? "+" : ""}${delta}` : null;
|
||||
}
|
||||
|
||||
function sourceVerified(candidate: CandidateReview): boolean {
|
||||
return candidate.request.kind !== "metric" || source[candidate.candidate_id]?.status === "ready";
|
||||
}
|
||||
|
||||
async function loadSource(candidate: CandidateReview): Promise<void> {
|
||||
const artifactId = candidate.request.capture_artifact_ref;
|
||||
if (!artifactId) return;
|
||||
source[candidate.candidate_id] = { status: "loading" };
|
||||
try {
|
||||
const blob = await fetchApiBlob(`/agent/runs/${encodeURIComponent(candidate.agent_run_id)}/drafts/${encodeURIComponent(artifactId)}/content`, { notifyError: false });
|
||||
const bytes = await blob.arrayBuffer();
|
||||
const digestBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||
const digest = [...digestBytes].map((part) => part.toString(16).padStart(2, "0")).join("");
|
||||
if (digest !== candidate.request.source_response_hash.replace(/^sha256:/, "")) {
|
||||
source[candidate.candidate_id] = { status: "corrupt" };
|
||||
return;
|
||||
}
|
||||
const preview = await blob.slice(0, 4096).text();
|
||||
source[candidate.candidate_id] = { status: "ready", preview };
|
||||
} catch {
|
||||
source[candidate.candidate_id] = { status: "unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const query = `scenario_id=${encodeURIComponent(scenarioId)}`;
|
||||
[overview, candidates] = await Promise.all([
|
||||
fetchApi<BaselineOverview>(`/dashboard-testing/baseline-overview?${query}`),
|
||||
fetchApi<CandidateReview[]>(`/dashboard-testing/baseline-candidates?${query}`),
|
||||
]);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : copy.baseline_review_error;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(candidate: CandidateReview, gateId: string): Promise<void> {
|
||||
if (!candidate.release_version || !candidate.release_commit_hash) return;
|
||||
const query = new URLSearchParams({
|
||||
release_version: candidate.release_version,
|
||||
release_commit_hash: candidate.release_commit_hash,
|
||||
});
|
||||
await requestApi(
|
||||
`/dashboard-testing/baseline-candidates/${candidate.candidate_id}/approval-gate/${gateId}/consume?${query}`,
|
||||
"POST", {},
|
||||
);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (scenarioId && scenarioId !== loadedId) {
|
||||
loadedId = scenarioId;
|
||||
void reload();
|
||||
}
|
||||
});
|
||||
|
||||
async function decide(candidate: CandidateReview, decision: "confirm" | "deny"): Promise<void> {
|
||||
const explanation = reason[candidate.candidate_id]?.trim();
|
||||
if (!explanation || !canApprove || !candidate.release_version || !candidate.release_commit_hash) return;
|
||||
if (decision === "confirm" && !sourceVerified(candidate)) return;
|
||||
busy = candidate.candidate_id;
|
||||
error = "";
|
||||
try {
|
||||
let gateId = candidate.gate_id;
|
||||
if (!gateId) {
|
||||
const gate = await requestApi<{ gate_id: string }>(
|
||||
`/dashboard-testing/baseline-candidates/${candidate.candidate_id}/approval-gate`, "POST", {
|
||||
agent_run_id: candidate.agent_run_id,
|
||||
release_version: candidate.release_version,
|
||||
release_commit_hash: candidate.release_commit_hash,
|
||||
reason_required: true,
|
||||
},
|
||||
);
|
||||
gateId = gate.gate_id;
|
||||
}
|
||||
await requestApi(
|
||||
`/dashboard-testing/baseline-candidates/${candidate.candidate_id}/approval-gate/${gateId}/decide`,
|
||||
"POST", { decision, reason: explanation },
|
||||
);
|
||||
if (decision === "confirm") {
|
||||
await publish(candidate, gateId);
|
||||
}
|
||||
await reload();
|
||||
} catch (cause) {
|
||||
const failure = cause instanceof Error ? cause.message : copy.baseline_review_error;
|
||||
await reload();
|
||||
error = `${copy.baseline_review_decision_failed} ${copy.baseline_review_recovery} (${failure})`;
|
||||
} finally {
|
||||
busy = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPublish(candidate: CandidateReview): Promise<void> {
|
||||
if (!candidate.gate_id || !canApprove) return;
|
||||
busy = candidate.candidate_id;
|
||||
try {
|
||||
await publish(candidate, candidate.gate_id);
|
||||
await reload();
|
||||
} catch (cause) {
|
||||
const failure = cause instanceof Error ? cause.message : copy.baseline_review_error;
|
||||
await reload();
|
||||
error = `${copy.baseline_review_decision_failed} ${copy.baseline_review_recovery} (${failure})`;
|
||||
} finally {
|
||||
busy = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg border border-border bg-surface-card p-4" aria-label={copy.baseline_review_title}>
|
||||
<ReferenceDiscovery {scenarioId} {dashboardId} {environments} onCandidatesCaptured={reload} />
|
||||
<div class="mt-4 flex items-center justify-between gap-2">
|
||||
<h2 class="font-semibold text-text">{copy.baseline_review_title}</h2>
|
||||
<Button variant="secondary" size="sm" onclick={() => void reload()} disabled={loading || !!busy}>{copy.baseline_review_reload}</Button>
|
||||
</div>
|
||||
{#if loading}<p class="mt-2 text-sm text-text-muted" role="status">{copy.baseline_review_loading}</p>{/if}
|
||||
{#if error}<p class="mt-2 text-sm text-destructive" role="alert">{error}</p>{/if}
|
||||
{#if overview}
|
||||
<div class="mt-4 rounded-lg border border-border bg-surface p-4" aria-label={copy.baseline_overview_title}>
|
||||
<h3 class="font-semibold text-text">{copy.baseline_overview_title}</h3>
|
||||
<p class="mt-1 text-sm text-text-muted">{copy[`baseline_overview_${overview.state}`]}</p>
|
||||
{#if overview.release_version}
|
||||
<p class="mt-1 text-xs text-text-muted">{copy.baseline_review_release}: {overview.release_version}{overview.environment_name ? ` · ${overview.environment_name}` : ""}</p>
|
||||
{/if}
|
||||
{#if overview.entries.length > 0}
|
||||
<ul class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{#each overview.entries as entry (entry.result_key + entry.release_version)}
|
||||
<li class="rounded border border-border bg-surface-card px-3 py-2">
|
||||
<p class="font-medium text-text">{entry.label}</p>
|
||||
<p class="text-sm text-text-muted">{copy.baseline_review_current}: <span class="font-semibold text-text">{valueText(entry.expected)}</span> · {entry.release_version}</p>
|
||||
{#if entry.state === "stale"}<p class="text-xs text-warning">{copy.baseline_overview_entry_stale}</p>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if overview.total > overview.entries.length}<p class="mt-2 text-xs text-text-muted">{copy.baseline_overview_more}: {overview.total - overview.entries.length}</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !loading && candidates.length === 0}<p class="mt-4 text-sm text-text-muted">{copy.baseline_review_missing}</p>{/if}
|
||||
<ul class="mt-3 grid gap-4 lg:grid-cols-2">
|
||||
{#each candidates as candidate (candidate.candidate_id)}
|
||||
<li class="rounded-lg border border-border bg-surface-card p-5 text-sm shadow-sm">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<h3 class="text-base font-semibold text-text">{candidate.request.label}</h3>
|
||||
<span class="rounded-full border border-border px-2.5 py-0.5 text-xs font-medium text-text">{statusText(candidate)}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-text-muted">{copy[`baseline_review_catalog_${candidate.catalog_status}`]}</p>
|
||||
<p class="mt-1 text-sm text-text">{actionText(candidate)}</p>
|
||||
<dl class="mt-4 grid gap-x-4 gap-y-3 border-t border-border pt-4 sm:grid-cols-2">
|
||||
<div><dt class="text-xs text-text-muted">{copy.baseline_review_source}</dt><dd class="mt-0.5 text-sm text-text">{candidate.environment_name || candidate.request.provenance.environment} · {candidate.request.provenance.actor}</dd></div>
|
||||
<div><dt class="text-xs text-text-muted">{copy.baseline_review_coordinate}</dt><dd class="mt-0.5 text-sm text-text">{candidate.request.result_key}</dd></div>
|
||||
{#if candidate.request.transform_expression}<div class="sm:col-span-2"><dt class="text-xs text-text-muted">{copy.baseline_review_formula}</dt><dd class="mt-0.5 break-all text-sm text-text">{candidate.request.transform_expression}</dd></div>{/if}
|
||||
<div><dt class="text-xs text-text-muted">{copy.baseline_review_value}</dt><dd class="mt-0.5 text-lg font-semibold text-text">{valueText(candidate.request.candidate_value)}</dd></div>
|
||||
<div><dt class="text-xs text-text-muted">{copy.baseline_review_policy}</dt><dd class="mt-0.5 text-sm text-text">{policyText(candidate.request.comparison_policy)}</dd></div>
|
||||
{#if candidate.current_baseline}
|
||||
<div><dt class="text-xs text-text-muted">{copy.baseline_review_current}</dt><dd class="mt-0.5 text-sm text-text">{valueText(candidate.current_baseline.expected)} · {candidate.current_baseline.release_version}</dd></div>
|
||||
{#if numericDelta(candidate.request.candidate_value, candidate.current_baseline.expected)}
|
||||
<div><dt class="text-xs text-text-muted">{copy.baseline_review_delta}</dt><dd class="mt-0.5 text-sm text-text">{numericDelta(candidate.request.candidate_value, candidate.current_baseline.expected)}</dd></div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div><dt class="text-xs text-text-muted">{copy.baseline_review_release}</dt><dd class="mt-0.5 text-sm text-text">{candidate.release_version ?? copy.baseline_review_unbound}</dd></div>
|
||||
{#if candidate.gate_reason}<div class="sm:col-span-2"><dt class="text-xs text-text-muted">{copy.baseline_review_reason}</dt><dd class="mt-0.5 text-sm text-text">{candidate.gate_reason}</dd></div>{/if}
|
||||
</dl>
|
||||
<details class="mt-2 text-xs text-text-muted"><summary class="cursor-pointer">{copy.baseline_review_evidence}</summary>
|
||||
<p class="mt-1 break-all">SHA-256: {candidate.request.source_response_hash}</p>
|
||||
<p class="break-all">Commit: {candidate.release_commit_hash ?? "—"}</p>
|
||||
<p class="break-all">AgentRun: {candidate.agent_run_id}</p>
|
||||
<p class="break-all">Environment ID: {candidate.request.provenance.environment}</p>
|
||||
<p>{copy.baseline_review_value_type}: {candidate.request.candidate_value?.kind ?? "—"}{candidate.request.candidate_value?.format ? ` · ${candidate.request.candidate_value.format}` : ""}</p>
|
||||
<p>{copy.baseline_review_candidate_type}: {candidate.request.kind}</p>
|
||||
{#if candidate.request.scenario_run_id}<p class="break-all">ScenarioRun: {candidate.request.scenario_run_id}</p>{/if}
|
||||
</details>
|
||||
{#if candidate.request.capture_artifact_ref}
|
||||
<div class="mt-4 rounded border border-border p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium text-text">{copy.baseline_review_source_evidence}</p>
|
||||
<Button size="sm" variant="secondary" onclick={() => void loadSource(candidate)} disabled={source[candidate.candidate_id]?.status === "loading"}>{copy.baseline_review_source_open}</Button>
|
||||
</div>
|
||||
{#if source[candidate.candidate_id]?.status === "loading"}<p class="mt-2 text-sm text-text-muted" role="status">{copy.baseline_review_source_loading}</p>{/if}
|
||||
{#if source[candidate.candidate_id]?.status === "ready"}
|
||||
<p class="mt-2 text-xs text-success" role="status">{copy.baseline_review_source_verified}</p>
|
||||
<pre class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded bg-surface p-2 text-xs text-text">{source[candidate.candidate_id].preview}</pre>
|
||||
{/if}
|
||||
{#if source[candidate.candidate_id]?.status === "unavailable"}<p class="mt-2 text-sm text-warning" role="alert">{copy.baseline_review_source_unavailable}</p>{/if}
|
||||
{#if source[candidate.candidate_id]?.status === "corrupt"}<p class="mt-2 text-sm text-destructive" role="alert">{copy.baseline_review_source_corrupt}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if candidate.status === "draft" || candidate.gate_status === "pending"}
|
||||
{#if !candidate.release_version || !candidate.release_commit_hash}
|
||||
<p class="mt-2 text-warning">{copy.baseline_review_unbound_help}</p>
|
||||
{:else if canApprove}
|
||||
{#if candidate.request.kind === "metric" && !sourceVerified(candidate)}
|
||||
<p class="mt-3 text-sm text-warning">{copy.baseline_review_verify_before_approve}</p>
|
||||
{/if}
|
||||
<label class="mt-3 block text-xs font-medium" for={`baseline-reason-${candidate.candidate_id}`}>{copy.baseline_review_reason}</label>
|
||||
<textarea id={`baseline-reason-${candidate.candidate_id}`} class="mt-1 w-full rounded border border-border-strong bg-surface-card p-2 text-sm" maxlength="500" required bind:value={reason[candidate.candidate_id]}></textarea>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<Button size="sm" onclick={() => void decide(candidate, "confirm")} disabled={!!busy || !reason[candidate.candidate_id]?.trim() || !sourceVerified(candidate)}>{copy.baseline_review_approve}</Button>
|
||||
<Button size="sm" variant="destructive" onclick={() => void decide(candidate, "deny")} disabled={!!busy || !reason[candidate.candidate_id]?.trim()}>{copy.baseline_review_reject}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if candidate.gate_status === "confirmed" && canApprove}
|
||||
<Button size="sm" onclick={() => void retryPublish(candidate)} disabled={!!busy}>{copy.baseline_review_publish}</Button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
<!-- #endregion ScenarioEditor.BaselineReview -->
|
||||
@@ -0,0 +1,310 @@
|
||||
<!-- #region ScenarioEditor.ReferenceDiscovery [C:4] [TYPE Component] [SEMANTICS baseline,reference,discovery,preview] -->
|
||||
<!-- @BRIEF Preview a scenario-bound Superset reference URL and capture selected metrics as draft candidates. -->
|
||||
<!-- @UX_STATE idle -> URL entry; loading -> disabled request and status; ready -> filter and coordinate evidence; error -> field error and retry. -->
|
||||
<!-- @UX_STATE capture pending -> capture disabled; success -> candidate count and review handoff; error -> retry without losing selection. -->
|
||||
<!-- @UX_FEEDBACK Dashboard mismatch is shown at the URL field; preview never claims approval or persistence. -->
|
||||
<!-- @UX_RECOVERY Correct the URL or retry the request. -->
|
||||
<!-- @UX_REACTIVITY Props -> $props(); URL, preview, error and selection -> $state(). -->
|
||||
<!-- @INVARIANT Only executed coordinates with evidence digests can be captured; capture creates drafts and cannot approve or publish a baseline. -->
|
||||
<!-- @RATIONALE Keep discovery beside candidate review while keeping preview coordinates outside the approval gate. -->
|
||||
<!-- @REJECTED Feeding preview coordinates into candidate approval was rejected because the capture endpoint creates only draft candidates for the separate review gate. -->
|
||||
<script lang="ts">
|
||||
import { requestApi } from "$lib/api";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
import Input from "$lib/ui/Input.svelte";
|
||||
|
||||
interface FilterValue { from?: string | null; to?: string | null; value?: string | null; values?: string[] | null }
|
||||
interface PreviewFilter { filter_id: string; name?: string; column: string; operator?: string; value?: FilterValue; target_chart_ids?: number[]; dataset_id?: number }
|
||||
interface Recommendation { recommended?: boolean; reason?: string; status?: string }
|
||||
interface Coordinate {
|
||||
chart_id: number; chart_name: string; dataset_id: number; dataset_name: string;
|
||||
metric: string; metric_label: string; filter_ids: string[];
|
||||
value: string | null; error: string | null; evidence_digest: string | null; value_digest: string | null;
|
||||
execution_status?: "executed" | "error" | "no_value" | "skipped_budget";
|
||||
recommended?: boolean; recommendation_reason?: string | null; recommendation_status?: string | null;
|
||||
recommendation?: Recommendation | string | null;
|
||||
}
|
||||
interface Preview {
|
||||
dashboard_id: number; dashboard_title: string; environment_id: string;
|
||||
query_model_fingerprint: string;
|
||||
filters: { filters: PreviewFilter[]; filters_hash: string }; coordinates: Coordinate[]; truncated: boolean;
|
||||
native_filters?: PreviewFilter[];
|
||||
datasets?: { dataset_id: number; dataset_name: string }[];
|
||||
charts?: { chart_id: number; chart_name?: string; slice_name?: string; dataset_id: number }[];
|
||||
}
|
||||
interface DiscoveryError extends Error { detail?: { code?: string; message?: string } }
|
||||
|
||||
let { scenarioId, dashboardId, environments = [], onCandidatesCaptured }: {
|
||||
scenarioId: string;
|
||||
dashboardId?: number;
|
||||
environments?: { id: string; name: string; dashboard_url: string | null }[];
|
||||
onCandidatesCaptured?: () => void | Promise<void>;
|
||||
} = $props();
|
||||
const copy = $derived(($t.dashboard_testing ?? {}) as Record<string, string>);
|
||||
let url = $state("");
|
||||
let preview = $state<Preview | null>(null);
|
||||
let loading = $state(false);
|
||||
let fieldError = $state("");
|
||||
let selected = $state<string[]>([]);
|
||||
let captureState = $state<"idle" | "pending" | "success" | "error">("idle");
|
||||
let captureError = $state("");
|
||||
let capturedCount = $state(0);
|
||||
let generation = 0;
|
||||
const digestPattern = /^sha256:[0-9a-f]{64}$/;
|
||||
|
||||
// #region ScenarioEditor.ReferenceDiscovery.CoordinateKey [C:2] [TYPE Function]
|
||||
// @BRIEF Identify a metric at its chart and dataset coordinate.
|
||||
function key(item: Coordinate): string { return `${item.chart_id}:${item.dataset_id}:${item.metric}`; }
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.CoordinateKey
|
||||
// #region ScenarioEditor.ReferenceDiscovery.CanCapture [C:3] [TYPE Function]
|
||||
// @BRIEF Require a successful query and both server evidence digests for selection.
|
||||
function canCapture(item: Coordinate): boolean {
|
||||
return item.execution_status === "executed" && !item.error && item.value !== null
|
||||
&& digestPattern.test(item.evidence_digest ?? "") && digestPattern.test(item.value_digest ?? "");
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.CanCapture
|
||||
// #region ScenarioEditor.ReferenceDiscovery.Recommendation [C:2] [TYPE Function]
|
||||
// @BRIEF Read current recommendation fields with compatibility for nested preview payloads.
|
||||
function recommendation(item: Coordinate): Recommendation {
|
||||
const nested = item.recommendation;
|
||||
return {
|
||||
recommended: item.recommended ?? (typeof nested === "object" && nested !== null ? nested.recommended : false),
|
||||
reason: item.recommendation_reason ?? (typeof nested === "object" && nested !== null ? nested.reason : undefined),
|
||||
status: item.recommendation_status ?? (typeof nested === "object" && nested !== null ? nested.status : undefined),
|
||||
};
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.Recommendation
|
||||
// #region ScenarioEditor.ReferenceDiscovery.FilterValue [C:2] [TYPE Function]
|
||||
// @BRIEF Render a normalized filter value for analyst review.
|
||||
function filterValue(value?: FilterValue): string {
|
||||
if (!value) return "—";
|
||||
if (value.values?.length) return value.values.join(", ");
|
||||
if (value.from || value.to) return `${value.from ?? "…"} – ${value.to ?? "…"}`;
|
||||
return value.value ?? "—";
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.FilterValue
|
||||
// #region ScenarioEditor.ReferenceDiscovery.ActiveFilterValue [C:3] [TYPE Function]
|
||||
// @BRIEF Display active URL-state values for a dashboard filter definition.
|
||||
function activeFilterValue(filterId: string): string {
|
||||
const active = preview?.filters.filters.filter((entry) => entry.filter_id === filterId) ?? [];
|
||||
return active.length
|
||||
? active.map((entry) => `${entry.operator === "GREATER_THAN_OR_EQUALS" ? "≥ " : entry.operator === "LESS_THAN_OR_EQUALS" ? "≤ " : entry.operator === "GREATER_THAN" ? "> " : entry.operator === "LESS_THAN" ? "< " : ""}${filterValue(entry.value)}`).join(" · ")
|
||||
: "—";
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.ActiveFilterValue
|
||||
// #region ScenarioEditor.ReferenceDiscovery.AppliedFilterNames [C:2] [TYPE Function]
|
||||
// @BRIEF Name the active filters used by one chart metric.
|
||||
function appliedFilterNames(item: Coordinate): string {
|
||||
return item.filter_ids.map((id) => preview?.native_filters?.find((entry) => entry.filter_id === id)?.name ?? id).join(", ") || copy.reference_none;
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.AppliedFilterNames
|
||||
// #region ScenarioEditor.ReferenceDiscovery.RecommendationText [C:3] [TYPE Function]
|
||||
// @BRIEF Translate facts-based recommendation reasons from the server.
|
||||
function recommendationText(item: Coordinate): string {
|
||||
const basis = recommendation(item);
|
||||
if (basis.reason === "Big-number aggregate without grouping") return copy.reference_reason_big_number;
|
||||
if (basis.reason === "First chart metric for this dataset") return copy.reference_reason_representative;
|
||||
if (basis.reason === "First occurrence of this metric on the dataset") return copy.reference_reason_distinct_metric;
|
||||
return basis.reason || basis.status || (basis.recommended ? copy.reference_no_recommendation : copy.reference_not_recommended);
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.RecommendationText
|
||||
// #region ScenarioEditor.ReferenceDiscovery.PreviewValue [C:2] [TYPE Function]
|
||||
// @BRIEF Shorten long decimal previews for reading, mark rounding, and retain the exact server value in technical details.
|
||||
function previewValue(value: string): string {
|
||||
if (!/^-?\d{1,15}\.\d{9,}$/.test(value)) return value;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || (numeric !== 0 && Math.abs(numeric) < 0.000001)) return value;
|
||||
const concise = numeric.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
|
||||
return concise === value ? value : `≈ ${concise}`;
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.PreviewValue
|
||||
// #region ScenarioEditor.ReferenceDiscovery.EditUrl [C:3] [TYPE Function]
|
||||
// @BRIEF Invalidate stale preview and selection when the analyst changes the URL.
|
||||
function editUrl(): void {
|
||||
generation += 1;
|
||||
preview = null;
|
||||
selected = [];
|
||||
captureState = "idle";
|
||||
captureError = "";
|
||||
fieldError = "";
|
||||
loading = false;
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.EditUrl
|
||||
// #region ScenarioEditor.ReferenceDiscovery.Toggle [C:2] [TYPE Function]
|
||||
// @BRIEF Toggle one local selection without implying baseline approval.
|
||||
function toggle(item: Coordinate): void {
|
||||
const id = key(item);
|
||||
selected = selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id];
|
||||
captureState = "idle";
|
||||
captureError = "";
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.Toggle
|
||||
// #region ScenarioEditor.ReferenceDiscovery.Discover [C:4] [TYPE Function]
|
||||
// @BRIEF Load URL-bound chart metrics and initialize selection from server recommendations.
|
||||
async function discover(): Promise<void> {
|
||||
const submitted = url.trim();
|
||||
if (!submitted) { fieldError = copy.reference_url_required; return; }
|
||||
const current = ++generation;
|
||||
loading = true;
|
||||
fieldError = "";
|
||||
preview = null;
|
||||
selected = [];
|
||||
captureState = "idle";
|
||||
captureError = "";
|
||||
try {
|
||||
const result = await requestApi<Preview>("/dashboard-testing/reference-discovery", "POST", { scenario_id: scenarioId, url: submitted });
|
||||
if (current !== generation) return;
|
||||
preview = result;
|
||||
selected = result.coordinates.filter((item) => recommendation(item).recommended && canCapture(item)).map(key);
|
||||
} catch (cause) {
|
||||
if (current !== generation) return;
|
||||
const failure = cause as DiscoveryError;
|
||||
fieldError = failure.detail?.code === "DASHBOARD_MISMATCH"
|
||||
? copy.reference_dashboard_mismatch
|
||||
: failure.detail?.code === "ENVIRONMENT_MISMATCH"
|
||||
? copy.reference_environment_mismatch
|
||||
: failure.message || copy.reference_discovery_error;
|
||||
} finally {
|
||||
if (current === generation) loading = false;
|
||||
}
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.Discover
|
||||
// #region ScenarioEditor.ReferenceDiscovery.Capture [C:4] [TYPE Function]
|
||||
// @BRIEF Submit selected evidence coordinates for draft candidate creation.
|
||||
async function capture(): Promise<void> {
|
||||
if (!preview || captureState === "pending" || captureState === "success") return;
|
||||
const chosen = preview.coordinates.filter((item) => selected.includes(key(item)) && canCapture(item));
|
||||
if (chosen.length === 0 || chosen.length > 20) return;
|
||||
captureState = "pending";
|
||||
captureError = "";
|
||||
try {
|
||||
const result = await requestApi<{ candidate_ids: string[] }>("/dashboard-testing/reference-selection/capture", "POST", {
|
||||
scenario_id: scenarioId, url: url.trim(),
|
||||
selected: chosen.map((item) => ({ chart_id: item.chart_id, dataset_id: item.dataset_id, metric: item.metric, evidence_digest: item.evidence_digest, value_digest: item.value_digest, execution_status: "executed" })),
|
||||
expected_query_model_fingerprint: preview.query_model_fingerprint,
|
||||
expected_filters_hash: preview.filters.filters_hash,
|
||||
});
|
||||
capturedCount = result.candidate_ids.length;
|
||||
captureState = "success";
|
||||
await onCandidatesCaptured?.();
|
||||
} catch (cause) {
|
||||
captureState = "error";
|
||||
const failure = cause as DiscoveryError;
|
||||
captureError = failure.detail?.code === "RELEASE_PREREQUISITE_MISSING"
|
||||
? copy.reference_capture_release_missing
|
||||
: failure.detail?.code === "REFERENCE_DRIFT"
|
||||
? copy.reference_capture_drift
|
||||
: failure.message || copy.reference_capture_error;
|
||||
}
|
||||
}
|
||||
// #endregion ScenarioEditor.ReferenceDiscovery.Capture
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg border border-border bg-surface-card p-4" aria-label={copy.reference_title}>
|
||||
<h3 class="font-semibold text-text">{copy.reference_title}</h3>
|
||||
<p class="mt-1 text-sm text-text-muted">{copy.reference_intro}</p>
|
||||
{#if dashboardId !== undefined}
|
||||
<div class="mt-3 rounded border border-border bg-surface-muted p-3 text-sm" aria-label={copy.reference_scenario_context}>
|
||||
<p class="font-medium text-text">{copy.reference_scenario_dashboard}: #{dashboardId}</p>
|
||||
<p class="mt-1 text-text-muted">{copy.reference_expected_environment}</p>
|
||||
{#if environments.length}
|
||||
<ul class="mt-2 space-y-2">
|
||||
{#each environments as environment (environment.id)}
|
||||
<li class="text-text">
|
||||
<span class="font-medium">{environment.name}</span> <span class="text-text-muted">({environment.id})</span>
|
||||
{#if environment.dashboard_url}
|
||||
· <a class="break-all text-primary underline" href={environment.dashboard_url} target="_blank" rel="noopener noreferrer" aria-label={`${copy.reference_open_dashboard}: ${environment.name}, #${dashboardId}`}>{copy.reference_open_dashboard}</a>
|
||||
{:else}
|
||||
· <span class="text-warning">{copy.reference_environment_unavailable}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="mt-2 text-warning">{copy.reference_no_bound_environment}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<form class="mt-3 flex flex-wrap items-end gap-2" onsubmit={(event) => { event.preventDefault(); void discover(); }}>
|
||||
<div class="min-w-64 flex-1">
|
||||
<Input id="reference-dashboard-url" type="url" label={copy.reference_url_label} placeholder={copy.reference_url_placeholder} bind:value={url} oninput={editUrl} disabled={captureState === "pending"} aria-invalid={!!fieldError} aria-describedby={fieldError ? "reference-url-error" : undefined} />
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={loading || captureState === "pending"}>{loading ? copy.reference_loading : copy.reference_preview}</Button>
|
||||
</form>
|
||||
{#if loading}<p class="mt-2 text-sm text-text-muted" role="status">{copy.reference_loading}</p>{/if}
|
||||
{#if fieldError}
|
||||
<div class="mt-2 flex items-center gap-2" id="reference-url-error" role="alert">
|
||||
<p class="text-sm text-destructive">{fieldError}</p>
|
||||
<Button size="sm" variant="secondary" onclick={() => void discover()}>{copy.reference_retry}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if preview}
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<h4 class="font-medium text-text">{preview.dashboard_title}</h4>
|
||||
<p class="text-sm text-warning">{copy.reference_not_approved}</p>
|
||||
{#if captureState !== "success"}<p class="text-xs text-text-muted">{copy.reference_local_selection}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-text">{copy.reference_filters}</h4>
|
||||
{#if (preview.native_filters ?? preview.filters.filters).length === 0}
|
||||
<p class="text-sm text-text-muted">{copy.reference_no_filters}</p>
|
||||
{:else}
|
||||
<ul class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{#each preview.native_filters ?? preview.filters.filters as filter (filter.filter_id)}
|
||||
<li class="rounded border border-border p-2 text-sm">
|
||||
<span class="font-medium text-text">{filter.name ?? filter.column}</span>
|
||||
<span class="text-text-muted"> · {activeFilterValue(filter.filter_id)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if preview.datasets?.length}
|
||||
<div>
|
||||
<h4 class="font-medium text-text">{copy.reference_datasets}</h4>
|
||||
<ul class="mt-2 flex flex-wrap gap-2">
|
||||
{#each preview.datasets as dataset (dataset.dataset_id)}
|
||||
<li class="rounded border border-border px-2 py-1 text-sm text-text">{dataset.dataset_name}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<h4 class="font-medium text-text">{copy.reference_coordinates}</h4>
|
||||
{#if preview.coordinates.length === 0}<p class="text-sm text-text-muted">{copy.reference_no_coordinates}</p>{/if}
|
||||
<ul class="mt-2 grid gap-3 lg:grid-cols-2">
|
||||
{#each preview.coordinates as item (key(item))}
|
||||
<li class="rounded border border-border bg-surface p-3 text-sm">
|
||||
<p class="font-medium text-text">{item.dataset_name} → {item.chart_name} → {item.metric_label || item.metric}</p>
|
||||
<p class="text-xs text-text-muted">{copy.reference_metric_on_chart}: {item.metric_label || item.metric} · {item.chart_name}</p>
|
||||
<p class="mt-1 text-text-muted">{copy.reference_dataset}: {item.dataset_name}</p>
|
||||
<p class="text-text-muted">{copy.reference_applied_filters}: {appliedFilterNames(item)}</p>
|
||||
<details class="mt-1 text-xs text-text-muted"><summary>{copy.reference_technical_details}</summary><p>Chart #{item.chart_id} · Dataset #{item.dataset_id} · {item.metric}</p>{#if item.value !== null}<p>{copy.reference_raw_value}: {item.value}</p>{/if}</details>
|
||||
{#if item.error}<p class="mt-2 text-destructive" role="alert">{copy.reference_execution_error}: {item.error}</p>
|
||||
{:else if item.execution_status === "skipped_budget"}<p class="mt-2 text-warning">{copy.reference_skipped_budget}</p>
|
||||
{:else if item.value === null}<p class="mt-2 text-warning">{copy.reference_no_value}</p>
|
||||
{:else}<p class="mt-2 font-semibold text-text">{copy.reference_value}: {previewValue(item.value)}</p>{/if}
|
||||
<p class="mt-2 text-text-muted">{copy.reference_recommendation}: {recommendationText(item)}</p>
|
||||
<label class="mt-2 flex items-center gap-2 text-text">
|
||||
<input type="checkbox" checked={selected.includes(key(item))} onchange={() => toggle(item)} disabled={!canCapture(item) || captureState === "pending" || captureState === "success"} />
|
||||
{copy.reference_select_local}
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if preview.truncated}<p class="mt-2 text-sm text-warning">{copy.reference_truncated}</p>{/if}
|
||||
<p class="mt-2 text-xs text-text-muted">{copy.reference_selected}: {selected.length}.{captureState === "success" ? "" : ` ${copy.reference_local_selection}`}</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onclick={() => void capture()} disabled={selected.length === 0 || captureState === "pending" || captureState === "success"}>
|
||||
{captureState === "pending" ? copy.reference_capture_pending : copy.reference_capture_action}
|
||||
</Button>
|
||||
{#if captureState === "success"}<p class="text-sm text-success" role="status">{copy.reference_capture_success}: {capturedCount}. {copy.reference_capture_review}</p>{/if}
|
||||
{#if captureState === "error"}<p class="text-sm text-destructive" role="alert">{captureError} {copy.reference_capture_retry}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<!-- #endregion ScenarioEditor.ReferenceDiscovery -->
|
||||
@@ -1,20 +1,54 @@
|
||||
<!-- #region ScenarioEditor.Component.VisualDagCanvas [C:4] [TYPE Component] [SEMANTICS scenario,editor,dag,dependency,ux] -->
|
||||
<!-- @ingroup ScenarioEditor -->
|
||||
<!-- @BRIEF Render dependency edges and expose only add/remove typed dependency actions. -->
|
||||
<!-- @INVARIANT Self edges and duplicate edges are rejected before callback. -->
|
||||
<!-- @BRIEF Render revision steps as directed nodes and arrows; expose typed dependency additions. -->
|
||||
<!-- @UX_STATE empty -> no-step message; ready -> directed graph; invalid edge -> alert. -->
|
||||
<!-- @UX_REACTIVITY Props -> $props(); graph layout -> $derived(); form -> $state(). -->
|
||||
<!-- @INVARIANT Canvas uses server-loaded steps and dependencies; invalid edges never reach callback. -->
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
|
||||
let { dependencies = [], onchange }: { dependencies?: { source: string; target: string }[]; onchange?: (edges: { source: string; target: string }[]) => void } = $props();
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
type Edge = { source: string; target: string };
|
||||
type Node = { id: string; title: string; action: string; level: number; row: number; index: number };
|
||||
const nodeWidth = 224, nodeHeight = 88, columnGap = 88, rowGap = 36;
|
||||
let { steps = [], dependencies = [], onchange }: { steps?: Record<string, unknown>[]; dependencies?: Edge[]; onchange?: (edges: Edge[]) => void } = $props();
|
||||
let source = $state("");
|
||||
let target = $state("");
|
||||
let error = $state("");
|
||||
|
||||
const dt = $derived($t.dashboard_testing ?? {});
|
||||
|
||||
function layout(input: Record<string, unknown>[], edges: Edge[]): Node[] {
|
||||
const ids = input.map((step, index) => String(step.logical_step_id ?? step.id ?? index));
|
||||
const levels = new Map(ids.map((id) => [id, 0]));
|
||||
for (let pass = 0; pass < ids.length; pass += 1) {
|
||||
let changed = false;
|
||||
for (const edge of edges) {
|
||||
if (!levels.has(edge.source) || !levels.has(edge.target) || edge.source === edge.target) continue;
|
||||
const next = Math.min(ids.length - 1, (levels.get(edge.source) ?? 0) + 1);
|
||||
if (next > (levels.get(edge.target) ?? 0)) { levels.set(edge.target, next); changed = true; }
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
const rows = new Map<number, number>();
|
||||
return input.map((step, index) => {
|
||||
const id = ids[index], level = levels.get(id) ?? 0, row = rows.get(level) ?? 0;
|
||||
rows.set(level, row + 1);
|
||||
return { id, level, row, index, title: String(step.title ?? step.name ?? step.template ?? id), action: String(step.action ?? step.operator ?? "") };
|
||||
});
|
||||
}
|
||||
const nodes = $derived(layout(steps, dependencies));
|
||||
const columns = $derived(Math.max(0, ...nodes.map((node) => node.level)) + 1);
|
||||
const rows = $derived(Math.max(0, ...Array.from({ length: columns }, (_, level) => nodes.filter((node) => node.level === level).length)));
|
||||
const width = $derived(columns * nodeWidth + Math.max(0, columns - 1) * columnGap);
|
||||
const height = $derived(rows * nodeHeight + Math.max(0, rows - 1) * rowGap);
|
||||
const drawnEdges = $derived(dependencies.flatMap((edge) => {
|
||||
const from = nodes.find((node) => node.id === edge.source), to = nodes.find((node) => node.id === edge.target);
|
||||
return from && to ? [{ edge, from, to }] : [];
|
||||
}));
|
||||
function x(node: Node): number { return node.level * (nodeWidth + columnGap); }
|
||||
function y(node: Node): number { return node.row * (nodeHeight + rowGap); }
|
||||
function addEdge(): void {
|
||||
if (!source || !target || source === target) { error = dt.dag_error_self; return; }
|
||||
if (dependencies.some((edge) => edge.source === source && edge.target === target)) { error = dt.dag_error_dup; return; }
|
||||
if (!nodes.some((node) => node.id === source) || !nodes.some((node) => node.id === target)) { error = dt.dag_error_unknown; return; }
|
||||
error = "";
|
||||
onchange?.([...dependencies, { source, target }]);
|
||||
}
|
||||
@@ -22,14 +56,36 @@
|
||||
|
||||
<section class="rounded-lg border border-border bg-surface-card p-4" aria-label={dt.dag_aria}>
|
||||
<h3 class="font-medium text-text">{dt.dag_title}</h3>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<input aria-label={dt.dag_source_aria} class="rounded border border-border px-2 py-2" bind:value={source} placeholder="step-a" />
|
||||
<input aria-label={dt.dag_target_aria} class="rounded border border-border px-2 py-2" bind:value={target} placeholder="step-b" />
|
||||
<button class="rounded bg-primary px-3 py-2 text-sm text-white" onclick={addEdge}>{dt.dag_add}</button>
|
||||
{#if nodes.length === 0}
|
||||
<p class="mt-3 text-sm text-text-muted">{dt.editor_steps_empty}</p>
|
||||
{:else}
|
||||
<div class="mt-4 overflow-x-auto pb-2" role="img" aria-label={dt.dag_graph_aria}>
|
||||
<div class="relative" style:width={`${width}px`} style:height={`${height}px`}>
|
||||
<svg class="pointer-events-none absolute inset-0" width={width} height={height} aria-hidden="true">
|
||||
<defs><marker id="scenario-dag-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 Z" fill="currentColor" class="text-primary" /></marker></defs>
|
||||
{#each drawnEdges as { edge, from, to } (edge.source + ':' + edge.target)}
|
||||
<path d={`M ${x(from) + nodeWidth} ${y(from) + nodeHeight / 2} C ${x(from) + nodeWidth + columnGap / 2} ${y(from) + nodeHeight / 2}, ${x(to) - columnGap / 2} ${y(to) + nodeHeight / 2}, ${x(to) - 8} ${y(to) + nodeHeight / 2}`} fill="none" stroke="currentColor" stroke-width="2" marker-end="url(#scenario-dag-arrow)" class="text-primary" />
|
||||
{/each}
|
||||
</svg>
|
||||
{#each nodes as node (node.id)}
|
||||
<article class="absolute rounded-lg border border-border-strong bg-surface-card p-3 shadow-sm" style:left={`${x(node)}px`} style:top={`${y(node)}px`} style:width={`${nodeWidth}px`} style:height={`${nodeHeight}px`} aria-label={`${dt.dag_step_label} ${node.index + 1}: ${node.title}`}>
|
||||
<p class="truncate text-xs font-medium text-primary">{dt.dag_step_label} {node.index + 1} · {node.id}</p>
|
||||
<h4 class="mt-1 truncate font-medium text-text" title={node.title}>{node.title}</h4>
|
||||
{#if node.action}<p class="truncate text-xs text-text-muted" title={node.action}>{node.action}</p>{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="sr-only" aria-label={dt.dag_edges_aria}>
|
||||
{#each dependencies as edge (edge.source + ':' + edge.target)}<li>{edge.source} → {edge.target}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<input aria-label={dt.dag_source_aria} class="rounded border border-border px-2 py-2" bind:value={source} placeholder="step-a" list="scenario-dag-steps" />
|
||||
<input aria-label={dt.dag_target_aria} class="rounded border border-border px-2 py-2" bind:value={target} placeholder="step-b" list="scenario-dag-steps" />
|
||||
<datalist id="scenario-dag-steps">{#each nodes as node (node.id)}<option value={node.id}>{node.title}</option>{/each}</datalist>
|
||||
<Button size="sm" onclick={addEdge}>{dt.dag_add}</Button>
|
||||
</div>
|
||||
{#if error}<p class="mt-2 text-sm text-destructive" role="alert">{error}</p>{/if}
|
||||
<ul class="mt-3 space-y-1 text-sm text-text-muted">
|
||||
{#each dependencies as edge}<li>{edge.source} → {edge.target}</li>{/each}
|
||||
</ul>
|
||||
</section>
|
||||
<!-- #endregion ScenarioEditor.Component.VisualDagCanvas -->
|
||||
<!-- #endregion ScenarioEditor.Component.VisualDagCanvas -->
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// #region Test.ScenarioEditor.BaselineReview [C:3] [TYPE Module] [SEMANTICS test,baseline,review,reason,conflict]
|
||||
// @BRIEF Prove mandatory reason, preserved input after conflict, and reload recovery.
|
||||
// @RELATION VERIFIES -> [ScenarioEditor.BaselineReview]
|
||||
// @TEST_INVARIANT A failed approval retains the entered reason and leaves the candidate reviewable.
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import BaselineReview from "../BaselineReview.svelte";
|
||||
import { fetchApi, fetchApiBlob, requestApi } from "$lib/api";
|
||||
|
||||
vi.mock("$lib/api", () => ({ fetchApi: vi.fn(), fetchApiBlob: vi.fn(), requestApi: vi.fn() }));
|
||||
|
||||
const candidate = {
|
||||
candidate_id: "candidate-1",
|
||||
agent_run_id: "run-1",
|
||||
status: "draft",
|
||||
gate_id: null,
|
||||
gate_status: null,
|
||||
gate_reason: null,
|
||||
catalog_status: "missing",
|
||||
current_baseline: null,
|
||||
release_version: "v1.0.0",
|
||||
release_commit_hash: "a".repeat(40),
|
||||
created_at: "2026-09-24T00:00:00Z",
|
||||
request: {
|
||||
label: "Captured revenue",
|
||||
kind: "metric",
|
||||
result_key: "revenue",
|
||||
candidate_value: { kind: "integer", canonical_value: "125", display_value: "125" },
|
||||
source_response_hash: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
capture_artifact_ref: "source-1",
|
||||
comparison_policy: { type: "exact" },
|
||||
provenance: { environment: "preprod", actor: "analyst" },
|
||||
repository_key: "repo",
|
||||
dashboard_key: "dashboard",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchApiBlob).mockResolvedValue(new Blob(["abc"]));
|
||||
vi.mocked(fetchApi).mockImplementation(async (path) =>
|
||||
path.includes("baseline-overview")
|
||||
? { state: "missing", release_version: "v1.0.0", environment_name: "Preprod", entries: [], total: 0 }
|
||||
: [candidate]);
|
||||
});
|
||||
|
||||
describe("BaselineReview", () => {
|
||||
it("shows the authoritative release state when no candidate exists", async () => {
|
||||
vi.mocked(fetchApi).mockImplementation(async (path) =>
|
||||
path.includes("baseline-overview")
|
||||
? { state: "no_release", release_version: null, environment_name: null, entries: [], total: 0 }
|
||||
: []);
|
||||
render(BaselineReview, { scenarioId: "empty-scenario", canApprove: true });
|
||||
expect(await screen.findByText(/нет утверждённого релиза|no approved release/i)).toBeTruthy();
|
||||
expect(screen.queryByText(/В каталоге нет утверждённой записи|No approved catalog entry/)).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a reason and preserves it across a stale gate conflict", async () => {
|
||||
vi.mocked(requestApi).mockRejectedValue(Object.assign(new Error("Gate already decided"), { status: 409 }));
|
||||
render(BaselineReview, { scenarioId: "scenario-1", canApprove: true });
|
||||
expect(await screen.findByText("Captured revenue")).toBeTruthy();
|
||||
const approve = screen.getByRole("button", { name: /Утвердить и опубликовать|Approve and publish/ });
|
||||
const reason = screen.getByRole("textbox", { name: /Причина решения|Review reason/ }) as HTMLTextAreaElement;
|
||||
expect(approve.hasAttribute("disabled")).toBe(true);
|
||||
await fireEvent.input(reason, { target: { value: "Checked source evidence" } });
|
||||
expect(approve.hasAttribute("disabled")).toBe(true);
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Посмотреть источник|View source/ }));
|
||||
await screen.findByText(/контрольная сумма совпадает|checksum matches/i);
|
||||
expect(approve.hasAttribute("disabled")).toBe(false);
|
||||
await fireEvent.click(approve);
|
||||
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Gate already decided"));
|
||||
expect(reason.value).toBe("Checked source evidence");
|
||||
expect(vi.mocked(fetchApi).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
// #endregion Test.ScenarioEditor.BaselineReview
|
||||
@@ -0,0 +1,130 @@
|
||||
// #region Test.ScenarioEditor.ReferenceDiscovery [C:3] [TYPE Module] [SEMANTICS test,baseline,reference,preview]
|
||||
// @BRIEF Verify dashboard identity errors, metric attribution and local-only selection against fixed API fixtures.
|
||||
// @RELATION VERIFIES -> [ScenarioEditor.ReferenceDiscovery]
|
||||
// @TEST_INVARIANT A preview offers no approval action and a dashboard mismatch is shown at the URL field.
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ReferenceDiscovery from "../ReferenceDiscovery.svelte";
|
||||
import { requestApi } from "$lib/api";
|
||||
|
||||
vi.mock("$lib/api", () => ({ requestApi: vi.fn() }));
|
||||
|
||||
const fixture = {
|
||||
environment_id: "preprod", dashboard_id: 42, dashboard_title: "Revenue dashboard",
|
||||
query_model_fingerprint: `sha256:${"a".repeat(64)}`,
|
||||
native_filters: [{ filter_id: "region", name: "Region selector", dataset_id: 7, column: "region" }],
|
||||
datasets: [{ dataset_id: 7, dataset_name: "Orders" }],
|
||||
filters: { filters_hash: `sha256:${"b".repeat(64)}`, filters: [{ filter_id: "region", dataset_id: 7, column: "region", operator: "IN", value: { values: ["EU"] }, target_chart_ids: [9] }] },
|
||||
coordinates: [
|
||||
{ chart_id: 9, chart_name: "Revenue trend", dataset_id: 7, dataset_name: "Orders", metric: "revenue", metric_label: "Revenue", filter_ids: ["region"], value: "247.4600000000001", error: null, evidence_digest: `sha256:${"c".repeat(64)}`, value_digest: `sha256:${"d".repeat(64)}`, execution_status: "executed", recommended: true, recommendation_reason: "First occurrence of this metric on the dataset" },
|
||||
{ chart_id: 10, chart_name: "Orders trend", dataset_id: 7, dataset_name: "Orders", metric: "count", metric_label: "Count", filter_ids: [], value: null, error: "Query timed out", evidence_digest: null, value_digest: null, execution_status: "error", recommended: false, recommendation_reason: "Execution failed" },
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("ReferenceDiscovery", () => {
|
||||
it("shows the scenario environment and opens its dashboard before URL entry", () => {
|
||||
render(ReferenceDiscovery, { scenarioId: "scenario-1", dashboardId: 10, environments: [{ id: "dev", name: "Development", dashboard_url: "https://ss-dev.bebesh.ru/superset/dashboard/10/" }] });
|
||||
expect(screen.getByText(/Дашборд сценария: #10|Scenario dashboard: #10/)).toBeTruthy();
|
||||
const link = screen.getByRole("link", { name: /Открыть дашборд|Open dashboard/ }) as HTMLAnchorElement;
|
||||
expect(link.href).toBe("https://ss-dev.bebesh.ru/superset/dashboard/10/");
|
||||
expect(link.getAttribute("rel")).toContain("noopener");
|
||||
});
|
||||
it("renders chart and dataset attribution, recommendation basis, and local selection", async () => {
|
||||
vi.mocked(requestApi).mockResolvedValue(fixture);
|
||||
render(ReferenceDiscovery, { scenarioId: "scenario-1" });
|
||||
await fireEvent.input(screen.getByRole("textbox", { name: /Reference dashboard URL|URL эталонного дашборда/ }), { target: { value: "https://superset.example/superset/dashboard/42/" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Preview reference|Посмотреть эталон/ }));
|
||||
await screen.findByText("Revenue dashboard");
|
||||
expect(vi.mocked(requestApi)).toHaveBeenCalledWith("/dashboard-testing/reference-discovery", "POST", { scenario_id: "scenario-1", url: "https://superset.example/superset/dashboard/42/" });
|
||||
expect(screen.getByText("Orders → Revenue trend → Revenue")).toBeTruthy();
|
||||
expect(screen.getAllByText(/Orders/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Region selector")).toBeTruthy();
|
||||
expect(screen.getByText(/First appearance of this metric|Первое появление этой метрики/)).toBeTruthy();
|
||||
expect(screen.getByText(/≈ 247\.46/)).toBeTruthy();
|
||||
expect(screen.getByText(/247\.4600000000001/)).toBeTruthy();
|
||||
expect(screen.getByText(/Query timed out/)).toBeTruthy();
|
||||
const choices = screen.getAllByRole("checkbox") as HTMLInputElement[];
|
||||
expect(choices[0].checked).toBe(true);
|
||||
expect(choices[1].disabled).toBe(true);
|
||||
await fireEvent.click(choices[0]);
|
||||
expect(choices[0].checked).toBe(false);
|
||||
expect(screen.getAllByText(/not saved|не сохранён/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole("button", { name: /Approve|Утвердить/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps tiny high-precision source values visible without rounding them to zero", async () => {
|
||||
vi.mocked(requestApi).mockResolvedValue({
|
||||
...fixture,
|
||||
coordinates: [{ ...fixture.coordinates[0], value: "0.0000000001" }],
|
||||
});
|
||||
render(ReferenceDiscovery, { scenarioId: "scenario-1" });
|
||||
await fireEvent.input(screen.getByRole("textbox", { name: /Reference dashboard URL|URL эталонного дашборда/ }), { target: { value: "https://superset.example/superset/dashboard/42/" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Preview reference|Посмотреть эталон/ }));
|
||||
await screen.findByText("Revenue dashboard");
|
||||
expect(screen.getAllByText(/0\.0000000001/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/≈ 0(?:\D|$)/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a different-dashboard error on the URL field and retries", async () => {
|
||||
vi.mocked(requestApi).mockRejectedValueOnce(Object.assign(new Error("mismatch"), { detail: { code: "DASHBOARD_MISMATCH" } })).mockResolvedValueOnce(fixture);
|
||||
render(ReferenceDiscovery, { scenarioId: "scenario-1" });
|
||||
const input = screen.getByRole("textbox", { name: /Reference dashboard URL|URL эталонного дашборда/ }) as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: "https://superset.example/superset/dashboard/99/" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Preview reference|Посмотреть эталон/ }));
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(/different dashboard|другой дашборд/i);
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Retry|Повторить/ }));
|
||||
await waitFor(() => expect(screen.getByText("Revenue dashboard")).toBeTruthy());
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
});
|
||||
|
||||
it("captures selected evidence as drafts and asks the parent to reload candidates", async () => {
|
||||
const onCandidatesCaptured = vi.fn();
|
||||
vi.mocked(requestApi).mockResolvedValueOnce(fixture).mockResolvedValueOnce({ candidate_ids: ["draft-1"], agent_run_id: "run-1" });
|
||||
render(ReferenceDiscovery, { scenarioId: "scenario-1", onCandidatesCaptured });
|
||||
await fireEvent.input(screen.getByRole("textbox", { name: /Reference dashboard URL|URL эталонного дашборда/ }), { target: { value: "https://superset.example/superset/dashboard/42/" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Preview reference|Посмотреть эталон/ }));
|
||||
await screen.findByText("Revenue dashboard");
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Create candidates for review|Создать кандидатов для проверки/ }));
|
||||
await waitFor(() => expect(onCandidatesCaptured).toHaveBeenCalledOnce());
|
||||
expect(vi.mocked(requestApi)).toHaveBeenLastCalledWith("/dashboard-testing/reference-selection/capture", "POST", {
|
||||
scenario_id: "scenario-1", url: "https://superset.example/superset/dashboard/42/",
|
||||
selected: [{ chart_id: 9, dataset_id: 7, metric: "revenue", evidence_digest: `sha256:${"c".repeat(64)}`, value_digest: `sha256:${"d".repeat(64)}`, execution_status: "executed" }],
|
||||
expected_query_model_fingerprint: `sha256:${"a".repeat(64)}`,
|
||||
expected_filters_hash: `sha256:${"b".repeat(64)}`,
|
||||
});
|
||||
expect(screen.getByText(/Draft candidates created|Созданы черновики кандидатов/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /Approve|Утвердить/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("retains selected evidence after a capture error so the user can retry", async () => {
|
||||
vi.mocked(requestApi).mockResolvedValueOnce(fixture)
|
||||
.mockRejectedValueOnce(Object.assign(new Error("release missing"), { detail: { code: "RELEASE_PREREQUISITE_MISSING" } }))
|
||||
.mockResolvedValueOnce({ candidate_ids: ["draft-1"] });
|
||||
render(ReferenceDiscovery, { scenarioId: "scenario-1" });
|
||||
await fireEvent.input(screen.getByRole("textbox", { name: /Reference dashboard URL|URL эталонного дашборда/ }), { target: { value: "https://superset.example/superset/dashboard/42/" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Preview reference|Посмотреть эталон/ }));
|
||||
await screen.findByText("Revenue dashboard");
|
||||
const action = screen.getByRole("button", { name: /Create candidates for review|Создать кандидатов для проверки/ });
|
||||
await fireEvent.click(action);
|
||||
await waitFor(() => expect(screen.getAllByRole("alert").some((alert) => /approved release|утверждённый релиз/i.test(alert.textContent ?? ""))).toBe(true));
|
||||
expect((screen.getAllByRole("checkbox")[0] as HTMLInputElement).checked).toBe(true);
|
||||
await fireEvent.click(action);
|
||||
await waitFor(() => expect(vi.mocked(requestApi)).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
|
||||
it("cannot select a metric whose preview lacks a valid value digest", async () => {
|
||||
vi.mocked(requestApi).mockResolvedValue({ ...fixture, coordinates: [{ ...fixture.coordinates[0], value_digest: null }] });
|
||||
render(ReferenceDiscovery, { scenarioId: "scenario-1" });
|
||||
await fireEvent.input(screen.getByRole("textbox", { name: /Reference dashboard URL|URL эталонного дашборда/ }), { target: { value: "https://superset.example/superset/dashboard/42/" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Preview reference|Посмотреть эталон/ }));
|
||||
await screen.findByText("Revenue dashboard");
|
||||
expect((screen.getByRole("checkbox") as HTMLInputElement).disabled).toBe(true);
|
||||
expect(screen.getByRole("button", { name: /Create candidates for review|Создать кандидатов для проверки/ }).hasAttribute("disabled")).toBe(true);
|
||||
expect(vi.mocked(requestApi)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
// #endregion Test.ScenarioEditor.ReferenceDiscovery
|
||||
@@ -14,5 +14,15 @@ describe("VisualDagCanvas", () => {
|
||||
expect(screen.getByRole("alert").textContent).toContain("уже существует");
|
||||
expect(onchange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("draws a branch and exposes each dependency to assistive technology", () => {
|
||||
const { container } = render(VisualDagCanvas, { props: {
|
||||
steps: [{ logical_step_id: "open", title: "Open dashboard" }, { logical_step_id: "capture", title: "Capture evidence" }, { logical_step_id: "compare", title: "Compare baseline" }],
|
||||
dependencies: [{ source: "open", target: "capture" }, { source: "open", target: "compare" }],
|
||||
} });
|
||||
expect(container.querySelectorAll("svg path[marker-end]")).toHaveLength(2);
|
||||
expect(screen.getByLabelText("Направления зависимостей").textContent).toContain("open → compare");
|
||||
expect(screen.getAllByText("Compare baseline").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
// #endregion Test.ScenarioEditor.Component.VisualDag
|
||||
|
||||
@@ -109,7 +109,6 @@
|
||||
|
||||
<section class="rounded-lg border border-border bg-surface-card p-4" aria-label={dt.run_config_aria}>
|
||||
<h2 class="font-semibold text-text">{dt.run_config_title}</h2>
|
||||
<p class="mt-1 text-sm text-text-muted">{dt.run_config_subtitle}</p>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="text-sm text-text-muted">
|
||||
@@ -233,4 +232,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
<!-- #endregion ScenarioRunMonitor.Component.RunConfigurationPanel -->
|
||||
<!-- #endregion ScenarioRunMonitor.Component.RunConfigurationPanel -->
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<section class="rounded-lg border border-border bg-surface-card" aria-label={dt.history_aria}>
|
||||
<header class="border-b border-border p-3">
|
||||
<h2 class="font-medium text-text">{dt.history_title}</h2>
|
||||
<p class="mt-0.5 text-xs text-text-muted">{dt.history_note}</p>
|
||||
</header>
|
||||
{#if runs.length === 0}
|
||||
<p class="p-3 text-sm text-text-muted">{dt.history_empty}</p>
|
||||
@@ -30,4 +29,4 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
<!-- #endregion ScenarioRunMonitor.Component.History -->
|
||||
<!-- #endregion ScenarioRunMonitor.Component.History -->
|
||||
|
||||
@@ -1,34 +1,75 @@
|
||||
<!-- #region ScenarioRunMonitor.Component.RunTimeline [C:3] [TYPE Component] [SEMANTICS scenario,run,monitor,timeline,ux] -->
|
||||
<!-- #region ScenarioRunMonitor.Component.RunTimeline [C:4] [TYPE Component] [SEMANTICS scenario,run,monitor,dag,ux] -->
|
||||
<!-- @ingroup ScenarioRunMonitor -->
|
||||
<!-- @BRIEF Render typed step status timeline for a scenario run, labeled distinctly from 037 verification and 040 load runs. -->
|
||||
<!-- @BRIEF Render the pinned dependency graph with live step status. -->
|
||||
<!-- @UX_STATE empty -> no graph; planned -> queued nodes; live -> typed status on matching nodes. -->
|
||||
<!-- @UX_REACTIVITY steps and runner plan props -> derived graph layout. -->
|
||||
<!-- @INVARIANT Only declared dependencies produce edges; layout never invents a relationship. -->
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import type { ScenarioStepRun } from "$lib/types/scenario-run";
|
||||
let { steps = [] }: { steps?: ScenarioStepRun[] } = $props();
|
||||
|
||||
let { steps = [], plan = {} }: { steps?: ScenarioStepRun[]; plan?: Record<string, unknown> } = $props();
|
||||
type Node = { id: string; step?: ScenarioStepRun; level: number; row: number; parents: string[] };
|
||||
type Edge = { source: string; target: string };
|
||||
const dt = $derived($t.dashboard_testing ?? {});
|
||||
const started = $derived(steps.filter((step) => step.status !== "queued").length);
|
||||
const graph = $derived.by(() => {
|
||||
const planSteps = Array.isArray(plan.steps) ? plan.steps : [];
|
||||
const ordered = Array.isArray(plan.topological_order) ? plan.topological_order.filter((id): id is string => typeof id === "string") : [];
|
||||
const ids = [...new Set([...ordered, ...planSteps.map((item) => (item && typeof item === "object" ? String((item as Record<string, unknown>).logical_step_id ?? "") : "")).filter(Boolean), ...steps.map((step) => step.logical_step_id)])];
|
||||
const rawEdges = Array.isArray(plan.dependencies) ? plan.dependencies : [];
|
||||
const edges: Edge[] = rawEdges.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const edge = item as Record<string, unknown>;
|
||||
return typeof edge.source === "string" && typeof edge.target === "string" && ids.includes(edge.source) && ids.includes(edge.target)
|
||||
? [{ source: edge.source, target: edge.target }] : [];
|
||||
});
|
||||
const placed: Node[] = [];
|
||||
const remaining = new Set(ids);
|
||||
while (remaining.size) {
|
||||
const ready = ids.filter((id) => remaining.has(id) && edges.filter((edge) => edge.target === id).every((edge) => !remaining.has(edge.source)));
|
||||
const batch = ready.length ? ready : [ids.find((id) => remaining.has(id))!];
|
||||
for (const id of batch) {
|
||||
const parents = edges.filter((edge) => edge.target === id).map((edge) => edge.source);
|
||||
const level = Math.max(0, ...parents.map((parent) => (placed.find((node) => node.id === parent)?.level ?? -1) + 1));
|
||||
const row = placed.filter((node) => node.level === level).length;
|
||||
placed.push({ id, step: steps.find((step) => step.logical_step_id === id), level, row, parents });
|
||||
remaining.delete(id);
|
||||
}
|
||||
}
|
||||
return { nodes: placed, edges, width: (Math.max(0, ...placed.map((node) => node.level)) + 1) * 240, height: Math.max(1, ...placed.map((node) => node.row + 1)) * 130 };
|
||||
});
|
||||
</script>
|
||||
<section class="rounded-lg border border-border bg-surface-card p-4" aria-label={dt.timeline_aria}>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-medium text-text">{dt.timeline_title}</h2>
|
||||
<span class="rounded-full bg-primary-light px-2 py-0.5 text-xs text-primary">{dt.runs_kind_badge}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-text-muted">{dt.timeline_note}</p>
|
||||
{#if steps.length === 0}
|
||||
{#if graph.nodes.length === 0}
|
||||
<p class="mt-3 text-sm text-text-muted">{dt.timeline_empty}</p>
|
||||
{:else}
|
||||
<ol class="mt-3 space-y-2">
|
||||
{#each steps as step (step.id)}
|
||||
<li class="flex items-center gap-3 rounded border border-border bg-surface p-3">
|
||||
<span class="h-2.5 w-2.5 shrink-0 rounded-full {step.status === 'passed' ? 'bg-success' : step.status === 'failed' ? 'bg-destructive' : step.status === 'waiting_human' ? 'bg-warning' : 'bg-warning'}"></span>
|
||||
<span class="min-w-0 flex-1 text-sm text-text">{step.logical_step_id}</span>
|
||||
{#if step.attempt > 1}<span class="text-xs text-text-muted">attempt {step.attempt}</span>{/if}
|
||||
<span class="text-xs text-text-muted">{step.status} · {step.progress}%</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<p class="mt-2 text-xs text-text-muted">{started} {dt.timeline_of} {steps.length} {dt.timeline_steps_started}</p>
|
||||
<div class="mt-4 overflow-x-auto" role="group" aria-label={dt.timeline_graph_aria}>
|
||||
<div class="relative" style={`width:${graph.width}px;height:${graph.height}px`}>
|
||||
<svg class="pointer-events-none absolute inset-0" width={graph.width} height={graph.height} aria-hidden="true">
|
||||
<defs><marker id="run-dag-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 Z" fill="currentColor" class="text-primary" /></marker></defs>
|
||||
{#each graph.edges as edge (`${edge.source}-${edge.target}`)}
|
||||
{@const source = graph.nodes.find((node) => node.id === edge.source)}
|
||||
{@const target = graph.nodes.find((node) => node.id === edge.target)}
|
||||
{#if source && target}
|
||||
<path d={`M ${source.level * 240 + 200} ${source.row * 130 + 47} L ${target.level * 240 + 8} ${target.row * 130 + 47}`} stroke="currentColor" stroke-width="2" marker-end="url(#run-dag-arrow)" class="text-primary" fill="none" />
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
{#each graph.nodes as node (node.id)}
|
||||
<div class="absolute w-[184px] rounded border bg-surface p-3 text-sm text-text shadow-sm {node.step?.status === 'passed' ? 'border-success' : node.step?.status === 'failed' ? 'border-destructive' : node.step?.status === 'blocked' || node.step?.status === 'inconclusive' || node.step?.status === 'waiting_human' ? 'border-warning' : 'border-border'}" style={`left:${node.level * 240 + 16}px;top:${node.row * 130 + 8}px`} role="group" aria-label={`${node.id}: ${node.step?.status ?? "queued"}`}>
|
||||
<p class="font-medium break-words">{node.id}</p>
|
||||
<p class="mt-1 text-xs text-text-muted">{node.step?.status ?? dt.timeline_planned} · {node.step?.progress ?? 0}%</p>
|
||||
{#if node.step && node.step.attempt > 1}<p class="text-xs text-text-muted">{dt.timeline_attempt} {node.step.attempt}</p>{/if}
|
||||
{#if node.parents.length}<p class="mt-1 text-xs text-text-muted">{dt.timeline_after}: {node.parents.join(", ")}</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-text-muted">{started} {dt.timeline_of} {graph.nodes.length} {dt.timeline_steps_started}</p>
|
||||
{/if}
|
||||
</section>
|
||||
<!-- #endregion ScenarioRunMonitor.Component.RunTimeline -->
|
||||
<!-- #endregion ScenarioRunMonitor.Component.RunTimeline -->
|
||||
|
||||
@@ -9,18 +9,34 @@
|
||||
import type { EvaluationRecord, ScenarioExecutionResult, ScenarioStepRun } from "$lib/types/scenario-run";
|
||||
import AgentEvaluationCard from "./AgentEvaluationCard.svelte";
|
||||
import EvidenceViewer from "./EvidenceViewer.svelte";
|
||||
import { metricDifferences } from "./result-evidence";
|
||||
|
||||
let {
|
||||
result,
|
||||
runId = "",
|
||||
steps = [],
|
||||
plan = {},
|
||||
}: {
|
||||
result: ScenarioExecutionResult;
|
||||
runId?: string;
|
||||
steps?: ScenarioStepRun[];
|
||||
plan?: Record<string, unknown>;
|
||||
} = $props();
|
||||
|
||||
const dt = $derived($t.dashboard_testing ?? {});
|
||||
const differences = $derived(metricDifferences(steps));
|
||||
const hasPinnedBaselines = $derived(Boolean(plan.pinned_baselines && typeof plan.pinned_baselines === "object" && Object.keys(plan.pinned_baselines).length));
|
||||
const failedComparisons = $derived(steps.filter((step) => {
|
||||
const outer = step.step_outcome as Record<string, unknown> | undefined;
|
||||
const inner = outer?.step_outcome as Record<string, unknown> | undefined;
|
||||
return (inner ?? outer)?.comparison_status === "fail";
|
||||
}).length);
|
||||
|
||||
function displayValue(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function evaluationOf(step: ScenarioStepRun): EvaluationRecord | null {
|
||||
const outputs = step.outputs as Record<string, unknown> | undefined;
|
||||
@@ -35,17 +51,36 @@
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<strong>{result.status}</strong>
|
||||
<span class="text-xs text-text-muted">{dt.result_note}</span>
|
||||
</div>
|
||||
<dl class="mt-4 grid grid-cols-4 gap-3 text-center">
|
||||
<div><dt class="text-xs text-text-muted">Passed</dt><dd>{result.passed}</dd></div>
|
||||
<div><dt class="text-xs text-text-muted">Failed</dt><dd>{result.failed}</dd></div>
|
||||
<div><dt class="text-xs text-text-muted">Blocked</dt><dd>{result.blocked}</dd></div>
|
||||
<div><dt class="text-xs text-text-muted">Inconclusive</dt><dd>{result.inconclusive}</dd></div>
|
||||
<div><dt class="text-xs text-text-muted">{dt.result_passed}</dt><dd>{result.passed}</dd></div>
|
||||
<div><dt class="text-xs text-text-muted">{dt.result_failed}</dt><dd>{result.failed}</dd></div>
|
||||
<div><dt class="text-xs text-text-muted">{dt.result_blocked}</dt><dd>{result.blocked}</dd></div>
|
||||
<div><dt class="text-xs text-text-muted">{dt.result_inconclusive}</dt><dd>{result.inconclusive}</dd></div>
|
||||
</dl>
|
||||
<section class="mt-4 rounded border border-border bg-surface p-3" aria-label={dt.result_metric_title}>
|
||||
<h3 class="font-medium text-text">{dt.result_metric_title}</h3>
|
||||
{#if !hasPinnedBaselines}<p class="mt-1 text-sm text-warning" role="status">{dt.result_no_baseline_pin}</p>{/if}
|
||||
{#if failedComparisons > 0}
|
||||
<p class="mt-1 text-sm text-destructive" role="status">{dt.result_metric_mismatch}: {failedComparisons}</p>
|
||||
{#if differences.length}
|
||||
<ul class="mt-2 space-y-2">
|
||||
{#each differences as difference, index (`${difference.stepId}-${index}`)}
|
||||
<li class="rounded border border-destructive/30 p-2 text-sm">
|
||||
<strong>{difference.field}</strong> · {difference.stepId}<br />
|
||||
{dt.result_expected}: {displayValue(difference.expected)} · {dt.result_actual}: {displayValue(difference.actual)}
|
||||
{#if difference.delta !== null} · {dt.result_delta}: {difference.delta}{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="mt-1 text-sm text-text-muted">{dt.result_metric_detail_missing}</p>{/if}
|
||||
{:else if result.status === "inconclusive" || result.status === "failed" || result.status === "blocked"}
|
||||
<p class="mt-1 text-sm text-text-muted" role="status">{dt.result_metric_unproven}</p>
|
||||
{:else}<p class="mt-1 text-sm text-text-muted">{dt.result_metric_no_mismatch}</p>{/if}
|
||||
</section>
|
||||
{#if result.failures && result.failures.length > 0}
|
||||
<details class="mt-4" aria-label={dt.result_failed_aria}>
|
||||
<summary class="cursor-pointer text-sm text-primary">{dt.result_failed_summary} ({result.failures.length})</summary>
|
||||
<section class="mt-4" aria-label={dt.result_failed_aria}>
|
||||
<h3 class="text-sm font-medium text-text">{dt.result_failed_summary} ({result.failures.length})</h3>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
{#each result.failures as failure (failure.logical_step_id)}
|
||||
<li class="rounded border border-border p-2">
|
||||
@@ -54,7 +89,7 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if steps.length > 0}
|
||||
@@ -65,7 +100,7 @@
|
||||
{#if evaluation}
|
||||
<div class="mt-2 space-y-2">
|
||||
<AgentEvaluationCard {evaluation} stepStatus={step.status} />
|
||||
{#each evaluation.input_manifest ?? [] as item (item.artifact_id)}
|
||||
{#each [...new Map((evaluation.input_manifest ?? []).map((item) => [item.artifact_id, item])).values()] as item (item.artifact_id)}
|
||||
{#if runId}
|
||||
<EvidenceViewer
|
||||
{runId}
|
||||
@@ -80,11 +115,14 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else if step.artifact_refs && step.artifact_refs.length > 0}
|
||||
<ul class="mt-2 space-y-1 text-xs text-text-muted" aria-label={`${dt.result_evidence_title} ${step.logical_step_id}`}>
|
||||
{#each step.artifact_refs as ref (ref)}
|
||||
<li>{step.logical_step_id} · <span class="font-mono">{ref}</span> · {dt.result_evidence_unavailable}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<details class="mt-2 text-sm text-text-muted">
|
||||
<summary class="cursor-pointer">{step.logical_step_id}: {dt.result_evidence_refs_details} ({new Set(step.artifact_refs).size})</summary>
|
||||
<ul class="mt-2 space-y-1 text-xs" aria-label={`${dt.result_evidence_title} ${step.logical_step_id}`}>
|
||||
{#each [...new Set(step.artifact_refs)] as ref (ref)}
|
||||
<li><span class="font-mono break-all">{ref}</span> · {dt.result_evidence_unavailable}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
@@ -7,4 +7,31 @@ import ScenarioResultView from "../ScenarioResultView.svelte";
|
||||
it("submits explicit human disposition", async () => { const ondispose = vi.fn(); render(HumanCheckpointPanel, { props: { ondispose } }); await fireEvent.click(screen.getByRole("button", { name: "Проблема не подтверждена" })); expect(ondispose).toHaveBeenCalledWith("false_positive", ""); });
|
||||
it("names the passing choice by its persisted outcome and never styles it destructive (DISP-001)", async () => { const ondispose = vi.fn(); render(HumanCheckpointPanel, { props: { ondispose } }); const confirm = screen.getByRole("button", { name: "Подтвердить соответствие" }); expect(confirm.className).toContain("bg-primary"); expect(confirm.className).not.toContain("bg-destructive"); await fireEvent.click(confirm); expect(ondispose).toHaveBeenCalledWith("confirm", ""); });
|
||||
it("renders result counts and provenance", () => { render(ScenarioResultView, { props: { result: { run_id: "r", status: "failed", passed: 1, failed: 1, blocked: 0, inconclusive: 0, provenance: { revision: "v1" } } } }); expect(screen.getByText("failed")).toBeTruthy(); expect(screen.getByText("Provenance")).toBeTruthy(); });
|
||||
it("makes one persisted metric mismatch visible with expected and actual values", () => {
|
||||
render(ScenarioResultView, { props: {
|
||||
result: { run_id: "r", status: "failed", passed: 0, failed: 1, blocked: 0, inconclusive: 0, provenance: {} },
|
||||
steps: [{ id: "step-1", logical_step_id: "compare-revenue", step_position: 1, attempt: 1, status: "failed", progress: 100, inputs_snapshot: {}, outputs: {}, artifact_refs: [], step_outcome: { step_outcome: { comparison_status: "fail", diff: [{ field: "Revenue", expected: "120", actual: "100", delta: "-20" }] } } }],
|
||||
} });
|
||||
expect(screen.getByText(/Подтверждённые расхождения|Confirmed mismatches/)).toBeTruthy();
|
||||
expect(screen.getByText(/Revenue/)).toBeTruthy();
|
||||
expect(screen.getByText(/120/)).toBeTruthy();
|
||||
expect(screen.getByText(/100/)).toBeTruthy();
|
||||
});
|
||||
it("does not call an inconclusive run a metric mismatch without comparison evidence", () => {
|
||||
render(ScenarioResultView, { props: {
|
||||
result: { run_id: "r", status: "inconclusive", passed: 1, failed: 0, blocked: 0, inconclusive: 1, provenance: {} },
|
||||
steps: [{ id: "step-1", logical_step_id: "compare-revenue", step_position: 1, attempt: 1, status: "inconclusive", progress: 100, inputs_snapshot: {}, outputs: {}, artifact_refs: [], step_outcome: { reason_codes: ["LOW_CONFIDENCE"] } }],
|
||||
} });
|
||||
expect(screen.getByText(/Расхождение метрик не доказано|Metric mismatch is unproven/)).toBeTruthy();
|
||||
expect(screen.queryByText(/Подтверждённые расхождения|Confirmed mismatches/)).toBeNull();
|
||||
});
|
||||
it("renders a terminal result with repeated artifact refs and explains an unpinned baseline", () => {
|
||||
render(ScenarioResultView, { props: {
|
||||
result: { run_id: "r", status: "inconclusive", passed: 1, failed: 0, blocked: 0, inconclusive: 1, provenance: {} },
|
||||
plan: { pinned_baselines: {} },
|
||||
steps: [{ id: "step-1", logical_step_id: "capture", step_position: 1, attempt: 1, status: "passed", progress: 100, inputs_snapshot: {}, outputs: {}, artifact_refs: ["same-ref", "same-ref"] }],
|
||||
} });
|
||||
expect(screen.getByText(/нет закреплённых метрик baseline|no pinned baseline metrics/)).toBeTruthy();
|
||||
expect(screen.getAllByText("same-ref")).toHaveLength(1);
|
||||
});
|
||||
// #endregion Test.ScenarioRunMonitor.Views
|
||||
|
||||
@@ -31,11 +31,21 @@ describe("RunTimeline", () => {
|
||||
});
|
||||
expect(screen.getByText("step-a")).toBeTruthy();
|
||||
expect(screen.getByText(/passed/)).toBeTruthy();
|
||||
expect(screen.getByText("attempt 2")).toBeTruthy();
|
||||
expect(screen.getByText(/(?:attempt|попытка) 2/)).toBeTruthy();
|
||||
expect(screen.getByText("step-b")).toBeTruthy();
|
||||
expect(screen.getByText("Ход выполнения scenario run")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("draws the declared dependency and keeps its direction available as text", () => {
|
||||
const { container } = render(RunTimeline, { props: {
|
||||
plan: { topological_order: ["capture", "compare"], dependencies: [{ source: "capture", target: "compare" }] },
|
||||
steps: [{ id: "1", logical_step_id: "capture", step_position: 0, attempt: 1, status: "passed", progress: 100, inputs_snapshot: {}, outputs: {}, artifact_refs: [] }],
|
||||
} });
|
||||
expect(container.querySelector("svg path")).toBeTruthy();
|
||||
expect(screen.getByText(/(?:После|After): capture/)).toBeTruthy();
|
||||
expect(screen.getByRole("group", { name: /compare: queued/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a timeline built only from typed SSE events (no prose parsing)", () => {
|
||||
const model = new RunMonitorModel();
|
||||
model.run = { ...baseRun, steps: [] };
|
||||
|
||||
38
frontend/src/lib/components/scenario-run/result-evidence.ts
Normal file
38
frontend/src/lib/components/scenario-run/result-evidence.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// #region ScenarioRunMonitor.ResultEvidence [C:3] [TYPE Module] [SEMANTICS scenario,run,comparison,evidence]
|
||||
// @BRIEF Project persisted assertion comparisons into analyst-facing facts without inferring a mismatch from run status.
|
||||
// @INVARIANT Only an explicit comparison_status=fail establishes a metric mismatch; missing comparison evidence remains unknown.
|
||||
import type { ScenarioStepRun } from "$lib/types/scenario-run";
|
||||
|
||||
export interface MetricDifference {
|
||||
stepId: string;
|
||||
field: string;
|
||||
expected: unknown;
|
||||
actual: unknown;
|
||||
delta: string | null;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
export function metricDifferences(steps: ScenarioStepRun[]): MetricDifference[] {
|
||||
const differences: MetricDifference[] = [];
|
||||
for (const step of steps) {
|
||||
const outcome = record(step.step_outcome);
|
||||
const comparison = record(outcome?.step_outcome) ?? outcome;
|
||||
if (comparison?.comparison_status !== "fail" || !Array.isArray(comparison.diff)) continue;
|
||||
for (const entry of comparison.diff) {
|
||||
const detail = record(entry);
|
||||
if (!detail || !("expected" in detail) || !("actual" in detail)) continue;
|
||||
differences.push({
|
||||
stepId: step.logical_step_id,
|
||||
field: typeof detail.field === "string" && detail.field ? detail.field : step.logical_step_id,
|
||||
expected: detail.expected,
|
||||
actual: detail.actual,
|
||||
delta: typeof detail.delta === "string" ? detail.delta : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return differences;
|
||||
}
|
||||
// #endregion ScenarioRunMonitor.ResultEvidence
|
||||
@@ -5,7 +5,7 @@
|
||||
// @INVARIANT Display-only: resolves from existing run/result fields; never triggers requests or new logic.
|
||||
import type { ScenarioExecutionResult, ScenarioRun } from "$lib/types/scenario-run";
|
||||
|
||||
export type TerminalReasonKind = "browser_action_unsupported" | "live_binding" | "human_step" | "unknown";
|
||||
export type TerminalReasonKind = "browser_action_unsupported" | "live_binding" | "human_step" | "evidence_unavailable" | "low_confidence" | "unknown";
|
||||
|
||||
export interface TerminalReason {
|
||||
kind: TerminalReasonKind;
|
||||
@@ -37,6 +37,20 @@ const TERMINAL_REASON_TABLE: Record<string, Omit<TerminalReason, "code">> = {
|
||||
bodyKey: "terminal_human_step_body",
|
||||
nextKey: "terminal_human_step_next",
|
||||
},
|
||||
BASELINE_EVIDENCE_UNAVAILABLE: {
|
||||
kind: "evidence_unavailable",
|
||||
severity: "warning",
|
||||
titleKey: "terminal_evidence_title",
|
||||
bodyKey: "terminal_evidence_body",
|
||||
nextKey: "terminal_evidence_next",
|
||||
},
|
||||
LOW_CONFIDENCE: {
|
||||
kind: "low_confidence",
|
||||
severity: "warning",
|
||||
titleKey: "terminal_confidence_title",
|
||||
bodyKey: "terminal_confidence_body",
|
||||
nextKey: "terminal_confidence_next",
|
||||
},
|
||||
};
|
||||
|
||||
/** Live-binding family (live_binding.py / live_composition.py): <TOOL>_BINDING_(MISSING|UNAVAILABLE|INVALID|MISMATCH). */
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"registry_prev": "Previous",
|
||||
"registry_next": "Next",
|
||||
"runs_title": "Scenario runs",
|
||||
"runs_subtitle": "Global Run Operations Center · scenario runs (not 037 verification runs, not 040 load tests).",
|
||||
"runs_subtitle": "Scenario runs and their results.",
|
||||
"runs_back_scenarios": "← Scenarios",
|
||||
"runs_automation": "Automation",
|
||||
"runs_waiting_me": "Waiting for my decision",
|
||||
@@ -240,7 +240,7 @@
|
||||
"monitor_title": "Scenario run monitor",
|
||||
"monitor_back": "← Scenario runs",
|
||||
"monitor_run_prefix": "Run",
|
||||
"monitor_scenario_run": "Scenario run (not 037 verification run, not 040 load test)",
|
||||
"monitor_scenario_run": "Scenario run",
|
||||
"monitor_recovering": "Recovering the run by run id…",
|
||||
"monitor_error": "Failed to load the run",
|
||||
"monitor_cancel": "Cancel run",
|
||||
@@ -257,6 +257,12 @@
|
||||
"terminal_human_step_next": "Launch the scenario manually and complete the analyst checkpoint.",
|
||||
"terminal_unknown_title": "Terminal state without a synthesized result",
|
||||
"terminal_unknown_body": "Typed non-pass — no result synthesized. Code:",
|
||||
"terminal_evidence_title": "Baseline evidence is unavailable",
|
||||
"terminal_evidence_body": "The expected value could not be retrieved. A metric mismatch has not been established.",
|
||||
"terminal_evidence_next": "Check the published baseline and retry with comparison evidence available.",
|
||||
"terminal_confidence_title": "Insufficient confidence to conclude",
|
||||
"terminal_confidence_body": "The evaluation confirms neither a match nor a metric mismatch. The run is inconclusive.",
|
||||
"terminal_confidence_next": "Review the step evidence and retry when sufficient evidence is available.",
|
||||
"automation_aria": "Scenario automation",
|
||||
"automation_title": "Scenario automation",
|
||||
"automation_subtitle": "Schedule/trigger management for scenario runs (not the 009 cron scheduler, not 037 verification runs).",
|
||||
@@ -329,7 +335,10 @@
|
||||
"scenario_detail_error": "Failed to load the scenario",
|
||||
"timeline_aria": "Scenario run progress",
|
||||
"timeline_title": "Scenario run progress",
|
||||
"timeline_note": "Not to be confused with verification runs (037) and load tests (040).",
|
||||
"timeline_graph_aria": "Step dependency graph",
|
||||
"timeline_planned": "queued",
|
||||
"timeline_attempt": "attempt",
|
||||
"timeline_after": "After",
|
||||
"timeline_empty": "No steps received yet.",
|
||||
"timeline_of": "of",
|
||||
"timeline_steps_started": "steps started",
|
||||
@@ -389,9 +398,22 @@
|
||||
"history_empty": "No scenario runs.",
|
||||
"result_aria": "Scenario run result",
|
||||
"result_title": "Scenario run result",
|
||||
"result_note": "Not a verification run (037), not a load test (040)",
|
||||
"result_failed_aria": "Failed steps",
|
||||
"result_failed_summary": "Failed steps",
|
||||
"result_passed": "Passed",
|
||||
"result_failed": "Failed",
|
||||
"result_blocked": "Blocked",
|
||||
"result_inconclusive": "Inconclusive",
|
||||
"result_metric_title": "Baseline metric comparison",
|
||||
"result_metric_mismatch": "Confirmed mismatches in steps",
|
||||
"result_metric_detail_missing": "The comparison failed, but expected and actual values were not saved.",
|
||||
"result_metric_unproven": "A metric mismatch has not been established: comparison evidence is absent or inconclusive.",
|
||||
"result_no_baseline_pin": "This run has no pinned baseline metrics. A metric value deviation cannot be determined.",
|
||||
"result_evidence_refs_details": "Unavailable technical evidence references",
|
||||
"result_metric_no_mismatch": "No confirmed mismatch in the saved comparison data.",
|
||||
"result_expected": "Expected",
|
||||
"result_actual": "Actual",
|
||||
"result_delta": "Difference",
|
||||
"result_failed_aria": "Steps without a passing conclusion",
|
||||
"result_failed_summary": "Steps without a passing conclusion",
|
||||
"run_config_aria": "Scenario run configuration",
|
||||
"run_config_title": "Scenario run configuration",
|
||||
"run_config_subtitle": "Scenario run — distinct from a verification run (release check) and a load test.",
|
||||
@@ -433,6 +455,10 @@
|
||||
"assert_error_baseline": "Select an approved baseline.",
|
||||
"dag_aria": "Step dependencies",
|
||||
"dag_title": "Step dependencies",
|
||||
"dag_graph_aria": "Scenario step dependency graph",
|
||||
"dag_edges_aria": "Dependency directions",
|
||||
"dag_step_label": "Step",
|
||||
"dag_error_unknown": "Choose step IDs shown in the graph.",
|
||||
"dag_source_aria": "Source step",
|
||||
"dag_target_aria": "Target step",
|
||||
"dag_add": "Add",
|
||||
@@ -491,5 +517,116 @@
|
||||
"baseline_title": "Baseline references",
|
||||
"baseline_empty": "No baseline references in this revision.",
|
||||
"baseline_hint": "Read-only review. Approving or rebasing a baseline is a separate human action.",
|
||||
"baseline_step": "Step"
|
||||
"baseline_step": "Step",
|
||||
"baseline_review_title": "Choose a baseline",
|
||||
"baseline_review_reload": "Reload",
|
||||
"baseline_review_loading": "Loading candidates…",
|
||||
"baseline_review_error": "Could not load baseline candidates.",
|
||||
"baseline_review_recovery": "Reload to review the current state.",
|
||||
"baseline_review_missing": "There are no new candidates to review. Published values are shown above; capture a candidate from a scenario run to make a new decision.",
|
||||
"baseline_overview_title": "Approved values",
|
||||
"baseline_overview_no_release": "This dashboard has no approved release. Approve a release, then capture a baseline candidate.",
|
||||
"baseline_overview_missing": "An approved release exists, but no baseline has been published for it.",
|
||||
"baseline_overview_ready": "Published values are available for this release.",
|
||||
"baseline_overview_stale": "Some published values belong to another release and need review.",
|
||||
"baseline_overview_entry_stale": "This value belongs to another release",
|
||||
"baseline_overview_more": "More entries",
|
||||
"baseline_review_status": "Status",
|
||||
"baseline_review_state_draft": "Awaiting review",
|
||||
"baseline_review_state_pending": "Awaiting review",
|
||||
"baseline_review_state_confirmed": "Decision recorded",
|
||||
"baseline_review_state_approved": "Decision recorded",
|
||||
"baseline_review_state_consumed": "Published",
|
||||
"baseline_review_state_denied": "Rejected",
|
||||
"baseline_review_state_unknown": "Status needs review",
|
||||
"baseline_review_action_draft": "Check the source, value and comparison rule, then enter a reason for your decision.",
|
||||
"baseline_review_action_pending": "Check the source, value and comparison rule, then enter a reason for your decision.",
|
||||
"baseline_review_action_confirmed": "The decision is recorded, but publication is incomplete. Check the current state and retry publication.",
|
||||
"baseline_review_action_approved": "The decision is recorded, but publication is incomplete. Check the current state and retry publication.",
|
||||
"baseline_review_action_consumed": "The baseline is published in the catalog.",
|
||||
"baseline_review_action_denied": "The candidate was rejected. The reason is stored with the decision.",
|
||||
"baseline_review_action_unknown": "Reload the list before taking another action.",
|
||||
"baseline_review_decision_failed": "The decision did not finish.",
|
||||
"baseline_review_value_type": "Value type",
|
||||
"baseline_review_candidate_type": "Candidate type",
|
||||
"baseline_review_source_evidence": "Captured source data",
|
||||
"baseline_review_source_open": "View source",
|
||||
"baseline_review_source_loading": "Loading source data…",
|
||||
"baseline_review_source_verified": "Source loaded; checksum matches.",
|
||||
"baseline_review_source_unavailable": "Source data is unavailable. Reload or request a new capture before deciding.",
|
||||
"baseline_review_source_corrupt": "Source checksum does not match. Do not use this capture for a decision.",
|
||||
"baseline_review_verify_before_approve": "View and verify the captured source before approving this value.",
|
||||
"baseline_review_catalog_missing": "No approved catalog entry at this coordinate",
|
||||
"baseline_review_catalog_approved": "Approved catalog entry matches this capture",
|
||||
"baseline_review_catalog_stale": "Catalog has a different capture or release",
|
||||
"baseline_review_source": "Source",
|
||||
"baseline_review_coordinate": "Result",
|
||||
"baseline_review_formula": "Formula",
|
||||
"baseline_review_value": "Captured value",
|
||||
"baseline_review_policy": "Comparison policy",
|
||||
"baseline_review_exact": "Exact match",
|
||||
"baseline_review_absolute": "Absolute tolerance",
|
||||
"baseline_review_relative": "Relative tolerance",
|
||||
"baseline_review_range": "Allowed range",
|
||||
"baseline_review_current": "Currently approved",
|
||||
"baseline_review_delta": "Change from approved",
|
||||
"baseline_review_evidence": "Technical evidence",
|
||||
"baseline_review_release": "Release",
|
||||
"baseline_review_unbound": "No authoritative release",
|
||||
"baseline_review_unbound_help": "This candidate has no linked release; capture it against a release to enable approval.",
|
||||
"baseline_review_reason": "Review reason",
|
||||
"baseline_review_approve": "Approve and publish",
|
||||
"baseline_review_reject": "Reject",
|
||||
"baseline_review_publish": "Retry publication",
|
||||
"reference_title": "Explore a Superset reference dashboard",
|
||||
"reference_intro": "Paste a URL for this scenario's dashboard to inspect its filters and metric values.",
|
||||
"reference_url_label": "Reference dashboard URL",
|
||||
"reference_scenario_context": "Scenario dashboard and environments",
|
||||
"reference_scenario_dashboard": "Scenario dashboard",
|
||||
"reference_expected_environment": "This scenario accepts reference URLs from these environments:",
|
||||
"reference_open_dashboard": "Open dashboard in Superset to choose filters",
|
||||
"reference_environment_unavailable": "Dashboard link unavailable for this environment",
|
||||
"reference_no_bound_environment": "No environment is bound to this scenario.",
|
||||
"reference_environment_mismatch": "This URL belongs to another environment. Open the scenario dashboard in an environment listed above and copy its filtered URL.",
|
||||
"reference_url_placeholder": "https://superset.example/superset/dashboard/123/",
|
||||
"reference_url_required": "Enter a reference dashboard URL.",
|
||||
"reference_dashboard_mismatch": "This URL points to a different dashboard than the scenario.",
|
||||
"reference_discovery_error": "Could not inspect this reference dashboard.",
|
||||
"reference_preview": "Preview reference",
|
||||
"reference_loading": "Inspecting reference dashboard…",
|
||||
"reference_retry": "Retry",
|
||||
"reference_not_approved": "Preview only. These values are not approved baselines.",
|
||||
"reference_local_selection": "Selections are local to this preview and are not saved.",
|
||||
"reference_filters": "Native filters",
|
||||
"reference_datasets": "Datasets",
|
||||
"reference_no_filters": "No native filters are active in this URL.",
|
||||
"reference_coordinates": "Datasets, charts and metrics",
|
||||
"reference_no_coordinates": "No executable metric coordinates were found.",
|
||||
"reference_metric_on_chart": "Metric on chart",
|
||||
"reference_dataset": "Dataset",
|
||||
"reference_applied_filters": "Applied filters",
|
||||
"reference_technical_details": "Technical details",
|
||||
"reference_none": "none",
|
||||
"reference_execution_error": "Execution error",
|
||||
"reference_skipped_budget": "Value not queried because the preview reached its limit.",
|
||||
"reference_no_value": "The query returned no metric value.",
|
||||
"reference_value": "Value",
|
||||
"reference_raw_value": "Exact source value",
|
||||
"reference_recommendation": "Recommendation basis",
|
||||
"reference_no_recommendation": "No recommendation basis was provided.",
|
||||
"reference_reason_big_number": "Big-number aggregate without grouping",
|
||||
"reference_reason_representative": "First chart metric for this dataset",
|
||||
"reference_reason_distinct_metric": "First appearance of this metric on the dataset",
|
||||
"reference_not_recommended": "This coordinate was not recommended.",
|
||||
"reference_select_local": "Select for next step",
|
||||
"reference_truncated": "The preview reached its query limit; some metrics are not shown.",
|
||||
"reference_selected": "Selected coordinates",
|
||||
"reference_capture_action": "Create candidates for review",
|
||||
"reference_capture_pending": "Creating candidates…",
|
||||
"reference_capture_success": "Draft candidates created",
|
||||
"reference_capture_review": "Review them below before any approval.",
|
||||
"reference_capture_error": "Could not create candidates.",
|
||||
"reference_capture_release_missing": "An approved release is required before capturing candidates.",
|
||||
"reference_capture_drift": "Reference data changed since this preview. Refresh the preview before retrying.",
|
||||
"reference_capture_retry": "Check the preview and retry."
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"registry_prev": "Назад",
|
||||
"registry_next": "Вперед",
|
||||
"runs_title": "Запуски сценариев",
|
||||
"runs_subtitle": "Global Run Operations Center · Scenario runs (не Verification run 037 и не Load test 040).",
|
||||
"runs_subtitle": "Запуски сценариев и их результаты.",
|
||||
"runs_back_scenarios": "← Сценарии",
|
||||
"runs_automation": "Автоматизация",
|
||||
"runs_waiting_me": "Ожидают моего решения",
|
||||
@@ -240,7 +240,7 @@
|
||||
"monitor_title": "Мониторинг scenario run",
|
||||
"monitor_back": "← Запуски сценариев",
|
||||
"monitor_run_prefix": "Run",
|
||||
"monitor_scenario_run": "Scenario run (не Verification run 037 и не Load test 040)",
|
||||
"monitor_scenario_run": "Запуск сценария",
|
||||
"monitor_recovering": "Восстановление запуска по run id…",
|
||||
"monitor_error": "Не удалось загрузить запуск",
|
||||
"monitor_cancel": "Отменить запуск",
|
||||
@@ -257,6 +257,12 @@
|
||||
"terminal_human_step_next": "Запустите сценарий вручную и пройдите чекпоинт аналитика.",
|
||||
"terminal_unknown_title": "Терминальное состояние без синтеза результата",
|
||||
"terminal_unknown_body": "Typed non-pass — результат не синтезирован. Код:",
|
||||
"terminal_evidence_title": "Нет данных для проверки baseline",
|
||||
"terminal_evidence_body": "Ожидаемое значение не было получено. Расхождение метрики не доказано.",
|
||||
"terminal_evidence_next": "Проверьте опубликованный baseline и повторите запуск с доступными данными сравнения.",
|
||||
"terminal_confidence_title": "Недостаточно уверенности для вывода",
|
||||
"terminal_confidence_body": "Оценка не подтверждает ни совпадение, ни расхождение метрик. Итог запуска неопределённый.",
|
||||
"terminal_confidence_next": "Изучите доказательства шага и повторите проверку при достаточном качестве данных.",
|
||||
"automation_aria": "Автоматизация сценариев",
|
||||
"automation_title": "Автоматизация сценариев",
|
||||
"automation_subtitle": "Schedule/trigger management для scenario runs (не cron-планировщик 009 и не verification run 037).",
|
||||
@@ -329,7 +335,10 @@
|
||||
"scenario_detail_error": "Не удалось загрузить сценарий",
|
||||
"timeline_aria": "Ход выполнения scenario run",
|
||||
"timeline_title": "Ход выполнения scenario run",
|
||||
"timeline_note": "Не путать с Verification run (037) и Load test (040).",
|
||||
"timeline_graph_aria": "Граф зависимостей шагов",
|
||||
"timeline_planned": "ожидает запуска",
|
||||
"timeline_attempt": "попытка",
|
||||
"timeline_after": "После",
|
||||
"timeline_empty": "Шаги ещё не поступили.",
|
||||
"timeline_of": "из",
|
||||
"timeline_steps_started": "шагов стартовали",
|
||||
@@ -389,9 +398,22 @@
|
||||
"history_empty": "Запусков сценария нет.",
|
||||
"result_aria": "Результат scenario run",
|
||||
"result_title": "Результат scenario run",
|
||||
"result_note": "Не Verification run (037) и не Load test (040)",
|
||||
"result_failed_aria": "Проваленные шаги",
|
||||
"result_failed_summary": "Проваленные шаги",
|
||||
"result_passed": "Успешно",
|
||||
"result_failed": "Ошибки",
|
||||
"result_blocked": "Заблокировано",
|
||||
"result_inconclusive": "Без вывода",
|
||||
"result_metric_title": "Сравнение метрик с baseline",
|
||||
"result_metric_mismatch": "Подтверждённые расхождения в шагах",
|
||||
"result_metric_detail_missing": "Сравнение завершилось с ошибкой, но ожидаемое и фактическое значения не сохранены.",
|
||||
"result_metric_unproven": "Расхождение метрик не доказано: данных сравнения нет или проверка не дала вывода.",
|
||||
"result_no_baseline_pin": "В этом запуске нет закреплённых метрик baseline. Отклонение значений метрик определить нельзя.",
|
||||
"result_evidence_refs_details": "Недоступные технические ссылки на доказательства",
|
||||
"result_metric_no_mismatch": "Подтверждённых расхождений в сохранённых данных сравнения нет.",
|
||||
"result_expected": "Ожидалось",
|
||||
"result_actual": "Получено",
|
||||
"result_delta": "Разница",
|
||||
"result_failed_aria": "Шаги без успешного вывода",
|
||||
"result_failed_summary": "Шаги без успешного вывода",
|
||||
"run_config_aria": "Конфигурация запуска сценария",
|
||||
"run_config_title": "Конфигурация запуска сценария",
|
||||
"run_config_subtitle": "Scenario run — отличается от Verification run (релизная проверка) и Load test (нагрузка).",
|
||||
@@ -433,6 +455,10 @@
|
||||
"assert_error_baseline": "Выберите утверждённый baseline.",
|
||||
"dag_aria": "Зависимости шагов",
|
||||
"dag_title": "Зависимости шагов",
|
||||
"dag_graph_aria": "Граф зависимостей шагов сценария",
|
||||
"dag_edges_aria": "Направления зависимостей",
|
||||
"dag_step_label": "Шаг",
|
||||
"dag_error_unknown": "Выберите идентификаторы шагов из графа.",
|
||||
"dag_source_aria": "Источник шага",
|
||||
"dag_target_aria": "Цель шага",
|
||||
"dag_add": "Добавить",
|
||||
@@ -491,5 +517,116 @@
|
||||
"baseline_title": "Ссылки на baseline",
|
||||
"baseline_empty": "В этой revision нет ссылок на baseline.",
|
||||
"baseline_hint": "Только просмотр. Утверждение или перебазирование baseline — отдельное действие человека.",
|
||||
"baseline_step": "Шаг"
|
||||
"baseline_step": "Шаг",
|
||||
"baseline_review_title": "Выбрать baseline",
|
||||
"baseline_review_reload": "Обновить",
|
||||
"baseline_review_loading": "Загрузка кандидатов…",
|
||||
"baseline_review_error": "Не удалось загрузить кандидатов baseline.",
|
||||
"baseline_review_recovery": "Обновите список, чтобы увидеть актуальное состояние.",
|
||||
"baseline_review_missing": "Новых кандидатов для проверки нет. Состояние опубликованных значений показано выше; для нового решения нужен захват кандидата из запуска сценария.",
|
||||
"baseline_overview_title": "Утверждённые значения",
|
||||
"baseline_overview_no_release": "Для этого дашборда нет утверждённого релиза. Сначала утвердите релиз, затем захватите кандидат baseline.",
|
||||
"baseline_overview_missing": "Утверждённый релиз есть, но baseline для него ещё не опубликован.",
|
||||
"baseline_overview_ready": "Опубликованные значения доступны для этого релиза.",
|
||||
"baseline_overview_stale": "Некоторые опубликованные значения относятся к другому релизу и требуют проверки.",
|
||||
"baseline_overview_entry_stale": "Значение относится к другому релизу",
|
||||
"baseline_overview_more": "Ещё записей",
|
||||
"baseline_review_status": "Статус",
|
||||
"baseline_review_state_draft": "Ожидает решения",
|
||||
"baseline_review_state_pending": "Ожидает решения",
|
||||
"baseline_review_state_confirmed": "Решение записано",
|
||||
"baseline_review_state_approved": "Решение записано",
|
||||
"baseline_review_state_consumed": "Опубликован",
|
||||
"baseline_review_state_denied": "Отклонён",
|
||||
"baseline_review_state_unknown": "Состояние требует проверки",
|
||||
"baseline_review_action_draft": "Сверьте источник, значение и правило сравнения. Затем укажите причину решения.",
|
||||
"baseline_review_action_pending": "Сверьте источник, значение и правило сравнения. Затем укажите причину решения.",
|
||||
"baseline_review_action_confirmed": "Решение записано. Публикация ещё не завершена; повторите её после проверки текущего состояния.",
|
||||
"baseline_review_action_approved": "Решение записано. Публикация ещё не завершена; повторите её после проверки текущего состояния.",
|
||||
"baseline_review_action_consumed": "Baseline опубликован в каталоге.",
|
||||
"baseline_review_action_denied": "Кандидат отклонён. Причина сохранена в истории решения.",
|
||||
"baseline_review_action_unknown": "Обновите список перед следующим действием.",
|
||||
"baseline_review_decision_failed": "Решение не завершено.",
|
||||
"baseline_review_value_type": "Тип значения",
|
||||
"baseline_review_candidate_type": "Тип кандидата",
|
||||
"baseline_review_source_evidence": "Данные исходного захвата",
|
||||
"baseline_review_source_open": "Посмотреть источник",
|
||||
"baseline_review_source_loading": "Загрузка исходных данных…",
|
||||
"baseline_review_source_verified": "Источник загружен; контрольная сумма совпадает.",
|
||||
"baseline_review_source_unavailable": "Исходные данные недоступны. Обновите страницу или запросите повторный захват перед решением.",
|
||||
"baseline_review_source_corrupt": "Контрольная сумма источника не совпадает. Не используйте этот захват для решения.",
|
||||
"baseline_review_verify_before_approve": "Перед утверждением значения откройте источник и проверьте его контрольную сумму.",
|
||||
"baseline_review_catalog_missing": "В каталоге нет утверждённой записи для этой координаты",
|
||||
"baseline_review_catalog_approved": "Утверждённая запись совпадает с захватом",
|
||||
"baseline_review_catalog_stale": "В каталоге другой захват или релиз",
|
||||
"baseline_review_source": "Источник",
|
||||
"baseline_review_coordinate": "Результат",
|
||||
"baseline_review_formula": "Формула",
|
||||
"baseline_review_value": "Захваченное значение",
|
||||
"baseline_review_policy": "Политика сравнения",
|
||||
"baseline_review_exact": "Точное совпадение",
|
||||
"baseline_review_absolute": "Абсолютный допуск",
|
||||
"baseline_review_relative": "Относительный допуск",
|
||||
"baseline_review_range": "Допустимый диапазон",
|
||||
"baseline_review_current": "Сейчас утверждено",
|
||||
"baseline_review_delta": "Изменение от утверждённого",
|
||||
"baseline_review_evidence": "Технические данные",
|
||||
"baseline_review_release": "Релиз",
|
||||
"baseline_review_unbound": "Нет авторитетного релиза",
|
||||
"baseline_review_unbound_help": "Кандидат не связан с релизом; для утверждения захватите его с привязкой к релизу.",
|
||||
"baseline_review_reason": "Причина решения",
|
||||
"baseline_review_approve": "Утвердить и опубликовать",
|
||||
"baseline_review_reject": "Отклонить",
|
||||
"baseline_review_publish": "Повторить публикацию",
|
||||
"reference_title": "Просмотр эталонного дашборда Superset",
|
||||
"reference_intro": "Вставьте URL дашборда этого сценария, чтобы проверить фильтры и значения метрик.",
|
||||
"reference_url_label": "URL эталонного дашборда",
|
||||
"reference_scenario_context": "Дашборд и окружения сценария",
|
||||
"reference_scenario_dashboard": "Дашборд сценария",
|
||||
"reference_expected_environment": "Сценарий принимает эталонные URL из этих окружений:",
|
||||
"reference_open_dashboard": "Открыть дашборд в Superset и выбрать фильтры",
|
||||
"reference_environment_unavailable": "Ссылка на дашборд в этом окружении недоступна",
|
||||
"reference_no_bound_environment": "У сценария нет привязанного окружения.",
|
||||
"reference_environment_mismatch": "URL относится к другому окружению. Откройте дашборд сценария в окружении выше и скопируйте URL с выбранными фильтрами.",
|
||||
"reference_url_placeholder": "https://superset.example/superset/dashboard/123/",
|
||||
"reference_url_required": "Укажите URL эталонного дашборда.",
|
||||
"reference_dashboard_mismatch": "Этот URL ведёт на другой дашборд, не связанный со сценарием.",
|
||||
"reference_discovery_error": "Не удалось проверить эталонный дашборд.",
|
||||
"reference_preview": "Посмотреть эталон",
|
||||
"reference_loading": "Проверка эталонного дашборда…",
|
||||
"reference_retry": "Повторить",
|
||||
"reference_not_approved": "Это только предварительный просмотр. Значения не утверждены как baseline.",
|
||||
"reference_local_selection": "Выбор действует только в этом просмотре и не сохранён.",
|
||||
"reference_filters": "Нативные фильтры",
|
||||
"reference_datasets": "Датасеты",
|
||||
"reference_no_filters": "В этом URL нет активных нативных фильтров.",
|
||||
"reference_coordinates": "Датасеты, чарты и метрики",
|
||||
"reference_no_coordinates": "Не найдены метрики, доступные для выполнения.",
|
||||
"reference_metric_on_chart": "Метрика на чарте",
|
||||
"reference_dataset": "Датасет",
|
||||
"reference_applied_filters": "Применённые фильтры",
|
||||
"reference_technical_details": "Технические детали",
|
||||
"reference_none": "нет",
|
||||
"reference_execution_error": "Ошибка выполнения",
|
||||
"reference_skipped_budget": "Значение не запрошено: достигнут предел предварительного просмотра.",
|
||||
"reference_no_value": "Запрос не вернул значение метрики.",
|
||||
"reference_value": "Значение",
|
||||
"reference_raw_value": "Точное значение источника",
|
||||
"reference_recommendation": "Основание рекомендации",
|
||||
"reference_no_recommendation": "Основание рекомендации не передано.",
|
||||
"reference_reason_big_number": "Агрегат большого числа без группировки",
|
||||
"reference_reason_representative": "Первая метрика чарта для этого датасета",
|
||||
"reference_reason_distinct_metric": "Первое появление этой метрики в датасете",
|
||||
"reference_not_recommended": "Эта координата не рекомендована.",
|
||||
"reference_select_local": "Выбрать для следующего шага",
|
||||
"reference_truncated": "Достигнут предел запросов; часть метрик не показана.",
|
||||
"reference_selected": "Выбрано координат",
|
||||
"reference_capture_action": "Создать кандидатов для проверки",
|
||||
"reference_capture_pending": "Создание кандидатов…",
|
||||
"reference_capture_success": "Созданы черновики кандидатов",
|
||||
"reference_capture_review": "Проверьте их ниже перед утверждением.",
|
||||
"reference_capture_error": "Не удалось создать кандидатов.",
|
||||
"reference_capture_release_missing": "Для создания кандидатов нужен утверждённый релиз.",
|
||||
"reference_capture_drift": "Данные эталона изменились после просмотра. Обновите просмотр перед повторной попыткой.",
|
||||
"reference_capture_retry": "Проверьте просмотр и повторите попытку."
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// @ACTION load, dispose
|
||||
// @INVARIANT Disposition sends the last-known decision_version (CAS); the response bumps it locally.
|
||||
// @INVARIANT Case metadata is orthogonal to immutable RunResult rows — disposing never rewrites run truth.
|
||||
// @RATIONALE Disposition uses postApi because the endpoint is a write; fetchApi always sends GET.
|
||||
// @REJECTED Passing method: POST into fetchApi was rejected after live E2E showed HTTP 405.
|
||||
import { api } from "$lib/api";
|
||||
import type { AgentAction, CaseDetail, DispositionKind, EvidenceRef, InvestigationCase } from "$lib/types/scenario-analytics";
|
||||
|
||||
@@ -42,7 +44,7 @@ export class InvestigationCaseModel {
|
||||
disposition: DispositionKind,
|
||||
rationale = "",
|
||||
verificationEvidence: { content_ref: string; sha256: string } | undefined = undefined,
|
||||
apiFn = api.fetchApi,
|
||||
apiFn = api.postApi,
|
||||
): Promise<void> {
|
||||
if (this.saving || this.loading || this.error) return;
|
||||
this.saving = true;
|
||||
@@ -51,14 +53,11 @@ export class InvestigationCaseModel {
|
||||
const result = await apiFn<InvestigationCase>(
|
||||
`/scenario-analytics/cases/${encodeURIComponent(caseId)}/disposition`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
disposition,
|
||||
expected_version: this.decisionVersion,
|
||||
...(rationale.trim() ? { rationale: rationale.trim() } : {}),
|
||||
...(verificationEvidence ? { verification_evidence: verificationEvidence } : {}),
|
||||
}),
|
||||
} as never,
|
||||
disposition,
|
||||
expected_version: this.decisionVersion,
|
||||
...(rationale.trim() ? { rationale: rationale.trim() } : {}),
|
||||
...(verificationEvidence ? { verification_evidence: verificationEvidence } : {}),
|
||||
},
|
||||
);
|
||||
this.decisionVersion = result.decision_version;
|
||||
this.status = result.status;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user