feat: add validated workflow client boundary
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for the transport-independent workflow client boundary."""
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class FakeWorkflowClient:
|
||||
"""Small configurable adapter used to test rich clients without HTTP."""
|
||||
|
||||
def __init__(self, **responses: object) -> None:
|
||||
self.responses = responses
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def response(self, method: str) -> object:
|
||||
return self.responses[method]
|
||||
|
||||
def _response(self, method: str, params: dict[str, Any]) -> object:
|
||||
self.calls.append((method, params))
|
||||
return self.response(method)
|
||||
|
||||
async def list_capabilities(self, **params: Any) -> object:
|
||||
return self._response("workflow.capabilities.list", params)
|
||||
|
||||
async def inspect_capability(self, **params: Any) -> object:
|
||||
return self._response("workflow.capabilities.inspect", params)
|
||||
|
||||
async def call_capability(self, **params: Any) -> object:
|
||||
return self._response("workflow.capabilities.call", params)
|
||||
|
||||
async def inspect_artifact(self, **params: Any) -> object:
|
||||
return self._response("workflow.artifacts.inspect", params)
|
||||
|
||||
async def save_artifact(self, artifact: dict[str, Any]) -> object:
|
||||
return self._response("workflow.artifacts.save", {"artifact": artifact})
|
||||
|
||||
async def validate_artifact_plan(self, **params: Any) -> object:
|
||||
return self._response("workflow.artifacts.validate_plan", params)
|
||||
|
||||
async def create_artifact_from_plan(self, **params: Any) -> object:
|
||||
return self._response("workflow.artifacts.create_from_plan", params)
|
||||
|
||||
async def list_deployments(self) -> object:
|
||||
return self._response("workflow.deployments.list", {})
|
||||
|
||||
async def inspect_deployment(self, **params: Any) -> object:
|
||||
return self._response("workflow.deployments.inspect", params)
|
||||
|
||||
async def save_deployment(self, deployment: dict[str, Any]) -> object:
|
||||
return self._response("workflow.deployments.save", {"deployment": deployment})
|
||||
|
||||
async def validate_deployment(self, **params: Any) -> object:
|
||||
return self._response("workflow.deployments.validate", params)
|
||||
|
||||
async def run_deployment(self, **params: Any) -> object:
|
||||
return self._response("workflow.runs.start", params)
|
||||
|
||||
async def inspect_run(self, **params: Any) -> object:
|
||||
return self._response("workflow.runs.inspect", params)
|
||||
|
||||
async def resume_run(self, **params: Any) -> object:
|
||||
return self._response("workflow.runs.resume", params)
|
||||
|
||||
async def read_run_trace(self, **params: Any) -> object:
|
||||
return self._response("workflow.runs.trace", params)
|
||||
|
||||
async def _call(self, method: str, params: dict[str, Any]) -> object:
|
||||
return self._response(method, params)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_client() -> FakeWorkflowClient:
|
||||
return FakeWorkflowClient()
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from wf_client.codec import (
|
||||
decode_dependency_diagnostics,
|
||||
decode_deployment,
|
||||
decode_run_result,
|
||||
decode_trace_result,
|
||||
decode_workflow_artifact,
|
||||
)
|
||||
from wf_client.errors import InvalidResponse
|
||||
|
||||
|
||||
def _constant_plan_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"name": "report",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"state_schema": {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string", "reducer": "wf.std.replace"}},
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
"required": ["result"],
|
||||
},
|
||||
"outcomes": ["ok"],
|
||||
"start": "constant",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "constant",
|
||||
"type": "node",
|
||||
"node": "wf.std.constant",
|
||||
"input": [{"value": "hello", "target": "local.value"}],
|
||||
"output": [{"source": "local.value", "target": "state.result"}],
|
||||
}
|
||||
],
|
||||
"edges": [{"from": "constant", "outcome": "ok", "to": "__end__"}],
|
||||
"output": [{"path": "state.result", "target": "result"}],
|
||||
}
|
||||
|
||||
|
||||
def _workflow_artifact_payload(*, plan: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": "report",
|
||||
"version": 1,
|
||||
"title": "Report",
|
||||
"kind": "workflow",
|
||||
"description": None,
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"result": {"type": "string"}},
|
||||
},
|
||||
"outcomes": ["ok"],
|
||||
"plan": plan,
|
||||
"required_capabilities": [],
|
||||
"workflow_dependencies": {},
|
||||
"created_from_catalog_version": None,
|
||||
}
|
||||
|
||||
|
||||
def test_decode_workflow_artifact_validates_plan() -> None:
|
||||
artifact, workflow = decode_workflow_artifact(
|
||||
_workflow_artifact_payload(plan=_constant_plan_payload())
|
||||
)
|
||||
|
||||
assert artifact.id == "report"
|
||||
assert workflow.name == "report"
|
||||
assert workflow.start == "constant"
|
||||
|
||||
|
||||
def test_decode_workflow_artifact_rejects_invalid_nested_plan() -> None:
|
||||
with pytest.raises(InvalidResponse, match="workflow.artifacts.inspect"):
|
||||
decode_workflow_artifact(_workflow_artifact_payload(plan={"name": "broken"}))
|
||||
|
||||
|
||||
def test_decode_deployment_returns_domain_model() -> None:
|
||||
deployment = decode_deployment(
|
||||
{
|
||||
"id": "production",
|
||||
"artifact_id": "report",
|
||||
"artifact_version": 1,
|
||||
"bindings": [],
|
||||
"drift_policy": "block",
|
||||
}
|
||||
)
|
||||
|
||||
assert deployment.id == "production"
|
||||
assert deployment.artifact_id == "report"
|
||||
|
||||
|
||||
def test_decode_dependency_diagnostics_returns_domain_models() -> None:
|
||||
diagnostics = decode_dependency_diagnostics(
|
||||
[
|
||||
{
|
||||
"severity": "error",
|
||||
"code": "missing_source",
|
||||
"logical_ref": "demo",
|
||||
"bound_source": None,
|
||||
"message": "missing",
|
||||
"repair_hint": "bind it",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert diagnostics[0].code == "missing_source"
|
||||
assert diagnostics[0].severity.value == "error"
|
||||
|
||||
|
||||
def _run_payload(*, trace: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"artifact_id": "report",
|
||||
"artifact_version": 1,
|
||||
"deployment_id": "production",
|
||||
"status": "completed",
|
||||
"run_id": "run-1",
|
||||
"resume_readiness": None,
|
||||
"interrupt": None,
|
||||
"outcome": "ok",
|
||||
"error": None,
|
||||
"output": {"result": "hello"},
|
||||
"trace_count": 0 if trace is None else len(trace),
|
||||
"diagnostics": [],
|
||||
"next_actions": {
|
||||
"can_continue": False,
|
||||
"can_save_now": None,
|
||||
"recommended_next_tool": None,
|
||||
"reason": "done",
|
||||
"patch_examples": [],
|
||||
"warnings": [],
|
||||
},
|
||||
}
|
||||
if trace is not None:
|
||||
payload.update(
|
||||
trace=trace,
|
||||
trace_start=0,
|
||||
trace_limit=25,
|
||||
trace_truncated=False,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def test_decode_run_result_returns_typed_domain_boundary() -> None:
|
||||
result = decode_run_result(_run_payload())
|
||||
|
||||
assert result.run_id == "run-1"
|
||||
assert result.output == {"result": "hello"}
|
||||
assert result.diagnostics == ()
|
||||
|
||||
|
||||
def test_decode_trace_result_decodes_bounded_trace() -> None:
|
||||
result = decode_trace_result(
|
||||
_run_payload(
|
||||
trace=[
|
||||
{
|
||||
"frame_id": "root",
|
||||
"node_id": "constant",
|
||||
"step_type": "node",
|
||||
"resolved_input": {},
|
||||
"outcome": "ok",
|
||||
"next_node_id": "__end__",
|
||||
"output": {},
|
||||
"state_changes": {},
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert result.trace_start == 0
|
||||
assert result.trace is not None
|
||||
assert result.trace[0]["node_id"] == "constant"
|
||||
@@ -26,10 +26,41 @@ from wf_core.models.steps import (
|
||||
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
||||
from wf_server import build_local_static_workflow_server
|
||||
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
|
||||
from wf_transport_rpc_http.client.base import RpcProtocolError
|
||||
from wf_transport_rpc_http.client.drafts import RpcDraftClientMixin
|
||||
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
|
||||
|
||||
|
||||
async def test_rpc_client_preserves_structured_jsonrpc_error() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "request",
|
||||
"error": {
|
||||
"code": "missing_source",
|
||||
"message": "workflow operation failed",
|
||||
"data": {"message": "source is not configured", "hint": "bind it"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
async with http_client:
|
||||
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
|
||||
with pytest.raises(RpcProtocolError) as raised:
|
||||
await client.list_capabilities()
|
||||
|
||||
assert raised.value.code == "missing_source"
|
||||
assert raised.value.message == "workflow operation failed"
|
||||
assert raised.value.data == {
|
||||
"message": "source is not configured",
|
||||
"hint": "bind it",
|
||||
}
|
||||
assert str(raised.value) == ("workflow operation failed: source is not configured")
|
||||
|
||||
|
||||
def _constant_plan() -> RawWorkflowPlan:
|
||||
return RawWorkflowPlan.model_validate(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user