Files
ss-tools/backend/src/services/validation_run_service.py
busya 31680a1bc9 fix(maintenance): auto-expiry + adaptive markdown height + tests
- Auto-expiry: expired events auto-end on GET /events via task manager
- Height: _estimate_markdown_height adapted to unit=8px scale with padding detection
- CRITICAL-1: removed dead import remove_chart_from_position (QA finding)
- CRITICAL-2: added chart alive check in ensure_banner_chart (QA finding)
- CRITICAL-3: fixed test assertions update_markdown_chart → update_banner_on_dashboard
- @POST contract: fixed return range [2,12] → [19,200]
- Tests: 8 new tests (auto-expiry + layout height)
2026-05-25 08:36:33 +03:00

141 lines
6.0 KiB
Python

# #region ValidationRunService [C:3] [TYPE Module] [SEMANTICS validation, run, service, sqlalchemy]
# @BRIEF Business logic for validation run queries: list, detail, delete.
# @LAYER Service
from datetime import date
from sqlalchemy.orm import Session
from ..core.logger import logger
from ..models.llm import ValidationPolicy, ValidationRecord
from ..schemas.validation import ValidationRunDetailResponse, ValidationRunResponse
# #region ValidationRunService [C:4] [TYPE Class]
# @BRIEF Queries and manages validation run history records.
# @RATIONALE Separates run history queries from task CRUD — run data has different access patterns (filter-heavy, paginated) than task configuration.
# @REJECTED Merging run service into task service rejected — would create a large class with mixed responsibilities and different query patterns.
class ValidationRunService:
def __init__(self, db: Session):
self.db = db
# #region _resolve_task_name [C:2] [TYPE Function]
def _resolve_task_name(self, record: ValidationRecord) -> str | None:
"""Find matching ValidationPolicy name by dashboard + environment."""
if not record.dashboard_id or not record.environment_id:
return None
candidates = (
self.db.query(ValidationPolicy)
.filter(ValidationPolicy.environment_id == record.environment_id)
.all()
)
for policy in candidates:
dids = list(policy.dashboard_ids) if policy.dashboard_ids else []
if record.dashboard_id in dids:
return policy.name
return None
# #endregion _resolve_task_name
# #region list_runs [C:3] [TYPE Function]
# @RATIONALE Supports 6 optional filters + pagination at the query level, enabling flexible cross-task run exploration without application-level filtering.
# @REJECTED Application-level filtering after loading all runs rejected — would be unscalable with large run histories; database-level WHERE clauses are more efficient.
def list_runs(
self,
task_id: str | None = None,
dashboard_id: str | None = None,
environment_id: str | None = None,
status: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[int, list[ValidationRunResponse]]:
query = self.db.query(ValidationRecord)
if task_id:
# Match both spawned task_id (TaskRecord) and policy_id (ValidationPolicy.id)
# because the frontend sends the validation policy UUID, but ValidationRecord.task_id
# stores the spawned task UUID. Fall back to policy_id for the common case.
query = query.filter(
(ValidationRecord.task_id == task_id) | (ValidationRecord.policy_id == task_id)
)
if dashboard_id:
query = query.filter(ValidationRecord.dashboard_id == dashboard_id)
if environment_id:
query = query.filter(ValidationRecord.environment_id == environment_id)
if status:
query = query.filter(ValidationRecord.status == status.upper())
if date_from:
query = query.filter(ValidationRecord.timestamp >= date_from)
if date_to:
query = query.filter(ValidationRecord.timestamp <= date_to)
total = query.count()
records = (
query.order_by(ValidationRecord.timestamp.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
items = [
ValidationRunResponse(
id=r.id,
task_id=r.task_id,
dashboard_id=r.dashboard_id,
environment_id=r.environment_id,
status=r.status,
summary=r.summary or "",
timestamp=r.timestamp,
screenshot_path=r.screenshot_path,
task_name=self._resolve_task_name(r),
)
for r in records
]
return total, items
# #endregion list_runs
# #region get_run_detail [C:3] [TYPE Function]
# @RATIONALE Returns full join including issues list and raw_response for the report UI — single query avoids N+1 when displaying the run detail page.
# @REJECTED Lazy-loading issues and raw_response separately rejected — would cause N+1 queries on the detail page; both fields are always needed for the report view.
def get_run_detail(self, run_id: str) -> ValidationRunDetailResponse:
record = self.db.query(ValidationRecord).filter(ValidationRecord.id == run_id).first()
if not record:
raise ValueError(f"Validation run '{run_id}' not found")
return ValidationRunDetailResponse(
id=record.id,
task_id=record.task_id,
dashboard_id=record.dashboard_id,
environment_id=record.environment_id,
status=record.status,
summary=record.summary or "",
timestamp=record.timestamp,
screenshot_path=record.screenshot_path,
issues=list(record.issues) if record.issues else [],
raw_response=record.raw_response,
task_name=self._resolve_task_name(record),
)
# #endregion get_run_detail
# #region delete_run [C:2] [TYPE Function]
# @RATIONALE Cleans up the database record only — screenshot cleanup is caller responsibility to maintain separation of concerns between DB and filesystem.
# @REJECTED Automatic screenshot file deletion in service layer rejected — would couple DB cleanup to filesystem operations, complicating testing and error handling.
def delete_run(self, run_id: str) -> bool:
record = self.db.query(ValidationRecord).filter(ValidationRecord.id == run_id).first()
if not record:
return False
self.db.delete(record)
self.db.commit()
logger.info("[ValidationRunService] Deleted run '%s'", run_id)
return True
# #endregion delete_run
# #endregion ValidationRunService
# #endregion ValidationRunService