Files
lda-wf/tests/wf_transport_rpc_http/test_client.py
T

1397 lines
46 KiB
Python

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,
},
)