from __future__ import annotations import json from typing import Any import httpx2 import pytest from pydantic import TypeAdapter from wf_api import CapabilityStepUpdate from wf_api.models import RawWorkflowPlan, TraceRange from wf_api.surface import RouteSource, WorkflowDraftSurface from wf_artifacts.drafts.models import ( DraftEndPayload, DraftEndStep, DraftStep, ) from wf_core import END from wf_core.models.steps import ( InputBinding, InputExpressionBinding, InputPathBinding, InputValueBinding, OutputBinding, ) 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.runs import RpcRunClientMixin from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin async def test_rpc_client_preserves_structured_jsonrpc_error() -> None: def handler(request: httpx2.Request) -> httpx2.Response: request_id = json.loads(request.content)["id"] return httpx2.Response( 200, json={ "jsonrpc": "2.0", "id": request_id, "error": { "code": "missing_source", "message": "workflow operation failed", "data": {"message": "source is not configured", "hint": "bind it"}, }, }, ) http_client = httpx2.AsyncClient(transport=httpx2.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") @pytest.mark.asyncio @pytest.mark.parametrize( ("jsonrpc", "response_id"), [(None, "echo"), ("1.0", "echo"), ("2.0", "wrong")], ) async def test_rpc_client_rejects_malformed_response_envelope( jsonrpc: str | None, response_id: str, ) -> None: def handler(request: httpx2.Request) -> httpx2.Response: request_id = json.loads(request.content)["id"] payload: dict[str, object] = { "id": request_id if response_id == "echo" else response_id, "result": {}, } if jsonrpc is not None: payload["jsonrpc"] = jsonrpc return httpx2.Response(200, json=payload) async with httpx2.AsyncClient( transport=httpx2.MockTransport(handler) ) as http_client: client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client) with pytest.raises(RuntimeError, match="JSON-RPC response"): await client.list_capabilities() def _constant_plan() -> RawWorkflowPlan: return RawWorkflowPlan.model_validate( { "name": "client_constant", "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 from rpc client", "target": {"root": "local", "parts": ["value"]}, } ], "output": [ { "source": {"root": "local", "parts": ["value"]}, "target": {"root": "state", "parts": ["result"]}, } ], } ], "edges": [{"from": "constant", "outcome": "ok", "to": END}], "output": [ { "path": {"root": "state", "parts": ["result"]}, "target": {"root": "local", "parts": ["result"]}, } ], } ) async def test_rpc_workflow_client_lists_and_inspects_capabilities(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test", ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) listed = await client.list_capabilities(source_id="wf.std", limit=5) inspected = await client.inspect_capability(qualified_name="wf.std.constant") called = await client.call_capability( qualified_name="wf.std.constant", payload={"value": "hello rpc client"}, ) assert listed["capabilities"] assert {capability["source_id"] for capability in listed["capabilities"]} == { "wf.std" } assert inspected["name"] == "wf.std.constant" assert called["qualified_name"] == "wf.std.constant" assert called["outcome"] == "ok" assert called["output"] == {"value": "hello rpc client"} async def test_rpc_workflow_client_lists_and_inspects_sources(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test", ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) listed = await client.list_sources(limit=10) inspected = await client.inspect_source(source_id="wf.std") source_ids = {source["id"] for source in listed["sources"]} assert "wf.std" in source_ids assert inspected["id"] == "wf.std" async def test_rpc_workflow_client_reads_admin_state(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") server.events.record_workflow_event( "workflow_test_event", capability_id="workflow.demo.v1", payload={"ok": True}, ) app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test", ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) connections = await client.list_connections() statuses = await client.get_connection_statuses() events = await client.list_events() assert connections == {"connections": [], "total": 0} assert statuses == {"statuses": [], "total": 0} assert events["total"] == 1 assert events["events"][0]["kind"] == "workflow_test_event" assert isinstance(events["events"][0]["timestamp_epoch_ms"], int) async def test_rpc_workflow_client_runs_and_reads_trace(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") await server.api.create_artifact_from_plan( artifact_id="client_constant", version=1, title="Client Constant", plan=_constant_plan(), outcomes=["ok"], source_bindings={}, ) await server.api.save_deployment( { "id": "client_constant.default", "artifact_id": "client_constant", "artifact_version": 1, "bindings": {}, } ) app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test", ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) run = await client.run_deployment( deployment_id="client_constant.default", workflow_input={}, trace_range=TraceRange(start=0, limit=1), ) run_id = run["run_id"] assert run_id is not None inspected = await client.inspect_run(run_id=run_id) trace = await client.read_run_trace( run_id=run_id, trace_range=TraceRange(start=0, limit=1), ) assert run["status"] == "completed" output = run["output"] assert output is not None assert output["result"] == "hello from rpc client" assert inspected["trace_count"] >= 1 assert len(trace["trace"]) == 1 async def test_rpc_workflow_client_raises_for_rpc_error(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test", ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) try: await client.inspect_capability(qualified_name="missing.capability") except RuntimeError as exc: message = str(exc) else: raise AssertionError("expected RuntimeError") assert "Workflow operation failed" in message assert "missing.capability" in message async def test_rpc_workflow_client_lists_and_inspects_artifacts(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") await server.api.create_artifact_from_plan( artifact_id="client_art", version=1, title="Client Art", plan=_constant_plan(), outcomes=["ok"], source_bindings={}, ) app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client ) listed = await client.list_artifacts() inspected = await client.inspect_artifact(artifact_id="client_art", version=1) assert listed["nodes"] assert inspected["id"] == "client_art" async def test_rpc_workflow_client_lists_inspects_validates_and_deletes_deployments( tmp_path, ) -> None: server = build_local_static_workflow_server(tmp_path / "store") await server.api.create_artifact_from_plan( artifact_id="client_deploy_art", version=1, title="Client Deploy Art", plan=_constant_plan(), outcomes=["ok"], source_bindings={}, ) await server.api.save_deployment( { "id": "client_deploy_art.default", "artifact_id": "client_deploy_art", "artifact_version": 1, "bindings": {}, } ) app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client ) listed = await client.list_deployments() inspected = await client.inspect_deployment( deployment_id="client_deploy_art.default" ) validated = await client.validate_deployment( deployment_id="client_deploy_art.default" ) deleted = await client.delete_deployment( deployment_id="client_deploy_art.default" ) assert listed["deployments"] assert inspected["id"] == "client_deploy_art.default" assert validated["status"] == "runnable" assert deleted["deployment_id"] == "client_deploy_art.default" async def test_rpc_workflow_client_draft_workspace_lifecycle(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store", drafts=True) app = create_rpc_app(server, drafts=True) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client ) created = await client.create_draft_workspace_from_capability( workspace_id="client_ws", capability_name="wf.std.constant", name="client_constant", title="Client Constant", input_map={}, output_map={}, ) listed = await client.list_draft_workspaces() fetched = await client.get_draft_workspace(workspace_id="client_ws") validated = await client.validate_draft_workspace(workspace_id="client_ws") patched = await client.patch_draft_workspace( workspace_id="client_ws", revision=created["revision"], patch=[{"op": "replace", "path": "/name", "value": "client_renamed"}], ) artifact = await client.create_artifact_from_workspace( workspace_id="client_ws", artifact_id="client_ws_art", version=1, title="Client WS Art", outcomes=("ok",), kind="workflow", source_bindings={}, ) assert created["workspace_id"] == "client_ws" assert listed["workspaces"] assert fetched["workspace_id"] == "client_ws" assert validated["status"] in {"valid", "invalid"} assert patched["revision"] == created["revision"] + 1 assert artifact["saved"] is True assert artifact["artifact_id"] == "client_ws_art" async def test_rpc_client_sends_exact_draft_lifecycle_payloads() -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"revision": len(calls)} client = Client() state_schema = { "type": "object", "properties": {"status": {"type": "string"}}, } await client.create_empty_draft_workspace( workspace_id="ws", name="control_first", title="Control First", outcomes=("ok", "error"), ) await client.set_draft_start(workspace_id="ws", revision=1, step_id="gate") await client.set_draft_contract( workspace_id="ws", revision=2, state_schema=state_schema, outcomes=("ok", "error"), ) assert calls == [ { "method": "workflow.draft_workspaces.create_empty", "params": { "workspace_id": "ws", "name": "control_first", "title": "Control First", "input_schema": None, "state_schema": None, "output_schema": None, "outcomes": ["ok", "error"], }, }, { "method": "workflow.draft_workspaces.set_start", "params": {"workspace_id": "ws", "revision": 1, "step_id": "gate"}, }, { "method": "workflow.draft_workspaces.set_contract", "params": { "workspace_id": "ws", "revision": 2, "input_schema": None, "state_schema": state_schema, "output_schema": None, "outcomes": ["ok", "error"], }, }, ] async def test_rpc_client_sends_exact_authoring_contract_payload() -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"workspace_id": "ws", "revision": 4, "selected_step_id": None} client = Client() result = await client.inspect_draft_authoring_contract( workspace_id="ws", revision=4, ) assert result["revision"] == 4 assert calls == [ { "method": "workflow.draft_workspaces.inspect_authoring_contract", "params": { "workspace_id": "ws", "revision": 4, "selected_step_id": None, }, } ] async def test_rpc_client_sends_exact_stateless_draft_payloads() -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"status": "invalid", "diagnostics": []} client = Client() draft = {"name": "report"} patch = [{"op": "replace", "path": "/name", "value": "renamed"}] await client.validate_draft(draft=draft) await client.patch_draft(draft=draft, patch=patch) assert calls == [ { "method": "workflow.drafts.validate", "params": {"draft": draft}, }, { "method": "workflow.drafts.patch", "params": {"draft": draft, "patch": patch}, }, ] async def test_rpc_client_sends_exact_replace_document_payload() -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"revision": 5} client = Client() draft = { "name": "report", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "finish", "steps": {"finish": {"end": {}}}, "routes": {}, } result = await client.replace_draft_workspace_document( workspace_id="report", revision=4, draft=draft, ) assert result["revision"] == 5 assert calls == [ { "method": "workflow.draft_workspaces.replace_document", "params": { "workspace_id": "report", "revision": 4, "draft": draft, }, } ] async def test_rpc_client_builds_capability_free_draft_lifecycle(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store", drafts=True) app = create_rpc_app(server, drafts=True) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test", ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) created = await client.create_empty_draft_workspace( workspace_id="control_first", name="control_first", ) ended = await client.add_step( workspace_id="control_first", revision=created["revision"], step_id="finish", step=DraftEndStep(end=DraftEndPayload(outcome="error")), ) started = await client.set_draft_start( workspace_id="control_first", revision=ended["revision"], step_id="finish", ) contracted = await client.set_draft_contract( workspace_id="control_first", revision=started["revision"], outcomes=("error",), ) stale = await client.set_draft_start( workspace_id="control_first", revision=started["revision"], step_id="finish", ) validated = await client.validate_draft_workspace(workspace_id="control_first") compiled = await client.compile_draft_workspace(workspace_id="control_first") inspected = await client.get_draft_workspace( workspace_id="control_first", include_draft=True, ) assert created["revision"] == 1 assert ended["revision"] == 2 assert started["revision"] == 3 assert contracted["revision"] == 4 assert stale["status"] == "conflict" assert stale["diagnostics"][0]["code"] == "revision_conflict" assert validated["status"] == "valid" assert "compiled_plan" in compiled assert compiled["compiled_plan"]["start"] == "finish" draft = inspected.get("draft") assert draft is not None assert draft["start"] == "finish" assert draft["steps"] == {"finish": {"end": {"outcome": "error"}}} def test_rpc_client_satisfies_draft_surface_static_shape() -> None: _: type[WorkflowDraftSurface] = RpcWorkflowApiClient async def test_rpc_workflow_client_deletes_draft_workspace(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store", drafts=True) app = create_rpc_app(server, drafts=True) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client ) await client.create_draft_workspace_from_capability( workspace_id="delete-me", capability_name="wf.std.constant", name="delete_me_ws", ) deleted = await client.delete_draft_workspace(workspace_id="delete-me") assert deleted["workspace_id"] == "delete-me" assert deleted["deleted"] is True deleted_again = await client.delete_draft_workspace(workspace_id="delete-me") assert deleted_again["workspace_id"] == "delete-me" assert deleted_again["deleted"] is False async def test_rpc_workflow_client_deletes_artifact(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") await server.api.create_artifact_from_plan( artifact_id="delete_artifact", version=1, title="Delete Me", plan=_constant_plan(), outcomes=["ok"], source_bindings={}, ) app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client ) deleted = await client.delete_artifact(artifact_id="delete_artifact", version=1) assert deleted["deleted"] is True assert deleted["artifact_id"] == "delete_artifact" assert deleted["version"] == 1 async def test_rpc_client_lists_runs(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") await server.api.create_artifact_from_plan( artifact_id="client_list_runs", version=1, title="Client List Runs", plan=_constant_plan(), outcomes=["ok"], source_bindings={}, ) await server.api.save_deployment( { "id": "client_list_runs.default", "artifact_id": "client_list_runs", "artifact_version": 1, "bindings": {}, } ) started = await server.api.run_deployment( deployment_id="client_list_runs.default", workflow_input={}, ) app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test", ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) listed = await client.list_runs(status="completed", limit=5) started_run_id = started["run_id"] assert started_run_id is not None assert listed["total"] == 1 assert listed["runs"][0]["run_id"] == started_run_id async def test_rpc_client_creates_artifact_from_plan(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test", ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) created = await client.create_artifact_from_plan( artifact_id="client_plan", version=1, title="Client Plan", plan=_constant_plan().model_dump(mode="json", by_alias=True), outcomes=("ok",), source_bindings={}, ) inspected = await client.inspect_artifact( artifact_id="client_plan", version=1, ) assert created["artifact_id"] == "client_plan" assert inspected["id"] == "client_plan" async def test_rpc_client_validates_artifact_plan_without_persisting(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store") app = create_rpc_app(server) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) validated = await client.validate_artifact_plan( plan=_constant_plan().model_dump(mode="json", by_alias=True), outcomes=("ok",), source_bindings={}, ) listed = await client.list_artifacts(query="client_constant") assert validated["status"] == "valid" assert validated["diagnostics"] == [] assert listed["nodes"] == [] assert listed["total"] == 0 async def test_rpc_client_set_workflow_output_map(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store", drafts=True) app = create_rpc_app(server, drafts=True) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) await client.create_draft_workspace_from_capability( workspace_id="client_output_ws", capability_name="wf.std.constant", name="client_output", ) result = await client.set_workflow_output_map( workspace_id="client_output_ws", revision=1, output_map={"state.value": "value"}, ) fetched = await client.get_draft_workspace( workspace_id="client_output_ws", include_draft=True, ) assert result["revision"] == 2 draft = fetched.get("draft") assert draft is not None assert draft["output"] == [ {"path": "state.value", "target": "value"}, ] async def test_rpc_client_draft_workspace_focused_edit_methods(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store", drafts=True) app = create_rpc_app(server, drafts=True) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) await client.create_draft_workspace_from_capability( workspace_id="client_focused_ws", capability_name="wf.std.constant", name="client_initial", ) named = await client.set_draft_name( workspace_id="client_focused_ws", revision=1, name="client_renamed", ) routed = await client.set_draft_route( workspace_id="client_focused_ws", revision=2, step_id="call", outcome="ok", target="__end__", ) input_mapped = await client.set_step_input_map( workspace_id="client_focused_ws", revision=3, step_id="call", input_map={"input.value": "value"}, ) output_mapped = await client.set_step_output_map( workspace_id="client_focused_ws", revision=4, step_id="call", output_map={"value": "state.value"}, ) input_merged = await client.set_step_input_map( workspace_id="client_focused_ws", revision=5, step_id="call", input_map={"input.extra": "extra"}, merge=True, ) output_merged = await client.set_step_output_map( workspace_id="client_focused_ws", revision=6, step_id="call", output_map={"extra": "state.extra"}, merge=True, ) state_bound = await client.bind_draft( workspace_id="client_focused_ws", revision=7, step_id="call", source_path="local.value", target_path="state.extra_value", ) assert named["revision"] == 2 assert routed["revision"] == 3 assert input_mapped["revision"] == 4 assert output_mapped["revision"] == 5 assert input_merged["revision"] == 6 assert output_merged["revision"] == 7 assert state_bound["revision"] == 8 async def test_rpc_client_serializes_canonical_step_input_bindings() -> None: calls: list[tuple[str, dict[str, object]]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append((method, params)) return {"revision": 3} client = Client() await client.set_step_input_bindings( workspace_id="client_ws", revision=2, step_id="call", bindings=[ InputPathBinding( path=GraphSourcePath.state("title"), target=LocalPath.of("request", "title"), ), InputValueBinding( target=LocalPath.of("request", "format"), value="markdown", ), ], ) assert calls[-1] == ( "workflow.draft_workspaces.set_step_input_bindings", { "workspace_id": "client_ws", "revision": 2, "step_id": "call", "bindings": [ {"target": "request.title", "path": "state.title"}, {"target": "request.format", "value": "markdown"}, ], }, ) async def test_rpc_client_serializes_composite_step_input_bindings() -> None: calls: list[tuple[str, dict[str, object]]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append((method, params)) return {"revision": 3} client = Client() await client.set_step_input_bindings( workspace_id="client_ws", revision=2, step_id="call", bindings=[ InputExpressionBinding.model_validate( { "target": "items", "expression": { "kind": "array", "items": [ {"kind": "path", "path": "state.value"}, {"kind": "literal", "value": "!"}, ], }, } ) ], ) assert calls[-1][1]["bindings"] == [ { "target": "items", "expression": { "kind": "array", "items": [ {"kind": "path", "path": "state.value"}, {"kind": "literal", "value": "!"}, ], }, } ] async def test_rpc_client_set_workflow_output_bindings_preserves_union_order() -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"revision": 4} client = Client() bindings: list[InputBinding] = [ InputPathBinding( path=GraphSourcePath.state("value"), target=LocalPath.of("value"), ), InputValueBinding( target=LocalPath.of("format"), value="markdown", ), ] await client.set_workflow_output_bindings( workspace_id="ws", revision=3, bindings=bindings, ) assert calls[-1]["method"] == ( "workflow.draft_workspaces.set_workflow_output_bindings" ) assert calls[-1]["params"]["bindings"] == [ {"target": "value", "path": "state.value"}, {"target": "format", "value": "markdown"}, ] async def test_rpc_client_serializes_step_output_bindings() -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"revision": 3} client = Client() await client.set_step_output_bindings( workspace_id="client_ws", revision=2, step_id="analyze", bindings=[ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ), OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.audit.title"), ), ], ) assert calls[-1]["method"] == "workflow.draft_workspaces.set_step_output_bindings" assert calls[-1]["params"]["bindings"] == [ {"source": "report.title", "target": "state.report.title"}, {"source": "report.title", "target": "state.audit.title"}, ] async def test_rpc_client_draft_remove_methods(tmp_path) -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"revision": 2} client = Client() route_result = await client.remove_draft_route( workspace_id="ws", revision=1, step_id="call", outcome="ok", ) step_result = await client.remove_draft_step( workspace_id="ws", revision=1, step_id="call", ) binding_result = await client.remove_draft_binding( workspace_id="ws", revision=1, step_id="echo", inputs=["message"], outputs=["debug"], ) assert route_result == {"revision": 2} assert step_result == {"revision": 2} assert binding_result == {"revision": 2} assert calls[0]["method"] == "workflow.draft_workspaces.remove_route" assert calls[1]["method"] == "workflow.draft_workspaces.remove_step" assert calls[2]["method"] == "workflow.draft_workspaces.remove_binding" assert calls[2]["params"]["inputs"] == ["message"] assert calls[2]["params"]["outputs"] == ["debug"] async def test_rpc_client_draft_workspace_add_step_from_capability(tmp_path) -> None: server = build_local_static_workflow_server(tmp_path / "store", drafts=True) app = create_rpc_app(server, drafts=True) transport = httpx2.ASGITransport(app=app) async with httpx2.AsyncClient( transport=transport, base_url="http://test" ) as http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) await client.create_draft_workspace_from_capability( workspace_id="client_add_step_ws", capability_name="wf.std.constant", name="client_add_step", ) result = await client.add_step_from_capability( workspace_id="client_add_step_ws", revision=1, step_id="second", capability_name="wf.std.constant", route_from_step="call", route_from_outcome="ok", routes={"ok": "__end__"}, input_map={"input.value": "value"}, bind_outputs={"value": "state.second_value"}, ) assert result["revision"] == 2 assert result["status"] == "valid" async def test_rpc_client_serializes_capability_step_changes() -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"revision": 2} client = Client() await client.update_capability_step( workspace_id="ws", revision=1, step_id="publish", update=CapabilityStepUpdate.model_validate({"desc": None, "retry": 0}), ) await client.add_step_from_capability( workspace_id="ws", revision=2, step_id="publish", capability_name="demo.report", routes={"ok": "__end__"}, desc="Publish report", retry=0, timeout_seconds=30, input_bindings=[ InputPathBinding( path=GraphSourcePath.state("report", "title"), target=LocalPath.of("request", "title"), ), InputValueBinding( target=LocalPath.of("request", "format"), value="markdown", ), ], ) assert calls[0] == { "method": "workflow.draft_workspaces.update_capability_step", "params": { "workspace_id": "ws", "revision": 1, "step_id": "publish", "update": {"desc": None, "retry": 0}, }, } assert calls[1]["method"] == "workflow.draft_workspaces.add_step_from_capability" assert calls[1]["params"]["input_bindings"] == [ {"path": "state.report.title", "target": "request.title"}, {"value": "markdown", "target": "request.format"}, ] assert calls[1]["params"]["desc"] == "Publish report" assert calls[1]["params"]["retry"] == 0 assert calls[1]["params"]["timeout_seconds"] == 30 async def test_rpc_client_preserves_nested_local_path_strings() -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"revision": 2} client = Client() await client.bind_draft( workspace_id="ws", revision=1, step_id="render", source_path="input.title", target_path="local.report.title", ) await client.add_step_from_capability( workspace_id="ws", revision=2, step_id="render", capability_name="demo.report", input_map={"input.title": "report.title"}, ) assert calls[0]["params"]["target_path"] == "local.report.title" assert calls[1]["params"]["input_map"] == {"input.title": "report.title"} @pytest.mark.parametrize( ("step_id", "step", "expected_wire"), [ ( "use", TypeAdapter(DraftStep).validate_python({"use": "demo.echo"}), {"use": "demo.echo"}, ), ( "foreach", TypeAdapter(DraftStep).validate_python( {"foreach": {"over": "state.items", "as": "item"}} ), {"over": "state.items", "as": "item"}, ), ( "interrupt", TypeAdapter(DraftStep).validate_python( { "interrupt": { "kind": "approval", "request_schema": {"type": "object"}, "resume_schema": {"type": "object"}, } } ), { "kind": "approval", "request": [], "resume": [], "request_schema": {"type": "object", "properties": {}, "required": []}, "resume_schema": {"type": "object", "properties": {}, "required": []}, "outcomes": ["submitted"], }, ), ("end", TypeAdapter(DraftStep).validate_python({"end": {}}), {"outcome": "ok"}), ( "when", TypeAdapter(DraftStep).validate_python( { "when": { "if": {"op": "exists", "path": "state.ready"}, "then": "next", } } ), { "if": {"op": "exists", "path": "state.ready"}, "then": "next", "otherwise": "__end__", }, ), ( "choose", TypeAdapter(DraftStep).validate_python( { "choose": { "clauses": [ { "if": {"op": "exists", "path": "state.ready"}, "then": "next", } ] } } ), { "clauses": [ {"if": {"op": "exists", "path": "state.ready"}, "then": "next"} ], "default": "__end__", }, ), ( "match", TypeAdapter(DraftStep).validate_python( { "match": { "value": "state.status", "cases": [{"equals": "ready", "then": "next"}], } } ), { "value": "state.status", "cases": [{"equals": "ready", "then": "next"}], "default": "__end__", }, ), ( "subgraph", TypeAdapter(DraftStep).validate_python( {"subgraph": {"workflow": {"artifact_id": "child", "version": 2}}} ), {"workflow": {"artifact_id": "child", "version": 2}}, ), ], ) async def test_rpc_client_add_step_preserves_all_typed_variants( step_id: str, step: DraftStep, expected_wire: dict[str, Any] ) -> None: calls: list[dict[str, Any]] = [] class Client(RpcDraftClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append({"method": method, "params": params}) return {"revision": 2} result = await Client().add_step( workspace_id="ws", revision=1, step_id=step_id, step=step, incoming=RouteSource(step_id="lookup"), routes=None, ) assert result == {"revision": 2} request = calls[0] assert request["method"] == "workflow.draft_workspaces.add_step" wire_step = request["params"]["step"] if step_id == "use": wire_payload = wire_step else: assert set(wire_step) == {step_id} wire_payload = wire_step[step_id] for field, expected_value in expected_wire.items(): assert wire_payload[field] == expected_value assert request["params"]["incoming"] == { "step_id": "lookup", "outcome": "ok", } if step_id == "when": assert request["params"]["step"]["when"]["if"]["op"] == "exists" if step_id == "foreach": assert request["params"]["step"]["foreach"]["as"] == "item" assert "as_" not in request["params"]["step"]["foreach"] if step_id == "interrupt": assert ( request["params"]["step"]["interrupt"]["request_schema"]["type"] == "object" ) assert ( request["params"]["step"]["interrupt"]["resume_schema"]["type"] == "object" ) if step_id == "subgraph": assert request["params"]["step"]["subgraph"]["workflow"] == { "artifact_id": "child", "version": 2, } async def test_rpc_client_diagnoses_source(tmp_path) -> None: calls: list[tuple[str, dict[str, object]]] = [] class Client(RpcSourceAdminClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append((method, params)) return {"source_id": params["source_id"], "status": "ok"} payload = await Client().diagnose_source(source_id="demo.personal") assert payload == {"source_id": "demo.personal", "status": "ok"} assert calls == [("workflow.sources.diagnose", {"source_id": "demo.personal"})] async def test_rpc_run_client_omits_max_steps_unless_supplied() -> None: calls: list[tuple[str, dict[str, object]]] = [] class Client(RpcRunClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append((method, params)) return {"run_id": "run-1"} client = Client() await client.run_deployment(deployment_id="report.default", workflow_input={}) assert calls[-1] == ( "workflow.runs.start", { "deployment_id": "report.default", "workflow_input": {}, "trace_range": None, }, ) async def test_rpc_run_client_threads_max_steps_when_supplied() -> None: calls: list[tuple[str, dict[str, object]]] = [] class Client(RpcRunClientMixin): async def _call(self, method: str, params: dict[str, object]): calls.append((method, params)) return {"run_id": "run-1"} client = Client() await client.run_deployment( deployment_id="report.default", workflow_input={}, max_steps=5, ) assert calls[-1] == ( "workflow.runs.start", { "deployment_id": "report.default", "workflow_input": {}, "trace_range": None, "max_steps": 5, }, )