2753 lines
81 KiB
Python
2753 lines
81 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
import httpx
|
|
from typer.testing import CliRunner
|
|
|
|
import wf_cli.context as cli_context
|
|
from wf_api.models import RawWorkflowPlan
|
|
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,
|
|
) -> dict[str, Any]:
|
|
return {"sources": [], "next_cursor": None, "total": 0}
|
|
|
|
async def inspect_source(self, *, source_id: str) -> dict[str, Any]:
|
|
raise RuntimeError(f"broken source admin for {source_id}")
|
|
|
|
async def diagnose_source(self, *, source_id: str) -> dict[str, Any]:
|
|
raise RuntimeError(f"broken source admin for {source_id}")
|
|
|
|
|
|
class InventorySourceAdmin:
|
|
async def list_sources(
|
|
self,
|
|
*,
|
|
cursor: str | None = None,
|
|
limit: int = 50,
|
|
) -> dict[str, Any]:
|
|
return {"sources": [], "next_cursor": None, "total": 0}
|
|
|
|
async def inspect_source(self, *, source_id: str) -> dict[str, Any]:
|
|
return {
|
|
"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) -> dict[str, Any]:
|
|
return {"source_id": source_id, "status": "ok"}
|
|
|
|
|
|
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=httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
|
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")
|
|
_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")
|
|
_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")
|
|
_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 httpx.ConnectError(
|
|
"connection refused",
|
|
request=httpx.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")
|
|
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")
|
|
_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")
|
|
_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",
|
|
"join",
|
|
"control_ws",
|
|
"--revision",
|
|
"1",
|
|
"--step",
|
|
"gate",
|
|
"--route",
|
|
"done=finish",
|
|
],
|
|
[
|
|
"draft",
|
|
"set-start",
|
|
"control_ws",
|
|
"--revision",
|
|
"2",
|
|
"--step",
|
|
"gate",
|
|
],
|
|
[
|
|
"draft",
|
|
"add",
|
|
"end",
|
|
"control_ws",
|
|
"--revision",
|
|
"3",
|
|
"--step",
|
|
"finish",
|
|
"--outcome",
|
|
"error",
|
|
],
|
|
[
|
|
"draft",
|
|
"set-contract",
|
|
"control_ws",
|
|
"--revision",
|
|
"4",
|
|
"--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"] == 5
|
|
assert payload["draft"]["start"] == "gate"
|
|
assert payload["draft"]["outcomes"] == ["error"]
|
|
assert set(payload["draft"]["steps"]) == {"gate", "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")
|
|
_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")
|
|
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["draft"]
|
|
_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")
|
|
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")
|
|
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")
|
|
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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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")
|
|
_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__",
|
|
},
|
|
),
|
|
("join", ["--route", "done=__end__"], {}, {"done": "__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")
|
|
_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")
|
|
_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")
|
|
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")
|
|
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")
|
|
_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
|