from __future__ import annotations import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.db.base import Base from app.models.agent_asset import AgentAsset, AgentAssetTestRun, AgentAssetVersion from app.services.agent_asset_release_guard import ( AgentAssetReleaseGuardService, ReleaseEvaluationInput, ReleaseGuardPolicy, ) def _session() -> Session: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) return sessionmaker(bind=engine, autoflush=False, autocommit=False)() def _seed_asset(db: Session) -> AgentAsset: asset = AgentAsset( id="asset-release", asset_type="task", code="task.release.guard", name="发布门禁任务", domain="expense", owner="tester", status="active", current_version="v1", working_version="v2", published_version="v1", config_json={"business_config": "preserved"}, ) db.add(asset) db.add_all( [ AgentAssetVersion( asset_id=asset.id, version=version, content="{}", content_type="json", created_by="tester", ) for version in ("v1", "v2") ] ) db.commit() return asset def _policy() -> ReleaseGuardPolicy: return ReleaseGuardPolicy( shadow_min_samples=2, canary_min_samples=3, max_error_rate=0.1, min_precision=0.9, max_precision_drop=0.05, canary_traffic_percent=10, recall_gate_enabled=False, ) def test_release_moves_shadow_canary_active_and_preserves_previous_version() -> None: with _session() as db: asset = _seed_asset(db) service = AgentAssetReleaseGuardService(db) shadow = service.start_shadow(asset.id, "v2", actor="manager", policy=_policy()) assert shadow["stage"] == "shadow" assert shadow["previous_version"] == "v1" assert db.get(AgentAsset, asset.id).config_json["business_config"] == "preserved" assert service.get_serving_plan(asset.id) == { "stage": "shadow", "primary_version": "v1", "candidate_version": "v2", "candidate_traffic_percent": 0, "shadow_evaluation": True, } service.record_evaluation( asset.id, ReleaseEvaluationInput(total=2, failure_count=0, precision=0.99), actor="evaluator", ) canary = service.promote(asset.id, actor="manager") assert canary["stage"] == "canary" assert canary["previous_version"] == "v1" assert service.get_serving_plan(asset.id)["candidate_traffic_percent"] == 10 service.record_evaluation( asset.id, ReleaseEvaluationInput( total=3, failure_count=0, precision=0.98, baseline_precision=0.99, ), actor="evaluator", ) active = service.promote(asset.id, actor="manager") refreshed = db.get(AgentAsset, asset.id) assert active["stage"] == "active" assert active["previous_version"] == "v1" assert refreshed.published_version == "v2" assert service.get_serving_plan(asset.id)["primary_version"] == "v2" def test_release_cannot_promote_while_samples_are_collecting() -> None: with _session() as db: asset = _seed_asset(db) service = AgentAssetReleaseGuardService(db) service.start_shadow(asset.id, "v2", actor="manager", policy=_policy()) result = service.record_evaluation( asset.id, ReleaseEvaluationInput(total=1, failure_count=0, precision=0.99), actor="evaluator", ) assert result["status"] == "collecting" try: service.promote(asset.id, actor="manager") except PermissionError as exc: assert "尚无通过" in str(exc) else: raise AssertionError("collecting 状态不应允许晋级") def test_active_quality_regression_automatically_restores_previous_version() -> None: with _session() as db: asset = _seed_asset(db) service = AgentAssetReleaseGuardService(db) service.start_shadow(asset.id, "v2", actor="manager", policy=_policy()) service.record_evaluation( asset.id, ReleaseEvaluationInput(total=2, failure_count=0, precision=0.99), actor="evaluator", ) service.promote(asset.id, actor="manager") service.record_evaluation( asset.id, ReleaseEvaluationInput(total=3, failure_count=0, precision=0.99), actor="evaluator", ) service.promote(asset.id, actor="manager") result = service.record_evaluation( asset.id, ReleaseEvaluationInput( total=10, failure_count=2, precision=0.8, baseline_precision=0.99, ), actor="monitor", ) refreshed = db.get(AgentAsset, asset.id) assert result["status"] == "failed" assert result["release_stage"] == "rolled_back" assert refreshed.published_version == "v1" assert refreshed.config_json["release_guard"]["previous_version"] == "v1" assert refreshed.config_json["release_guard"]["rollback"]["automatic"] is True assert service.get_serving_plan(asset.id)["primary_version"] == "v1" def test_invalid_or_missing_precision_fails_closed_and_writes_test_run() -> None: with _session() as db: asset = _seed_asset(db) service = AgentAssetReleaseGuardService(db) service.start_shadow(asset.id, "v2", actor="manager", policy=_policy()) result = service.record_evaluation( asset.id, ReleaseEvaluationInput(total=2, failure_count=0, precision=None), actor="evaluator", ) run = db.query(AgentAssetTestRun).filter_by(asset_id=asset.id).one() assert result["status"] == "failed" assert result["release_stage"] == "rolled_back" assert run.passed is False assert "precision_metric_missing" in run.result_json["reasons"] def test_malformed_policy_and_metric_counts_fail_closed() -> None: with _session() as db: asset = _seed_asset(db) service = AgentAssetReleaseGuardService(db) service.start_shadow( asset.id, "v2", actor="manager", policy=ReleaseGuardPolicy( shadow_min_samples="invalid", # type: ignore[arg-type] min_precision="invalid", # type: ignore[arg-type] reviewer_quorum=99, ), ) state = service.get_state(asset.id) assert state["policy"]["shadow_min_samples"] == 20 assert state["policy"]["min_precision"] == 0.98 assert state["policy"]["reviewer_quorum"] == 2 assert ReleaseGuardPolicy(reviewer_quorum=0).to_dict()["reviewer_quorum"] == 1 result = service.record_evaluation( asset.id, ReleaseEvaluationInput( total="invalid", # type: ignore[arg-type] failure_count=0, precision=0.99, ), actor="evaluator", ) assert result["status"] == "failed" assert result["release_stage"] == "rolled_back" assert "invalid_evaluation_metrics" in result["reasons"] def test_restarted_release_cannot_reuse_previous_attempt_quality_gate() -> None: with _session() as db: asset = _seed_asset(db) service = AgentAssetReleaseGuardService(db) first = service.start_shadow(asset.id, "v2", actor="manager", policy=_policy()) service.record_evaluation( asset.id, ReleaseEvaluationInput(total=2, failure_count=0, precision=0.99), actor="evaluator", ) service.rollback(asset.id, actor="manager", reason="restart") second = service.start_shadow(asset.id, "v2", actor="manager", policy=_policy()) assert first["release_id"] != second["release_id"] with pytest.raises(PermissionError, match="尚无通过"): service.promote(asset.id, actor="manager")