from __future__ import annotations from typing import Any, cast import pytest from wf_artifacts import WorkflowArtifact as ArtifactModel from wf_client import DeploymentRequired, WorkflowClientPort from wf_client.errors import DeploymentNotRunnable, InvalidResponse from wf_client.workflows import WorkflowArtifact from wf_core import Workflow def _artifact() -> WorkflowArtifact: plan: dict[str, Any] = { "name": "report", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "outcomes": ["ok"], "start": "end", "nodes": [{"id": "end", "type": "end", "outcome": "ok"}], "edges": [], } artifact = ArtifactModel( id="report", version=1, title="Report", input_schema=plan["input_schema"], output_schema=plan["output_schema"], outcomes=("ok",), plan=plan, ) return WorkflowArtifact( cast(WorkflowClientPort, _FakePort()), artifact, Workflow.model_validate(plan) ) class _FakePort: def __init__(self) -> None: self.calls: list[tuple[str, dict[str, Any]]] = [] self.list_result: dict[str, Any] = {"deployments": []} self.inspect_artifact_id = "report" self.inspect_artifact_version = 1 self.validation_result: dict[str, Any] = { "deployment_id": "report.production", "artifact_id": "report", "artifact_version": 1, "status": "runnable", "diagnostics": [], "next_actions": { "can_continue": True, "can_save_now": None, "recommended_next_tool": None, "reason": "ready", "patch_examples": [], "warnings": [], }, } self.run_result: dict[str, Any] = { "artifact_id": "report", "artifact_version": 1, "deployment_id": "report.production", "status": "completed", "run_id": "run-1", "resume_readiness": "not_applicable", "interrupt": None, "outcome": "ok", "error": None, "output": {"result": "done"}, "trace_count": 0, "diagnostics": [], "next_actions": { "can_continue": False, "can_save_now": None, "recommended_next_tool": None, "reason": "done", "patch_examples": [], "warnings": [], }, } async def save_deployment(self, deployment: dict[str, Any]) -> object: self.calls.append(("save_deployment", {"deployment": deployment})) return {"deployment_id": deployment["id"], "saved": True} async def inspect_deployment(self, *, deployment_id: str) -> object: self.calls.append(("inspect_deployment", {"deployment_id": deployment_id})) return { "id": deployment_id, "artifact_id": self.inspect_artifact_id, "artifact_version": self.inspect_artifact_version, "bindings": [ { "logical_source": "app.default", "concrete_source": "company.production", } ], "drift_policy": "block", } async def validate_deployment(self, **params: Any) -> object: self.calls.append(("validate_deployment", params)) return self.validation_result async def list_deployments(self) -> object: self.calls.append(("list_deployments", {})) return self.list_result async def run_deployment(self, **params: Any) -> object: self.calls.append(("run_deployment", params)) return self.run_result @pytest.mark.asyncio async def test_artifact_deploys_with_explicit_bindings() -> None: artifact = _artifact() port = cast(_FakePort, artifact._port) deployment = await artifact.deploy( "report.production", bindings={"app.default": "company.production"} ) assert deployment.deployment_id == "report.production" assert deployment.bindings == {"app.default": "company.production"} assert deployment.runnable is True assert [call[0] for call in port.calls[-3:]] == [ "save_deployment", "inspect_deployment", "validate_deployment", ] @pytest.mark.asyncio async def test_artifact_run_rejects_ambiguous_deployments() -> None: artifact = _artifact() port = cast(_FakePort, artifact._port) port.list_result = { "deployments": [ { "id": "report.prod", "artifact_id": "report", "artifact_version": 1, "binding_count": 0, "drift_policy": "block", }, { "id": "report.dev", "artifact_id": "report", "artifact_version": 1, "binding_count": 0, "drift_policy": "block", }, ] } with pytest.raises(DeploymentRequired) as captured: await artifact.run({"topic": "workflow"}) assert captured.value.candidate_deployment_ids == ("report.dev", "report.prod") assert not any(call[0] == "run_deployment" for call in port.calls) @pytest.mark.asyncio async def test_deployment_run_rejects_missing_run_id() -> None: artifact = _artifact() deployment = await artifact.deploy("report.production") cast(_FakePort, deployment._port).run_result["run_id"] = None with pytest.raises(DeploymentNotRunnable) as captured: await deployment.run({}) assert captured.value.error is None @pytest.mark.asyncio async def test_explicit_artifact_run_rejects_deployment_for_another_artifact() -> None: artifact = _artifact() port = cast(_FakePort, artifact._port) port.inspect_artifact_id = "other" with pytest.raises(InvalidResponse, match="does not target artifact"): await artifact.run({}, deployment_id="report.production") assert not any(call[0] == "run_deployment" for call in port.calls) @pytest.mark.asyncio async def test_deployment_validation_rejects_identity_mismatch() -> None: artifact = _artifact() deployment = await artifact.deploy("report.production") port = cast(_FakePort, deployment._port) port.validation_result["artifact_version"] = 2 with pytest.raises(InvalidResponse, match="workflow.deployments.validate"): await deployment.validate() @pytest.mark.asyncio async def test_deployment_run_rejects_mismatched_start_deployment() -> None: artifact = _artifact() deployment = await artifact.deploy("report.production") port = cast(_FakePort, deployment._port) port.run_result["deployment_id"] = "other.deployment" with pytest.raises(InvalidResponse, match="workflow.runs.start"): await deployment.run({}) @pytest.mark.asyncio async def test_deployment_run_preserves_server_error_and_diagnostics() -> None: artifact = _artifact() deployment = await artifact.deploy("report.production") port = cast(_FakePort, deployment._port) port.run_result.update( run_id=None, outcome="rejected", error="dependency check failed", diagnostics=[ { "severity": "error", "code": "missing_source", "logical_ref": "app.default", "bound_source": None, "message": "missing source", "repair_hint": "bind a source", } ], ) with pytest.raises(DeploymentNotRunnable) as captured: await deployment.run({}) assert captured.value.error == "dependency check failed" assert captured.value.outcome == "rejected" assert captured.value.diagnostics[0].code == "missing_source"