- 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)
141 lines
6.0 KiB
Python
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
|