Files
lda-wf/tests/wf_cli/test_remote_target.py
T

2751 lines
82 KiB
Python

from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any, cast
import httpx2
from typer.testing import CliRunner
import wf_cli.context as cli_context
from wf_api.models import (
InspectSourceResult,
ListSourcesResult,
RawWorkflowPlan,
SourceDiagnosisResult,
)
from wf_cli.app import app
from wf_cli.context import CliContext, load_cli_context, load_local_cli_context
from wf_core import END
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 RpcClientTransport
from wf_transport_rpc_http.client.sources import RpcSourceAdminClientMixin
from .conftest import write_python_source_config
class BrokenSourceAdmin:
async def list_sources(
self,
*,
cursor: str | None = None,
limit: int = 50,
) -> ListSourcesResult:
return {"sources": [], "next_cursor": None, "total": 0}
async def inspect_source(self, *, source_id: str) -> InspectSourceResult:
raise RuntimeError(f"broken source admin for {source_id}")
async def diagnose_source(self, *, source_id: str) -> SourceDiagnosisResult:
raise RuntimeError(f"broken source admin for {source_id}")
class InventorySourceAdmin:
async def list_sources(
self,
*,
cursor: str | None = None,
limit: int = 50,
) -> ListSourcesResult:
return {"sources": [], "next_cursor": None, "total": 0}
async def inspect_source(self, *, source_id: str) -> InspectSourceResult:
# These tests exercise only capability names; keep the fake payload narrow
# while declaring the same result boundary as the production client.
return cast(
InspectSourceResult,
{
"id": source_id,
"capabilities": {
"resources": [
f"{source_id}.architecture.md",
f"{source_id}.startup.md",
],
"prompts": [
f"{source_id}.simple-prompt",
f"{source_id}.args-prompt",
],
},
},
)
async def diagnose_source(self, *, source_id: str) -> SourceDiagnosisResult:
return {"source_id": source_id, "status": "ok", "diagnostics": []}
def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None:
config_path = tmp_path / "wf.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"client": {
"target": {
"kind": "rpc_http",
"url": "http://127.0.0.1:8765/rpc",
"timeout_seconds": 9,
}
},
}
),
encoding="utf-8",
)
context = load_cli_context(config_path)
assert isinstance(context.handlers, RpcWorkflowApiClient)
assert context.handlers.url == "http://127.0.0.1:8765/rpc"
assert context.handlers.timeout_seconds == 9
assert context.service is None
def test_load_cli_context_local_override_beats_rpc_config(tmp_path) -> None:
config_path = tmp_path / "wf.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"client": {
"target": {
"kind": "rpc_http",
"url": "http://127.0.0.1:8765/rpc",
}
},
"server": {
"store": {"kind": "filesystem", "root": ".wf_store"},
},
}
),
encoding="utf-8",
)
context = load_cli_context(config_path, force_local=True)
assert not isinstance(context.handlers, RpcWorkflowApiClient)
assert context.service is None
assert context.config_path == config_path
def test_load_cli_context_rejects_local_and_url_conflict(tmp_path) -> None:
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
try:
load_cli_context(
config_path,
force_local=True,
rpc_url="http://127.0.0.1:8765/rpc",
)
except ValueError as exc:
message = str(exc)
else:
raise AssertionError("expected ValueError")
assert "--local and --url are mutually exclusive" in message
def test_load_cli_context_uses_workflow_shape_not_filename(tmp_path) -> None:
config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"client": {
"target": {
"kind": "rpc_http",
"url": "http://127.0.0.1:8765/rpc",
}
},
}
),
encoding="utf-8",
)
context = load_cli_context(config_path)
assert isinstance(context.handlers, RpcWorkflowApiClient)
def test_load_cli_context_uses_broker_shape_not_filename(tmp_path) -> None:
config_path = tmp_path / "wf.json"
config_path.write_text(
json.dumps({"store_root": ".wf_mcp_store", "connections": []}),
encoding="utf-8",
)
context = load_cli_context(config_path)
assert context.service is not None
assert not isinstance(context.handlers, RpcWorkflowApiClient)
def test_load_cli_context_url_override_reuses_config_timeout(tmp_path) -> None:
config_path = tmp_path / "wf.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"client": {
"target": {
"kind": "rpc_http",
"url": "http://127.0.0.1:8765/rpc",
"timeout_seconds": 77,
}
},
}
),
encoding="utf-8",
)
context = load_cli_context(config_path, rpc_url="http://127.0.0.1:9999/rpc")
assert isinstance(context.handlers, RpcWorkflowApiClient)
assert context.handlers.url == "http://127.0.0.1:9999/rpc"
assert context.handlers.timeout_seconds == 77
def test_local_cli_context_rejects_rpc_target_for_local_only_commands(tmp_path) -> None:
config_path = tmp_path / "wf.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"client": {
"target": {
"kind": "rpc_http",
"url": "http://127.0.0.1:8765/rpc",
}
},
}
),
encoding="utf-8",
)
try:
load_local_cli_context(config_path)
except ValueError as exc:
message = str(exc)
else:
raise AssertionError("expected ValueError")
assert "not available for rpc_http targets yet" in message
def _constant_plan() -> RawWorkflowPlan:
return RawWorkflowPlan.model_validate(
{
"name": "remote_cli_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 remote cli",
"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"]},
}
],
}
)
def _interrupt_plan() -> RawWorkflowPlan:
return RawWorkflowPlan.model_validate(
{
"name": "remote_approval",
"input_schema": {
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
"state_schema": {"fields": {}},
"output_schema": {"type": "object", "properties": {}},
"outcomes": ["submitted"],
"start": "approval",
"nodes": [
{
"id": "approval",
"type": "interrupt",
"kind": "approval",
"request": [
{
"path": {"root": "input", "parts": ["message"]},
"target": {"root": "local", "parts": ["message"]},
}
],
"resume": [],
"outcomes": ["submitted"],
},
{"id": "end_submitted", "type": "end", "outcome": "submitted"},
],
"edges": [
{"from": "approval", "outcome": "submitted", "to": "end_submitted"}
],
}
)
def _patch_rpc_client_to_server(monkeypatch, server) -> None:
"""Route CLI-created RPC clients to an in-process ASGI test server."""
def fake_rpc_client_from_target(
*,
url: str,
timeout_seconds: float,
) -> RpcWorkflowApiClient:
return RpcWorkflowApiClient(
url=url,
timeout_seconds=timeout_seconds,
http_client=httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=create_rpc_app(server, drafts=True)),
base_url="http://test",
),
)
monkeypatch.setattr(
cli_context, "rpc_client_from_target", fake_rpc_client_from_target
)
def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
inspected = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"cap",
"inspect",
"wf.std.constant",
],
)
listed = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"cap",
"list",
"--source",
"wf.std",
"--limit",
"100",
],
)
called = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"cap",
"call",
"wf.std.constant",
"--input",
'{"value": "hello cap call"}',
],
)
assert inspected.exit_code == 0, inspected.output
assert '"name": "wf.std.constant"' in inspected.output
assert listed.exit_code == 0, listed.output
listed_payload = json.loads(listed.output)
assert listed_payload["capabilities"]
assert {
capability["source_id"] for capability in listed_payload["capabilities"]
} == {"wf.std"}
assert called.exit_code == 0, called.output
called_payload = json.loads(called.output)
assert called_payload["qualified_name"] == "wf.std.constant"
assert called_payload["outcome"] == "ok"
assert called_payload["output"] == {"value": "hello cap call"}
compact = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"cap",
"call",
"wf.std.constant",
"--input",
'{"value": "hello cap call"}',
"--format",
"compact",
],
)
assert compact.exit_code == 0, compact.output
assert "wf.std.constant" in compact.output
assert "outcome=ok" in compact.output
assert "hello cap call" not in compact.output
help_result = runner.invoke(app, ["cap", "call", "--help"])
assert help_result.exit_code == 0
help_text = " ".join(help_result.output.split())
assert "--unwrap-text" in help_result.output
assert "MCP text content block" in help_text
assert "multiple blocks" in help_text
assert "non-MCP" in help_text
def test_wf_source_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
listed = runner.invoke(app, [*base_args, "source", "list", "--limit", "10"])
inspected = runner.invoke(app, [*base_args, "source", "inspect", "wf.std"])
assert listed.exit_code == 0, listed.output
assert '"id": "wf.std"' in listed.output
assert inspected.exit_code == 0, inspected.output
assert '"id": "wf.std"' in inspected.output
def test_wf_source_resources_and_prompts_render_names(monkeypatch, tmp_path) -> None:
fake_context = CliContext(
config_path=Path("dummy"),
service=cast(Any, object()),
handlers=build_local_static_workflow_server(tmp_path / "store").api,
source_admin=InventorySourceAdmin(),
admin=cast(Any, object()),
)
monkeypatch.setattr(
"wf_cli.commands.sources.load_cli_context_from_typer",
lambda _ctx: fake_context,
)
runner = CliRunner()
resources = runner.invoke(app, ["source", "resources", "everything.default"])
prompts = runner.invoke(app, ["source", "prompts", "everything.default"])
assert resources.exit_code == 0, resources.output
assert resources.output.splitlines() == [
"everything.default.architecture.md",
"everything.default.startup.md",
]
assert prompts.exit_code == 0, prompts.output
assert prompts.output.splitlines() == [
"everything.default.simple-prompt",
"everything.default.args-prompt",
]
def test_wf_source_resources_json_format(monkeypatch, tmp_path) -> None:
fake_context = CliContext(
config_path=Path("dummy"),
service=cast(Any, object()),
handlers=build_local_static_workflow_server(tmp_path / "store").api,
source_admin=InventorySourceAdmin(),
admin=cast(Any, object()),
)
monkeypatch.setattr(
"wf_cli.commands.sources.load_cli_context_from_typer",
lambda _ctx: fake_context,
)
result = CliRunner().invoke(
app,
["source", "resources", "everything.default", "--format", "json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload == {
"source_id": "everything.default",
"resources": [
"everything.default.architecture.md",
"everything.default.startup.md",
],
}
def test_wf_remote_source_inspect_formats_expected_rpc_error(
monkeypatch,
tmp_path,
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
result = runner.invoke(app, [*base_args, "source", "inspect", "missing.source"])
assert result.exit_code != 0
assert "Error" in result.output
assert "Workflow operation failed" in result.output
assert "missing.source" in result.output
assert "Traceback" not in result.output
assert "RuntimeError" not in result.output
def test_wf_remote_source_list_formats_transport_error(monkeypatch, tmp_path) -> None:
async def connection_failed(*args: Any, **kwargs: Any) -> dict[str, Any]:
raise httpx2.ConnectError(
"connection refused",
request=httpx2.Request("POST", "http://test/rpc"),
)
monkeypatch.setattr(RpcSourceAdminClientMixin, "list_sources", connection_failed)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
result = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"source",
"list",
],
)
assert result.exit_code != 0
assert "Error" in result.output
assert "connection refused" in result.output
assert "Traceback" not in result.output
assert "ConnectError" not in result.output
def test_wf_unexpected_error_uses_short_traceback_by_default(
monkeypatch,
tmp_path,
) -> None:
fake_context = CliContext(
config_path=Path("dummy"),
service=cast(Any, object()),
handlers=build_local_static_workflow_server(tmp_path / "store").api,
source_admin=BrokenSourceAdmin(),
admin=cast(Any, object()),
)
monkeypatch.setattr(
"wf_cli.commands.sources.load_cli_context_from_typer",
lambda _ctx: fake_context,
)
result = CliRunner().invoke(app, ["source", "inspect", "wf.std"])
assert result.exit_code != 0
assert "broken source admin for wf.std" in result.output
assert "tests/wf_cli/test_remote_target.py" not in result.output
def test_wf_verbose_shows_full_traceback_for_unexpected_error(
monkeypatch,
tmp_path,
) -> None:
fake_context = CliContext(
config_path=Path("dummy"),
service=cast(Any, object()),
handlers=build_local_static_workflow_server(tmp_path / "store").api,
source_admin=BrokenSourceAdmin(),
admin=cast(Any, object()),
verbose=True,
)
monkeypatch.setattr(
"wf_cli.commands.sources.load_cli_context_from_typer",
lambda _ctx: fake_context,
)
result = CliRunner().invoke(app, ["--verbose", "source", "inspect", "wf.std"])
assert result.exit_code != 0
assert isinstance(result.exception, RuntimeError)
assert str(result.exception) == "broken source admin for wf.std"
def test_wf_admin_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
server.events.record_workflow_event(
"workflow_test_event",
capability_id="workflow.demo.v1",
payload={"ok": True},
)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
connections = runner.invoke(app, [*base_args, "admin", "connections"])
statuses = runner.invoke(app, [*base_args, "admin", "statuses"])
events = runner.invoke(app, [*base_args, "admin", "events"])
assert connections.exit_code == 0, connections.output
assert '"connections": []' in connections.output
assert statuses.exit_code == 0, statuses.output
assert '"statuses": []' in statuses.output
assert events.exit_code == 0, events.output
assert '"kind": "workflow_test_event"' in events.output
def test_wf_remote_draft_artifact_deploy_lifecycle(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"remote_ws",
"--capability",
"wf.std.constant",
"--name",
"remote_constant",
"--title",
"Remote Constant",
],
)
assert created.exit_code == 0, created.output
assert '"workspace_id": "remote_ws"' in created.output
validated = runner.invoke(
app,
[*base_args, "draft", "validate", "remote_ws"],
)
assert validated.exit_code == 0, validated.output
assert '"status": "valid"' in validated.output
invalid_created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"repair_ws",
"--capability",
"wf.std.constant",
"--name",
"repair_constant",
],
)
assert invalid_created.exit_code == 0, invalid_created.output
invalid_patch = runner.invoke(
app,
[
*base_args,
"draft",
"set-output",
"repair_ws",
"--revision",
"1",
"--step",
"call",
"--map",
"value=state.missing",
"--merge",
],
)
assert invalid_patch.exit_code == 0, invalid_patch.output
invalid_validated = runner.invoke(
app,
[*base_args, "draft", "validate", "repair_ws"],
)
assert invalid_validated.exit_code == 0, invalid_validated.output
assert '"status": "invalid"' in invalid_validated.output
assert "bind repair_ws --revision 2" in invalid_validated.output
assert (
"--step call --from local.value --to state.missing" in invalid_validated.output
)
saved_artifact = runner.invoke(
app,
[
*base_args,
"draft",
"save",
"remote_ws",
"--artifact",
"remote_artifact",
"--version",
"1",
"--title",
"Remote Artifact",
"--outcome",
"ok",
],
)
assert saved_artifact.exit_code == 0, saved_artifact.output
assert '"artifact_id": "remote_artifact"' in saved_artifact.output
inspected_artifact = runner.invoke(
app,
[*base_args, "artifact", "inspect", "remote_artifact", "1"],
)
assert inspected_artifact.exit_code == 0, inspected_artifact.output
assert '"id": "remote_artifact"' in inspected_artifact.output
saved_deployment = runner.invoke(
app,
[
*base_args,
"deploy",
"save",
"remote_artifact.default",
"--artifact",
"remote_artifact",
"--version",
"1",
],
)
assert saved_deployment.exit_code == 0, saved_deployment.output
assert '"deployment_id": "remote_artifact.default"' in saved_deployment.output
validated_deployment = runner.invoke(
app,
[*base_args, "deploy", "validate", "remote_artifact.default"],
)
assert validated_deployment.exit_code == 0, validated_deployment.output
assert '"status": "runnable"' in validated_deployment.output
def test_wf_remote_capability_free_draft_lifecycle(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
commands = [
["draft", "create", "control_ws", "--name", "control"],
[
"draft",
"add",
"end",
"control_ws",
"--revision",
"1",
"--step",
"finish",
"--outcome",
"error",
],
[
"draft",
"set-start",
"control_ws",
"--revision",
"2",
"--step",
"finish",
],
[
"draft",
"set-contract",
"control_ws",
"--revision",
"3",
"--outcome",
"error",
],
["draft", "validate", "control_ws"],
]
results = [runner.invoke(app, [*base_args, *command]) for command in commands]
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "control_ws", "--include-draft"],
)
for result in results:
assert result.exit_code == 0, result.output
assert '"status": "valid"' in results[-1].output
assert inspected.exit_code == 0, inspected.output
payload = json.loads(inspected.output)
assert payload["revision"] == 4
assert payload["draft"]["start"] == "finish"
assert payload["draft"]["outcomes"] == ["error"]
assert set(payload["draft"]["steps"]) == {"finish"}
def test_wf_draft_export_uses_remote_get_and_writes_only_draft(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_calls.append((method, params))
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[*base_args, "draft", "create", "export_ws", "--name", "report"],
)
assert created.exit_code == 0, created.output
rpc_calls.clear()
output_path = tmp_path / "exported-draft.json"
exported = runner.invoke(
app,
[
*base_args,
"draft",
"export",
"export_ws",
"--output",
str(output_path),
],
)
assert exported.exit_code == 0, exported.output
assert rpc_calls == [
(
"workflow.draft_workspaces.get",
{"workspace_id": "export_ws", "include_draft": True},
)
]
payload = json.loads(output_path.read_text(encoding="utf-8"))
assert payload["name"] == "report"
assert "workspace_id" not in payload
assert "revision" not in payload
def test_wf_draft_import_uses_exact_remote_replacement_payload(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
asyncio.run(
server.api.create_empty_draft_workspace(
workspace_id="source_ws",
name="source",
)
)
asyncio.run(
server.api.create_empty_draft_workspace(
workspace_id="destination_ws",
name="destination",
)
)
source = asyncio.run(
server.api.get_draft_workspace(
workspace_id="source_ws",
include_draft=True,
)
)
expected_draft = source.get("draft")
assert expected_draft is not None
_patch_rpc_client_to_server(monkeypatch, server)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_calls.append((method, params))
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
input_path = tmp_path / "source-draft.json"
input_path.write_text(json.dumps(expected_draft), encoding="utf-8")
runner = CliRunner()
imported = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"draft",
"import",
"destination_ws",
"--revision",
"1",
"--file",
str(input_path),
],
)
assert imported.exit_code == 0, imported.output
assert rpc_calls == [
(
"workflow.draft_workspaces.replace_document",
{
"workspace_id": "destination_ws",
"revision": 1,
"draft": expected_draft,
},
)
]
def test_wf_draft_transfer_round_trip_preserves_document_and_destination_id(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
asyncio.run(
server.api.create_empty_draft_workspace(
workspace_id="source_ws",
name="source",
title="Source workflow",
)
)
asyncio.run(
server.api.create_empty_draft_workspace(
workspace_id="destination_ws",
name="destination",
title="Destination workflow",
)
)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
transfer_path = tmp_path / "transfer.json"
exported = runner.invoke(
app,
[
*base_args,
"draft",
"export",
"source_ws",
"--output",
str(transfer_path),
],
)
imported = runner.invoke(
app,
[
*base_args,
"draft",
"import",
"destination_ws",
"--revision",
"1",
"--file",
str(transfer_path),
],
)
inspected = runner.invoke(
app,
[
*base_args,
"draft",
"inspect",
"destination_ws",
"--include-draft",
],
)
assert exported.exit_code == 0, exported.output
assert imported.exit_code == 0, imported.output
assert inspected.exit_code == 0, inspected.output
exported_draft = json.loads(transfer_path.read_text(encoding="utf-8"))
destination = json.loads(inspected.output)
assert destination["workspace_id"] == "destination_ws"
assert destination["draft"] == exported_draft
def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
asyncio.run(
server.api.create_artifact_from_plan(
artifact_id="remote_approval",
version=1,
title="Remote Approval",
plan=_interrupt_plan(),
outcomes=("submitted",),
)
)
asyncio.run(
server.api.save_deployment(
{
"id": "remote_approval.default",
"artifact_id": "remote_approval",
"artifact_version": 1,
"bindings": [],
}
)
)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
started = runner.invoke(
app,
[
*base_args,
"run",
"start",
"remote_approval.default",
"--input",
'{"message": "approve?"}',
],
)
assert started.exit_code == 0, started.output
started_payload = json.loads(started.output)
assert started_payload["status"] == "interrupted"
resumed = runner.invoke(
app,
[
*base_args,
"run",
"resume",
started_payload["run_id"],
"--payload",
"{}",
],
)
assert resumed.exit_code == 0, resumed.output
resumed_payload = json.loads(resumed.output)
assert resumed_payload["run_id"] == started_payload["run_id"]
assert resumed_payload["status"] == "completed"
assert resumed_payload["outcome"] == "submitted"
def test_wf_status_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
asyncio.run(
server.api.create_artifact_from_plan(
artifact_id="status_constant",
version=1,
title="Status Constant",
plan=_constant_plan(),
outcomes=("ok",),
source_bindings={},
)
)
asyncio.run(
server.api.save_deployment(
{
"id": "status_constant.default",
"artifact_id": "status_constant",
"artifact_version": 1,
"bindings": {},
}
)
)
started = asyncio.run(
server.api.run_deployment(
deployment_id="status_constant.default",
workflow_input={},
)
)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
result = CliRunner().invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"status",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["target"]["mode"] == "remote"
assert payload["target"]["url"] == "http://test/rpc"
assert payload["workflow"]["capability_count"] >= 1
assert payload["runs"]["available"] is True
assert payload["runs"]["total"] == 1
assert payload["runs"]["completed"] == 1
assert payload["runs"]["failed"] == 0
assert payload["runs"]["interrupted"] == 0
assert payload["runs"]["latest"]["run_id"] == started["run_id"]
assert payload["runs"]["latest"]["status"] == "completed"
assert payload["sources"]["available"] is True
assert payload["admin"]["available"] is True
assert payload["registry"]["available"] is False
def test_wf_status_reports_rpc_config_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"client": {
"target": {
"kind": "rpc_http",
"url": "http://test/rpc",
}
},
}
),
encoding="utf-8",
)
result = CliRunner().invoke(
app,
[
"--config",
str(config_path),
"status",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["target"]["mode"] == "remote"
assert payload["target"]["url"] == "http://test/rpc"
assert payload["workflow"]["capability_count"] >= 1
def test_wf_draft_delete_requires_confirm(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
result = runner.invoke(app, [*base_args, "draft", "delete", "delete-me"])
assert result.exit_code != 0
assert "confirm" in (result.output).lower()
def test_wf_draft_delete_succeeds_with_confirm(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"delete-me",
"--capability",
"wf.std.constant",
"--name",
"delete_me_ws",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app, [*base_args, "draft", "delete", "delete-me", "--confirm"]
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["workspace_id"] == "delete-me"
assert payload["deleted"] is True
def test_wf_source_diagnose_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
result = runner.invoke(app, [*base_args, "source", "diagnose", "wf.std"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["source_id"] == "wf.std"
assert payload["status"] == "unknown"
def test_wf_local_uses_selected_config_sources(tmp_path: Path) -> None:
config_path = write_python_source_config(tmp_path)
result = CliRunner().invoke(
app,
[
"--config",
str(config_path),
"--local",
"cap",
"list",
"--source",
"local.ops",
"--limit",
"100",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert {capability["name"] for capability in payload["capabilities"]} == {
"local.ops.echo"
}
def test_wf_draft_create_reports_optional_inputs_without_binding(
tmp_path: Path,
) -> None:
config_path = write_python_source_config(tmp_path)
runner = CliRunner()
base_args = ["--config", str(config_path), "--local"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"echo_ws",
"--capability",
"local.ops.echo",
],
)
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "echo_ws", "--include-draft"],
)
assert created.exit_code == 0, created.output
assert inspected.exit_code == 0, inspected.output
created_payload = json.loads(created.output)
draft = json.loads(inspected.output)["draft"]
assert created_payload["wrapper_hints"]["input_map"] == {"input.text": "text"}
assert draft["steps"]["call"]["input"] == [{"path": "input.text", "target": "text"}]
assert any("path" in note for note in created_payload["wrapper_hints"]["notes"])
def test_wf_draft_set_input_bindings_preserves_composite_expression_over_rpc(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_calls.append((method, params))
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
bindings_path = tmp_path / "bindings.json"
bindings_path.write_text(
json.dumps(
[
{
"target": "items",
"expression": {
"kind": "array",
"items": [
{"kind": "path", "path": "state.foo"},
{"kind": "literal", "value": "wowcool"},
],
},
},
{"target": "separator", "value": " "},
]
),
encoding="utf-8",
)
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"composite_ws",
"--capability",
"wf.std.concat",
"--name",
"composite",
],
)
assert created.exit_code == 0, created.output
replaced = runner.invoke(
app,
[
*base_args,
"draft",
"set-input",
"composite_ws",
"--revision",
"1",
"--step",
"call",
"--bindings-file",
str(bindings_path),
],
)
assert replaced.exit_code == 0, replaced.output
assert [method for method, _params in rpc_calls].count(
"workflow.draft_workspaces.set_step_input_bindings"
) == 1
method, params = next(
(method, params)
for method, params in rpc_calls
if method == "workflow.draft_workspaces.set_step_input_bindings"
)
assert method == "workflow.draft_workspaces.set_step_input_bindings"
assert params["bindings"] == json.loads(bindings_path.read_text(encoding="utf-8"))
def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"focused_ws",
"--capability",
"wf.std.constant",
"--name",
"focused_initial",
],
)
assert created.exit_code == 0, created.output
named = runner.invoke(
app,
[
*base_args,
"draft",
"set-name",
"focused_ws",
"--revision",
"1",
"--name",
"focused_renamed",
],
)
routed = runner.invoke(
app,
[
*base_args,
"draft",
"set-route",
"focused_ws",
"--revision",
"2",
"--step",
"call",
"--outcome",
"ok",
"--to",
"__end__",
],
)
input_mapped = runner.invoke(
app,
[
*base_args,
"draft",
"set-input",
"focused_ws",
"--revision",
"3",
"--step",
"call",
"--value",
'value="seed"',
],
)
output_mapped = runner.invoke(
app,
[
*base_args,
"draft",
"set-output",
"focused_ws",
"--revision",
"4",
"--step",
"call",
"--map",
"value=state.primary",
],
)
input_merged = runner.invoke(
app,
[
*base_args,
"draft",
"set-input",
"focused_ws",
"--revision",
"5",
"--step",
"call",
"--map",
"input.extra=extra",
"--merge",
],
)
output_merged = runner.invoke(
app,
[
*base_args,
"draft",
"set-output",
"focused_ws",
"--revision",
"6",
"--step",
"call",
"--map",
"extra=state.extra",
"--merge",
],
)
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "focused_ws", "--include-draft"],
)
assert named.exit_code == 0, named.output
assert routed.exit_code == 0, routed.output
assert input_mapped.exit_code == 0, input_mapped.output
assert output_mapped.exit_code == 0, output_mapped.output
assert input_merged.exit_code == 0, input_merged.output
assert output_merged.exit_code == 0, output_merged.output
assert inspected.exit_code == 0, inspected.output
payload = json.loads(inspected.output)
draft = payload["draft"]
assert draft["name"] == "focused_renamed"
assert draft["routes"]["call"]["ok"] == "__end__"
assert draft["steps"]["call"]["input"] == [
{
"target": "value",
"value": "seed",
},
{
"target": "extra",
"path": "input.extra",
},
]
assert draft["steps"]["call"]["output"] == [
{
"source": "value",
"target": "state.primary",
},
{
"source": "extra",
"target": "state.extra",
},
]
def test_wf_draft_set_workflow_output_replaces_canonical_bindings_over_rpc(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_calls.append((method, params))
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
state_schema_path = tmp_path / "state-schema.json"
state_schema_path.write_text(
json.dumps(
{
"type": "object",
"properties": {"title": {"type": "string"}},
}
),
encoding="utf-8",
)
output_schema_path = tmp_path / "output-schema.json"
output_schema_path.write_text(
json.dumps(
{
"type": "object",
"properties": {"format": {"type": "string"}},
}
),
encoding="utf-8",
)
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"report",
"--name",
"report",
"--state-schema-file",
str(state_schema_path),
"--output-schema-file",
str(output_schema_path),
],
)
assert created.exit_code == 0, created.output
rpc_calls.clear()
result = runner.invoke(
app,
[
*base_args,
"draft",
"set-workflow-output",
"report",
"--revision",
"1",
"--map",
"state.title=report.title",
"--value",
'format="markdown"',
],
)
assert result.exit_code == 0, result.output
assert rpc_calls == [
(
"workflow.draft_workspaces.set_workflow_output_bindings",
{
"workspace_id": "report",
"revision": 1,
"bindings": [
{"path": "state.title", "target": "report.title"},
{"value": "markdown", "target": "format"},
],
},
)
]
def test_wf_draft_set_workflow_output_merge_uses_compatibility_rpc_target(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_methods: list[str] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_methods.append(method)
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"report",
"--capability",
"wf.std.constant",
],
)
result = runner.invoke(
app,
[
*base_args,
"draft",
"set-workflow-output",
"report",
"--revision",
"1",
"--map",
"state.markdown=markdown",
"--merge",
],
)
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "report", "--include-draft"],
)
assert created.exit_code == 0, created.output
assert result.exit_code == 0, result.output
assert inspected.exit_code == 0, inspected.output
draft = json.loads(inspected.output)["draft"]
assert draft["output"] == [{"path": "state.markdown", "target": "markdown"}]
assert rpc_methods[-2:] == [
"workflow.draft_workspaces.set_workflow_output_map",
"workflow.draft_workspaces.get",
]
def test_wf_draft_set_workflow_output_merge_reports_canonical_replacement(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_methods: list[str] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_methods.append(method)
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
bindings_path = tmp_path / "workflow-output-bindings.json"
bindings_path.write_text(
json.dumps(
[
{"path": "state.value", "target": "first"},
{"path": "state.value", "target": "second"},
]
),
encoding="utf-8",
)
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"report",
"--capability",
"wf.std.constant",
],
)
replaced = runner.invoke(
app,
[
*base_args,
"draft",
"set-workflow-output",
"report",
"--revision",
"1",
"--bindings-file",
str(bindings_path),
],
)
rpc_methods.clear()
merged = runner.invoke(
app,
[
*base_args,
"draft",
"set-workflow-output",
"report",
"--revision",
"2",
"--map",
"state.value=renamed",
"--merge",
],
)
assert created.exit_code == 0, created.output
assert replaced.exit_code == 0, replaced.output
assert merged.exit_code == 1
assert "complete canonical binding list" in merged.output
assert rpc_methods == ["workflow.draft_workspaces.set_workflow_output_map"]
def test_wf_draft_remove_route_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"remove_route_ws",
"--capability",
"wf.std.constant",
"--name",
"remove_route",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"remove-route",
"remove_route_ws",
"--revision",
"1",
"--step",
"call",
"--outcome",
"ok",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["revision"] == 2
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "remove_route_ws", "--include-draft"],
)
assert inspected.exit_code == 0, inspected.output
draft = json.loads(inspected.output)["draft"]
assert "ok" not in draft["routes"]["call"]
def test_wf_draft_bind_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"snapshot_ws",
"--capability",
"wf.std.constant",
"--name",
"snapshot",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"bind",
"snapshot_ws",
"--revision",
"1",
"--step",
"call",
"--from",
"local.value",
"--to",
"state.result",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["revision"] == 2
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "snapshot_ws", "--include-draft"],
)
assert inspected.exit_code == 0, inspected.output
draft = json.loads(inspected.output)["draft"]
assert draft["steps"]["call"]["output"] == [
{"source": "value", "target": "state.result"}
]
def test_wf_draft_set_input_preserves_nested_target_over_rpc(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"nested_input_ws",
"--capability",
"wf.std.constant",
"--name",
"nested_input",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"set-input",
"nested_input_ws",
"--revision",
"1",
"--step",
"call",
"--map",
"input.value=payload.value",
"--merge",
],
)
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "nested_input_ws", "--include-draft"],
)
assert result.exit_code == 0, result.output
assert inspected.exit_code == 0, inspected.output
draft = json.loads(inspected.output)["draft"]
assert draft["steps"]["call"]["input"] == [
{"target": "payload.value", "path": "input.value"}
]
def test_wf_draft_set_input_replaces_canonical_bindings_over_rpc(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_methods: list[str] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_methods.append(method)
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"filter_ws",
"--capability",
"wf.std.filter_items",
"--name",
"filter_items",
],
)
initial = runner.invoke(
app,
[*base_args, "draft", "inspect", "filter_ws", "--include-draft"],
)
assert created.exit_code == 0, created.output
assert initial.exit_code == 0, initial.output
bindings_path = tmp_path / "input-bindings.json"
initial_bindings = json.loads(initial.output)["draft"]["steps"]["call"]["input"]
bindings_path.write_text(json.dumps(initial_bindings), encoding="utf-8")
assert json.loads(bindings_path.read_text(encoding="utf-8")) == initial_bindings
replacement = [
{"path": "input.key", "target": "key"},
{"path": "input.key", "target": "value"},
{"value": [], "target": "items"},
]
bindings_path.write_text(json.dumps(replacement), encoding="utf-8")
replaced = runner.invoke(
app,
[
*base_args,
"draft",
"set-input",
"filter_ws",
"--revision",
"1",
"--step",
"call",
"--bindings-file",
str(bindings_path),
],
)
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "filter_ws", "--include-draft"],
)
assert replaced.exit_code == 0, replaced.output
assert json.loads(replaced.output)["revision"] == 2
assert inspected.exit_code == 0, inspected.output
assert json.loads(inspected.output)["draft"]["steps"]["call"]["input"] == [
{"target": "key", "path": "input.key"},
{"target": "value", "path": "input.key"},
{"target": "items", "value": []},
]
assert rpc_methods.count("workflow.draft_workspaces.set_step_input_bindings") == 1
rpc_methods.clear()
merged = runner.invoke(
app,
[
*base_args,
"draft",
"set-input",
"filter_ws",
"--revision",
"2",
"--step",
"call",
"--map",
"input.other=other",
"--merge",
],
)
assert merged.exit_code == 1
assert "complete canonical binding list" in merged.output
assert rpc_methods == ["workflow.draft_workspaces.set_step_input_map"]
def test_wf_draft_set_output_replaces_canonical_bindings_over_rpc(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_calls.append((method, params))
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
bindings_path = tmp_path / "output-bindings.json"
bindings_path.write_text(
json.dumps(
[
{"source": "value", "target": "state.report"},
{"source": "value", "target": "state.audit"},
]
),
encoding="utf-8",
)
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"output_bindings_ws",
"--capability",
"wf.std.constant",
"--name",
"output_bindings",
],
)
assert created.exit_code == 0, created.output
rpc_calls.clear()
replaced = runner.invoke(
app,
[
*base_args,
"draft",
"set-output",
"output_bindings_ws",
"--revision",
"1",
"--step",
"call",
"--bindings-file",
str(bindings_path),
],
)
assert replaced.exit_code == 0, replaced.output
assert [method for method, _params in rpc_calls] == [
"workflow.draft_workspaces.set_step_output_bindings"
]
assert rpc_calls[0][1]["bindings"] == [
{"source": "value", "target": "state.report"},
{"source": "value", "target": "state.audit"},
]
rpc_calls.clear()
merged = runner.invoke(
app,
[
*base_args,
"draft",
"set-output",
"output_bindings_ws",
"--revision",
"2",
"--step",
"call",
"--map",
"value=state.compat",
"--merge",
],
)
assert merged.exit_code == 1
assert "complete canonical binding list" in merged.output
assert [method for method, _params in rpc_calls] == [
"workflow.draft_workspaces.set_step_output_map"
]
def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_methods: list[str] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_methods.append(method)
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"add_step_ws",
"--capability",
"wf.std.constant",
"--name",
"add_step",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"add",
"capability",
"add_step_ws",
"--revision",
"1",
"--step",
"second",
"--capability",
"wf.std.constant",
"--from-step",
"call",
"--from-outcome",
"ok",
"--route",
"ok=__end__",
"--input",
"input.value=value",
"--bind-output",
"value=state.second_value",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["revision"] == 2
assert payload["status"] == "valid"
assert rpc_methods[-1] == "workflow.draft_workspaces.add_step_from_capability"
assert "workflow.draft_workspaces.add_step" not in rpc_methods
def test_wf_draft_capability_add_and_update_preserve_rpc_payloads(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_calls: list[tuple[str, dict[str, Any]]] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_calls.append((method, params))
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"capability_update_ws",
"--capability",
"wf.std.constant",
"--name",
"capability_update",
],
)
assert created.exit_code == 0, created.output
added = runner.invoke(
app,
[
*base_args,
"draft",
"add",
"capability",
"capability_update_ws",
"--revision",
"1",
"--step",
"join",
"--capability",
"wf.std.concat",
"--from-step",
"call",
"--input",
"input.value=items",
"--value",
'separator=","',
"--description",
"Join values",
"--retry",
"0",
"--timeout-seconds",
"5",
],
)
assert added.exit_code == 0, added.output
add_method, add_params = rpc_calls[-1]
assert add_method == "workflow.draft_workspaces.add_step_from_capability"
assert add_params["input_bindings"] == [
{"path": "input.value", "target": "items"},
{"value": ",", "target": "separator"},
]
assert "input_map" not in add_params
assert add_params["desc"] == "Join values"
assert add_params["retry"] == 0
assert add_params["timeout_seconds"] == 5
updated = runner.invoke(
app,
[
*base_args,
"draft",
"update",
"capability",
"capability_update_ws",
"--revision",
"2",
"--step",
"join",
"--clear-description",
"--clear-timeout",
],
)
assert updated.exit_code == 0, updated.output
update_method, update_params = rpc_calls[-1]
assert update_method == "workflow.draft_workspaces.update_capability_step"
assert update_params["update"] == {
"desc": None,
"timeout_seconds": None,
}
def test_wf_draft_add_control_steps_use_generic_rpc_target(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
rpc_methods: list[str] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_methods.append(method)
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
request_schema = tmp_path / "request.json"
request_schema.write_text(
'{"type":"object","properties":{"value":{"type":"string"}}}',
encoding="utf-8",
)
resume_schema = tmp_path / "resume.json"
resume_schema.write_text(
'{"type":"object","properties":{"decision":{"type":"string"}}}',
encoding="utf-8",
)
condition_file = tmp_path / "condition.json"
condition_file.write_text('{"op":"exists","path":"state.value"}', encoding="utf-8")
clauses_file = tmp_path / "clauses.json"
clauses_file.write_text(
'[{"if":{"op":"exists","path":"state.value"},"then":"call"}]',
encoding="utf-8",
)
cases_file = tmp_path / "cases.json"
cases_file.write_text(
'[{"equals":"ready","then":"call"},{"equals":null,"then":"__end__"}]',
encoding="utf-8",
)
cases = [
(
"interrupt",
[
"--kind",
"review",
"--request-schema-file",
str(request_schema),
"--resume-schema-file",
str(resume_schema),
"--request",
"input.value=value",
"--resume",
"decision=state.decision",
"--outcome",
"submitted",
"--outcome",
"cancelled",
"--route",
"submitted=__end__",
"--route",
"cancelled=__end__",
],
{
"kind": "review",
"request": [{"target": "value", "path": "input.value"}],
"resume": [{"source": "decision", "target": "state.decision"}],
"request_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": [],
},
"resume_schema": {
"type": "object",
"properties": {"decision": {"type": "string"}},
"required": [],
},
"outcomes": ["submitted", "cancelled"],
},
{"submitted": "__end__", "cancelled": "__end__"},
),
(
"foreach",
[
"--over",
"input.items",
"--as",
"item",
"--mode",
"concurrent",
"--item-error",
"collect",
"--collect-to",
"state.errors",
"--max-active",
"2",
"--max-outstanding",
"5",
"--route",
"loop=call",
"--route",
"done=__end__",
"--route",
"completed_with_errors=__end__",
],
{
"over": "input.items",
"as": "item",
"mode": "concurrent",
"item_error": {"action": "collect", "collect_to": "state.errors"},
"concurrent": {
"max_active": 2,
"max_outstanding": 5,
"interrupt": "quiesce",
},
},
{
"loop": "call",
"done": "__end__",
"completed_with_errors": "__end__",
},
),
("end", ["--outcome", "ok"], {"outcome": "ok"}, None),
(
"when",
[
"--condition-file",
str(condition_file),
"--then",
"call",
"--otherwise",
"__end__",
],
{
"if": {"op": "exists", "path": "state.value"},
"then": "call",
"otherwise": "__end__",
},
None,
),
(
"choose",
["--clauses-file", str(clauses_file), "--default", "__end__"],
{
"clauses": [
{
"if": {"op": "exists", "path": "state.value"},
"then": "call",
}
],
"default": "__end__",
},
None,
),
(
"match",
[
"--value",
"state.value",
"--cases-file",
str(cases_file),
"--default",
"__end__",
],
{
"value": "state.value",
"cases": [
{"equals": "ready", "then": "call"},
{"equals": None, "then": "__end__"},
],
"default": "__end__",
},
None,
),
(
"subgraph",
[
"--workflow-name",
"child",
"--input",
"input.value=value",
"--bind-output",
"value=state.child_value",
"--outcome",
"ok",
"--route",
"ok=__end__",
],
{
"workflow": {"name": "child"},
"input": [{"target": "value", "path": "input.value"}],
"output": [{"source": "value", "target": "state.child_value"}],
"outcomes": ["ok"],
},
{"ok": "__end__"},
),
]
for command, command_args, expected, expected_routes in cases:
workspace_id = f"add_{command}_ws"
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
workspace_id,
"--capability",
"wf.std.constant",
"--name",
f"add_{command}",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"add",
command,
workspace_id,
"--revision",
"1",
"--step",
command,
"--from-step",
"call",
*command_args,
],
)
assert result.exit_code == 0, result.output
assert json.loads(result.output)["revision"] == 2
assert rpc_methods[-1] == "workflow.draft_workspaces.add_step"
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", workspace_id, "--include-draft"],
)
assert inspected.exit_code == 0, inspected.output
persisted_step = json.loads(inspected.output)["draft"]["steps"][command]
draft = json.loads(inspected.output)["draft"]
actual_payload = persisted_step[command]
for field, value in expected.items():
assert actual_payload[field] == value
assert draft["routes"]["call"]["ok"] == command
if expected_routes is None:
assert command not in draft["routes"]
else:
assert draft["routes"][command] == expected_routes
def test_wf_draft_add_capability_reports_bare_output_target_without_traceback(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"add_step_ws",
"--capability",
"wf.std.constant",
"--name",
"add_step",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"add",
"capability",
"add_step_ws",
"--revision",
"1",
"--step",
"second",
"--capability",
"wf.std.constant",
"--bind-output",
"value=value",
],
)
assert result.exit_code != 0
assert "Traceback" not in result.output
assert "--bind-output" in result.output
assert "state.value" in result.output
def test_wf_draft_compile_prints_compiled_plan(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"compile_ws",
"--capability",
"wf.std.constant",
"--name",
"compile_me",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(app, [*base_args, "draft", "compile", "compile_ws"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["name"] == "compile_me"
assert "compiled_plan" not in payload
def test_wf_draft_compile_invalid_prints_diagnostics_to_stderr(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
asyncio.run(
server.api.create_draft_workspace(
workspace_id="invalid_compile_ws",
draft={
"name": "invalid_compile",
"input_schema": {"type": "object"},
"state_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}},
"start": "call",
"steps": {
"call": {
"use": "wf.std.constant",
"input": [],
"output": [],
}
},
"routes": {"call": {"typo": "__end__"}},
},
)
)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
result = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"draft",
"compile",
"invalid_compile_ws",
],
)
assert result.exit_code == 1
# This Typer test runner mixes stderr into output; the command implementation
# writes invalid compile diagnostics with err=True for real terminals.
assert '"status": "invalid"' in result.output
assert "compiled_plan" not in result.output
def test_wf_deploy_create_alias_saves_deployment(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
asyncio.run(
server.api.create_artifact_from_plan(
artifact_id="alias_artifact",
version=1,
title="Alias Artifact",
plan=_constant_plan(),
outcomes=("ok",),
)
)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
created = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"deploy",
"create",
"alias_artifact.default",
"--artifact",
"alias_artifact",
"--version",
"1",
],
)
assert created.exit_code == 0, created.output
payload = json.loads(created.output)
assert payload["deployment_id"] == "alias_artifact.default"
def test_wf_draft_forward_route_invalid_via_rpc(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store", drafts=True)
_patch_rpc_client_to_server(monkeypatch, server)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"fwd_ws",
"--capability",
"wf.std.constant",
"--name",
"forward_route",
],
)
assert created.exit_code == 0, created.output
result = runner.invoke(
app,
[
*base_args,
"draft",
"add",
"capability",
"fwd_ws",
"--revision",
"1",
"--step",
"second",
"--capability",
"wf.std.constant",
"--from-step",
"call",
"--route",
"ok=missing",
"--input",
"input.value=value",
"--bind-output",
"value=state.val",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["status"] == "invalid"
assert payload["revision"] == 2