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