from __future__ import annotations from collections.abc import Mapping from dataclasses import replace from pathlib import Path from typing import Any, Literal, cast import pytest from pydantic import BaseModel, Field, TypeAdapter, ValidationError from tests.wf_mcp.test_support import echo_tool from tests.wf_mcp.workflow_surface.conftest import echo_artifact from wf_api.draft_authoring import RouteSource, WorkflowDraftAuthoringApi from wf_api.draft_updates import CapabilityStepUpdate from wf_api.drafts import WorkflowDraftApi from wf_api.models import ( AuthoringContractInventoryPayload, DraftWorkspaceResult, RawWorkflowPlan, ) from wf_api.service import WorkflowApi from wf_artifacts import FileDraftWorkspaceStore, FileWorkflowArtifactStore from wf_artifacts.drafts.models import DraftStep from wf_authoring import node from wf_core.models.steps import ( InputBinding, InputExpressionBinding, InputPathBinding, InputValueBinding, OutputBinding, ) from wf_core.paths import GraphSourcePath, LocalPath, StatePath from wf_mcp.broker import WfMcpService from wf_mcp.broker.service.workflow_operation_context import context_from_service from wf_mcp.models import ConnectionConfig from wf_mcp.storage import FileStore from wf_mcp.workflow_surface import WorkflowSurfaceHandlers def _required_draft(result: Mapping[str, Any]) -> dict[str, Any]: """Return a draft from a test result whose success path includes one.""" draft = result.get("draft") assert isinstance(draft, dict) return draft def _authoring_inventory( result: AuthoringContractInventoryPayload | DraftWorkspaceResult, ) -> AuthoringContractInventoryPayload: """Narrow a successful authoring inspection away from its conflict payload.""" assert "entry_steps" in result return cast(AuthoringContractInventoryPayload, result) def _compiled_workspace(result: Mapping[str, Any]) -> dict[str, Any]: """Return a compiled plan from a test case that established valid status.""" compiled_plan = result.get("compiled_plan") assert isinstance(compiled_plan, dict) return compiled_plan def test_capability_step_update_preserves_field_presence() -> None: update = CapabilityStepUpdate.model_validate( {"desc": None, "retry": 0, "input": []} ) assert update.model_fields_set == {"desc", "retry", "input"} assert update.desc is None assert update.retry == 0 assert update.input == [] @pytest.mark.parametrize( "payload", [ {}, {"input": None}, {"desc": ""}, {"retry": -1}, {"timeout_seconds": 0}, {"unknown": True}, ], ) def test_capability_step_update_rejects_invalid_patch(payload: object) -> None: with pytest.raises(ValidationError): CapabilityStepUpdate.model_validate(payload) @pytest.mark.asyncio async def test_update_capability_step_changes_metadata_and_inputs_atomically( tmp_path: Path, ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "update_capability"), register_echo=True, ) draft = _echo_draft() draft["steps"]["echo"].update( { "desc": "Old description", "retry": 1, "timeout_seconds": 10, } ) await draft_api.create_draft_workspace(workspace_id="echo", draft=draft) result = await authoring.update_capability_step( workspace_id="echo", revision=1, step_id="echo", update=CapabilityStepUpdate.model_validate( { "desc": "New description", "retry": 0, "timeout_seconds": None, "input": [{"value": "fixed", "target": "text"}], } ), ) inspected = await draft_api.get_draft_workspace( workspace_id="echo", include_draft=True, ) step = inspected["draft"]["steps"]["echo"] assert result["revision"] == 2 assert _required_draft(result)["steps"]["echo"]["desc"] == "New description" assert step["use"] == "demo.personal.echo_tool" assert step["desc"] == "New description" assert step["retry"] == 0 assert "timeout_seconds" not in step assert step["input"] == [{"value": "fixed", "target": "text"}] assert step["output"] == [{"source": "echoed", "target": "state.echoed"}] assert inspected["draft"]["routes"]["echo"] == {"ok": "__end__"} compiled = await draft_api.compile_draft_workspace(workspace_id="echo") run = await service.run_workflow_from_plan( RawWorkflowPlan.model_validate(_compiled_workspace(compiled)), {"text": "ignored"}, ) assert run.error is None assert run.output == {"echoed": "fixed"} @pytest.mark.asyncio async def test_inspect_draft_authoring_contract_projects_selected_capability( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "authoring_contract"), register_echo=True, ) draft = _echo_draft() draft["input_schema"] = { "type": "object", "properties": { "request": { "type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"], } }, "required": ["request"], } draft["state_schema"] = { "type": "object", "properties": {"echoed": {"type": "string"}}, } draft["output_schema"] = { "type": "object", "properties": {"echoed": {"type": "string"}}, } await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft) api = WorkflowApi(authoring.context) inventory = _authoring_inventory( await api.inspect_draft_authoring_contract( workspace_id="authoring", revision=1, selected_step_id="echo", ) ) assert inventory["workspace_id"] == "authoring" assert inventory["revision"] == 1 assert inventory["selected_step_id"] == "echo" assert [step["step_id"] for step in inventory["entry_steps"]] == ["echo"] assert inventory["workflow_outcomes"] == ["ok"] assert {option["path"] for option in inventory["step_input_targets"]} == { "step_input.text" } assert {option["path"] for option in inventory["step_output_sources"]} == { "step_output.echoed" } assert {option["path"] for option in inventory["readable_sources"]} >= { "input.request", "state.echoed", "context.prior_outcome", } assert "__end__" not in {step["step_id"] for step in inventory["entry_steps"]} selected = inventory["entry_steps"][0] assert selected.get("input_targets", [])[0]["schema"]["type"] == "string" assert selected.get("output_sources", [])[0]["schema"]["type"] == "string" assert selected.get("outcomes", []) == ["ok"] @pytest.mark.asyncio async def test_inspect_draft_authoring_contract_tolerates_invalid_workflow_schema( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "authoring_contract_invalid_schema"), register_echo=True, ) draft = _echo_draft() draft["input_schema"] = {"type": 7} draft["state_schema"] = { "type": "object", "properties": {"echoed": {"type": "string"}}, } await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft) api = WorkflowApi(authoring.context) inventory = _authoring_inventory( await api.inspect_draft_authoring_contract( workspace_id="authoring", revision=1, selected_step_id="echo", ) ) assert inventory["readable_sources"] assert all( option["origin"] != "workflow_input" for option in inventory["readable_sources"] ) assert {option["path"] for option in inventory["state_targets"]} == {"state.echoed"} assert {option["path"] for option in inventory["workflow_output_targets"]} == { "output.echoed" } assert any("input_schema" in warning for warning in inventory["warnings"]) @pytest.mark.asyncio async def test_inspect_draft_authoring_contract_resolves_saved_wrapper_capability( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "authoring_contract_wrapper") artifact_store.save_artifact( echo_artifact().model_copy(update={"id": "echo_wrapper", "kind": "wrapper"}) ) draft_api, _service, authoring = _draft_api(artifact_store) draft = _echo_draft() draft["steps"]["echo"]["use"] = "workflow.echo_wrapper.v1" draft["state_schema"] = { "type": "object", "properties": {"echoed": {"type": "string"}}, } await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft) api = WorkflowApi(authoring.context) inventory = _authoring_inventory( await api.inspect_draft_authoring_contract( workspace_id="authoring", revision=1, selected_step_id="echo", ) ) assert [step["step_id"] for step in inventory["entry_steps"]] == ["echo"] assert {option["path"] for option in inventory["step_input_targets"]} == { "step_input.text" } assert {option["path"] for option in inventory["step_output_sources"]} == { "step_output.echoed" } assert inventory["entry_steps"][0].get("outcomes", []) == ["completed"] @pytest.mark.asyncio async def test_inspect_draft_authoring_contract_preserves_empty_capability_schemas( tmp_path: Path, ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "authoring_contract_empty_schema") ) service.register_connection( ConnectionConfig(id="demo.personal", server="demo", account="personal") ) service.register_specs( "demo.personal", replace(echo_tool, input_schema_contract={}, output_schema_contract={}), ) draft = _echo_draft() draft["state_schema"] = { "type": "object", "properties": {"echoed": {"type": "string"}}, } await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft) api = WorkflowApi(authoring.context) inventory = _authoring_inventory( await api.inspect_draft_authoring_contract( workspace_id="authoring", revision=1, selected_step_id="echo", ) ) assert inventory["entry_steps"][0].get("input_targets", []) == [] assert inventory["entry_steps"][0].get("output_sources", []) == [] assert inventory["step_input_targets"] == [] assert inventory["step_output_sources"] == [] @pytest.mark.asyncio @pytest.mark.parametrize( "invalid_property_schema", [ {"$ref": "https://example.com/request.json"}, {"$ref": 7}, ], ) async def test_inspect_draft_authoring_contract_warns_for_invalid_capability_schema( tmp_path: Path, invalid_property_schema: dict[str, object], ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "authoring_contract_bad_capability") ) service.register_connection( ConnectionConfig(id="demo.personal", server="demo", account="personal") ) service.register_specs( "demo.personal", replace( echo_tool, input_schema_contract={ "type": "object", "properties": {"text": invalid_property_schema}, }, ), ) await draft_api.create_draft_workspace( workspace_id="authoring", draft=_echo_draft(), ) api = WorkflowApi(authoring.context) inventory = _authoring_inventory( await api.inspect_draft_authoring_contract( workspace_id="authoring", revision=1, selected_step_id="echo", ) ) assert inventory["entry_steps"][0].get("input_targets", []) == [] assert inventory["entry_steps"][0].get("output_sources", []) assert inventory["step_input_targets"] == [] assert inventory["step_output_sources"] assert any( "echo" in warning and "input schema" in warning for warning in inventory["warnings"] ) @pytest.mark.asyncio async def test_inspect_draft_authoring_contract_rejects_unknown_selected_step( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "authoring_contract_unknown"), register_echo=True, ) await draft_api.create_draft_workspace( workspace_id="authoring", draft=_echo_draft() ) api = WorkflowApi(authoring.context) with pytest.raises(KeyError, match="unknown draft step"): await api.inspect_draft_authoring_contract( workspace_id="authoring", revision=1, selected_step_id="missing", ) @pytest.mark.asyncio async def test_inspect_draft_authoring_contract_tolerates_invalid_selected_step( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "authoring_contract_invalid"), register_echo=True, ) draft = _echo_draft() draft["steps"] = {"broken": {"unknown_kind": {}}} draft["start"] = "broken" await draft_api.create_draft_workspace(workspace_id="authoring", draft=draft) api = WorkflowApi(authoring.context) inventory = _authoring_inventory( await api.inspect_draft_authoring_contract( workspace_id="authoring", revision=1, selected_step_id="broken", ) ) assert inventory["selected_step_id"] == "broken" assert inventory["entry_steps"] == [] assert inventory["step_input_targets"] == [] assert inventory["step_output_sources"] == [] assert inventory["readable_sources"] assert any("broken" in warning for warning in inventory["warnings"]) @pytest.mark.asyncio async def test_inspect_draft_authoring_contract_stale_revision_is_read_only( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "authoring_contract_stale"), register_echo=True, ) await draft_api.create_draft_workspace( workspace_id="authoring", draft=_echo_draft() ) api = WorkflowApi(authoring.context) changed = await api.set_draft_name( workspace_id="authoring", revision=1, name="changed", ) before = await api.get_draft_workspace( workspace_id="authoring", include_draft=True, ) conflict = await api.inspect_draft_authoring_contract( workspace_id="authoring", revision=1, selected_step_id="echo", ) after = await api.get_draft_workspace( workspace_id="authoring", include_draft=True, ) assert changed["revision"] == 2 assert "status" in conflict assert conflict["status"] == "conflict" assert conflict["revision"] == 2 assert conflict["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_update_capability_step_preserves_omitted_fields_and_exact_noop( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "update_capability_noop"), register_echo=True, ) draft = _echo_draft() draft["steps"]["echo"].update({"desc": "Keep", "retry": 2, "timeout_seconds": 15}) await draft_api.create_draft_workspace(workspace_id="echo", draft=draft) first = await authoring.update_capability_step( workspace_id="echo", revision=1, step_id="echo", update=CapabilityStepUpdate( retry=2, input=[ InputPathBinding( path=GraphSourcePath.input("text"), target=LocalPath.of("text"), ) ], ), ) second = await authoring.update_capability_step( workspace_id="echo", revision=first["revision"], step_id="echo", update=CapabilityStepUpdate(desc=None), ) inspected = await draft_api.get_draft_workspace( workspace_id="echo", include_draft=True, ) assert first["revision"] == 1 assert second["revision"] == 2 step = inspected["draft"]["steps"]["echo"] assert "desc" not in step assert step["retry"] == 2 assert step["timeout_seconds"] == 15 @pytest.mark.asyncio async def test_update_capability_step_removes_stored_null_metadata( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "update_capability_null_noop"), register_echo=True, ) await draft_api.create_draft_workspace(workspace_id="echo", draft=_echo_draft()) before = await draft_api.get_draft_workspace( workspace_id="echo", include_draft=True, ) assert before["draft"]["steps"]["echo"]["retry"] is None result = await authoring.update_capability_step( workspace_id="echo", revision=1, step_id="echo", update=CapabilityStepUpdate(retry=None), ) after = await draft_api.get_draft_workspace( workspace_id="echo", include_draft=True, ) assert result["revision"] == 2 assert "retry" not in after["draft"]["steps"]["echo"] @pytest.mark.asyncio async def test_update_capability_step_input_preserves_omitted_null_metadata( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "update_capability_null_metadata"), register_echo=True, ) draft = _echo_draft() draft["steps"]["echo"].update( { "desc": None, "retry": None, "timeout_seconds": None, } ) await draft_api.create_draft_workspace(workspace_id="echo", draft=draft) result = await authoring.update_capability_step( workspace_id="echo", revision=1, step_id="echo", update=CapabilityStepUpdate( input=[ InputValueBinding( target=LocalPath.of("text"), value="replacement", ) ] ), ) inspected = await draft_api.get_draft_workspace( workspace_id="echo", include_draft=True, ) step = inspected["draft"]["steps"]["echo"] assert result["revision"] == 2 assert step["desc"] is None assert step["retry"] is None assert step["timeout_seconds"] is None assert step["input"] == [{"value": "replacement", "target": "text"}] @pytest.mark.asyncio async def test_update_capability_step_metadata_does_not_resolve_capability( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "update_capability_metadata"), register_echo=True, ) await draft_api.create_draft_workspace(workspace_id="echo", draft=_echo_draft()) def fail_lookup(_provider: object, _capability_name: str) -> None: raise AssertionError("metadata-only update resolved the capability") monkeypatch.setattr( type(authoring.context.specs), "get_qualified_spec", fail_lookup, ) result = await authoring.update_capability_step( workspace_id="echo", revision=1, step_id="echo", update=CapabilityStepUpdate(desc="Metadata only"), ) assert result["revision"] == 2 @pytest.mark.asyncio async def test_update_capability_step_metadata_preserves_invalid_draft_shape( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "update_invalid_draft_shape"), register_echo=True, ) draft = _echo_draft() draft["routes"]["echo"]["ok"] = "missing" await draft_api.create_draft_workspace(workspace_id="echo", draft=draft) before = await draft_api.get_draft_workspace( workspace_id="echo", include_draft=True, ) result = await authoring.update_capability_step( workspace_id="echo", revision=1, step_id="echo", update=CapabilityStepUpdate(desc="Explain the invalid draft"), ) after = await draft_api.get_draft_workspace( workspace_id="echo", include_draft=True, ) assert result["revision"] == 2 assert after["draft"]["steps"]["echo"]["desc"] == "Explain the invalid draft" assert ( after["draft"]["steps"]["echo"]["input"] == before["draft"]["steps"]["echo"]["input"] ) assert ( after["draft"]["steps"]["echo"]["output"] == before["draft"]["steps"]["echo"]["output"] ) assert after["draft"]["routes"] == before["draft"]["routes"] @pytest.mark.asyncio async def test_update_capability_step_stale_revision_wins_over_semantic_errors( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "update_capability_stale"), register_echo=True, ) await draft_api.create_draft_workspace(workspace_id="echo", draft=_echo_draft()) result = await authoring.update_capability_step( workspace_id="echo", revision=2, step_id="missing", update=CapabilityStepUpdate( input=[InputValueBinding(target=LocalPath.of("missing"), value="bad")] ), ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" @pytest.mark.asyncio async def test_update_capability_step_rejects_wrong_kind_and_invalid_input_atomically( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "update_capability_invalid"), register_echo=True, ) draft = _echo_draft() draft["steps"]["joined"] = {"join": ["echo"]} await draft_api.create_draft_workspace(workspace_id="echo", draft=draft) before = await draft_api.get_draft_workspace( workspace_id="echo", include_draft=True, ) with pytest.raises(ValueError, match="not capability-backed"): await authoring.update_capability_step( workspace_id="echo", revision=1, step_id="joined", update=CapabilityStepUpdate(desc="No"), ) with pytest.raises(ValueError, match=r"bindings\[0\]\.target"): await authoring.update_capability_step( workspace_id="echo", revision=1, step_id="echo", update=CapabilityStepUpdate( desc="Must not persist", input=[ InputValueBinding( target=LocalPath.of("missing"), value="bad", ) ], ), ) after = await draft_api.get_draft_workspace( workspace_id="echo", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_add_step_from_capability_accepts_metadata_and_canonical_inputs( tmp_path: Path, ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "add_capability_parity"), register_echo=True, ) service.register_specs("demo.personal", _structured_report) draft = _structured_report_draft() draft["steps"] = {} draft["routes"] = {} await draft_api.create_draft_workspace(workspace_id="report", draft=draft) result = await authoring.add_step_from_capability( workspace_id="report", revision=1, step_id="report", capability_name="demo.personal.structured_report", routes={"ok": "__end__"}, desc="Publish report", retry=0, timeout_seconds=30, input_bindings=[ InputPathBinding( path=GraphSourcePath.state("report", "title"), target=LocalPath.of("request", "title"), ), InputValueBinding( target=LocalPath.of("request", "format"), value="markdown", ), ], ) inspected = await draft_api.get_draft_workspace( workspace_id="report", include_draft=True, ) step = inspected["draft"]["steps"]["report"] assert result["revision"] == 2 assert _required_draft(result)["steps"]["report"]["desc"] == "Publish report" assert step["desc"] == "Publish report" assert step["retry"] == 0 assert step["timeout_seconds"] == 30 assert step["input"] == [ {"path": "state.report.title", "target": "request.title"}, {"value": "markdown", "target": "request.format"}, ] assert ( inspected["draft"]["state_schema"]["properties"]["report"]["properties"][ "title" ]["type"] == "string" ) @pytest.mark.asyncio async def test_add_step_from_capability_rejects_both_input_forms( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "add_capability_exclusive"), register_echo=True, ) await draft_api.create_draft_workspace(workspace_id="echo", draft=_echo_draft()) with pytest.raises(ValueError, match="mutually exclusive"): await authoring.add_step_from_capability( workspace_id="echo", revision=1, step_id="other", capability_name="demo.personal.echo_tool", routes={"ok": "__end__"}, input_map={}, input_bindings=[], ) @pytest.mark.asyncio @pytest.mark.parametrize( ("desc", "retry", "timeout_seconds"), [ ("", None, None), (None, -1, None), (None, None, 0), ], ) async def test_add_step_from_capability_rejects_invalid_metadata_atomically( tmp_path: Path, desc: str | None, retry: int | None, timeout_seconds: int | None, ) -> None: workspace_id = f"invalid_add_metadata_{desc}_{retry}_{timeout_seconds}" draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / workspace_id), register_echo=True, ) await draft_api.create_draft_workspace( workspace_id=workspace_id, draft=_echo_draft(), ) before = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) with pytest.raises(ValidationError): await authoring.add_step_from_capability( workspace_id=workspace_id, revision=1, step_id="other", capability_name="demo.personal.echo_tool", routes={"ok": "__end__"}, desc=desc, retry=retry, timeout_seconds=timeout_seconds, ) after = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) assert after == before def _echo_draft() -> dict[str, Any]: return { "name": "echo", "input_schema": { "type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"], }, "state_schema": {"fields": {"echoed": {"type": "string"}}}, "output_schema": { "type": "object", "properties": {"echoed": {"type": "string"}}, "required": ["echoed"], }, "start": "echo", "steps": { "echo": { "use": "demo.personal.echo_tool", "input": [ { "target": {"root": "local", "parts": ["text"]}, "path": {"root": "input", "parts": ["text"]}, } ], "output": [ { "source": {"root": "local", "parts": ["echoed"]}, "target": {"root": "state", "parts": ["echoed"]}, } ], } }, "routes": {"echo": {"ok": "__end__"}}, } class _Snapshot(BaseModel): clicked: bool class _SnapshotOutput(BaseModel): after: _Snapshot class _SnapshotInput(BaseModel): pass @node(name="snapshot_tool", outcomes=("ok", "skipped")) def _snapshot_tool(payload: _SnapshotInput) -> _SnapshotOutput: return _SnapshotOutput(after=_Snapshot(clicked=True)) class _ReportInputValue(BaseModel): title: str class _NestedReportInput(BaseModel): report: _ReportInputValue class _ReportOutputValue(BaseModel): title: str markdown: str class _NestedReportOutput(BaseModel): report: _ReportOutputValue @node(name="nested_report", outcomes=("ok",)) def _nested_report(payload: _NestedReportInput) -> _NestedReportOutput: return _NestedReportOutput( report=_ReportOutputValue( title=payload.report.title, markdown=f"# {payload.report.title}", ) ) def _nested_report_draft() -> dict[str, Any]: return { "name": "nested_report", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "render", "steps": { "render": { "use": "demo.personal.nested_report", "input": [], "output": [], } }, "routes": {"render": {"ok": "__end__"}}, } class _StructuredRequest(BaseModel): title: str body: str format: Literal["markdown", "json"] note: str | None = None class _StructuredAudit(BaseModel): title: str = "" class _StructuredReportInput(BaseModel): request: _StructuredRequest audit: _StructuredAudit = Field(default_factory=_StructuredAudit) class _StructuredReportOutput(BaseModel): rendered: str @node(name="structured_report", outcomes=("ok",)) def _structured_report(payload: _StructuredReportInput) -> _StructuredReportOutput: return _StructuredReportOutput( rendered=( f"{payload.request.title}|{payload.request.body}|{payload.request.format}" ) ) def _structured_report_draft( capability_name: str = "demo.personal.structured_report", ) -> dict[str, Any]: return { "name": "structured_report", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "report", "steps": { "report": { "use": capability_name, "input": [], "output": [], } }, "routes": {"report": {"ok": "__end__"}}, } def _draft_api( artifact_store: FileWorkflowArtifactStore, *, register_echo: bool = False, ) -> tuple[WorkflowDraftApi, WfMcpService, WorkflowDraftAuthoringApi]: mcp_root = artifact_store.root / "drafts_mcp" / str(id(artifact_store)) service = WfMcpService( store=FileStore(mcp_root), artifact_store=artifact_store, draft_workspace_store=FileDraftWorkspaceStore(mcp_root), ) if register_echo: service.register_connection( ConnectionConfig(id="demo.personal", server="demo", account="personal") ) service.register_specs("demo.personal", echo_tool) context = context_from_service(service) return ( WorkflowDraftApi(context), service, WorkflowDraftAuthoringApi(context, WorkflowDraftApi(context)), ) async def _create_structured_binding_api( tmp_path: Path, workspace_id: str, ) -> tuple[WorkflowDraftApi, WfMcpService, WorkflowApi]: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / workspace_id), register_echo=True, ) service.register_specs("demo.personal", _structured_report) await draft_api.create_draft_workspace( workspace_id=workspace_id, draft=_structured_report_draft(), ) return draft_api, service, WorkflowApi(authoring.context) async def _create_nested_output_binding_api( tmp_path: Path, workspace_id: str, ) -> tuple[WorkflowDraftApi, WfMcpService, WorkflowApi]: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / workspace_id), register_echo=True, ) service.register_specs( "demo.personal", replace( _nested_report, output_schema_contract={ "type": "object", "properties": { "report": { "type": "object", "properties": { "title": {"type": "string"}, "markdown": {"type": "string"}, }, "required": ["title", "markdown"], } }, "required": ["report"], }, ), ) await draft_api.create_draft_workspace( workspace_id=workspace_id, draft=_nested_report_draft(), ) return draft_api, service, WorkflowApi(authoring.context) @pytest.mark.asyncio async def test_patch_draft_applies_json_patch(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) result = await api.patch_draft( draft=_echo_draft(), patch=[ { "op": "replace", "path": "/steps/echo/input/0/target/parts/0", "value": "message", } ], ) assert result["status"] == "invalid" assert _required_draft(result)["steps"]["echo"]["input"][0]["target"] == { "root": "local", "parts": ["message"], } @pytest.mark.asyncio async def test_patch_draft_projects_valid_applied_document(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_valid") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) result = await api.patch_draft( draft=_echo_draft(), patch=[{"op": "replace", "path": "/name", "value": "renamed_echo"}], ) assert result["status"] == "valid" assert result["draft"]["name"] == "renamed_echo" assert result["compiled_plan"]["name"] == "renamed_echo" @pytest.mark.asyncio async def test_patch_draft_malformed_patch_has_no_draft(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_malformed") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) result = await api.patch_draft( draft=_echo_draft(), patch=[{"op": "remove", "path": "/missing"}], ) assert result["status"] == "invalid" assert result["diagnostics"][0]["code"] == "patch_invalid" assert "draft" not in result @pytest.mark.asyncio async def test_create_draft_workspace_creates_workspace(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_workspace") api, _service, _authoring = _draft_api(artifact_store) result = await api.create_draft_workspace( workspace_id="echo_ws", title="Echo Workspace", draft=_echo_draft(), ) assert result["workspace_id"] == "echo_ws" assert result["revision"] == 1 assert _required_draft(result)["steps"]["echo"]["use"] == "demo.personal.echo_tool" fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True) assert fetched["workspace_id"] == "echo_ws" assert fetched["title"] == "Echo Workspace" assert fetched["draft"]["steps"]["echo"]["use"] == "demo.personal.echo_tool" @pytest.mark.asyncio async def test_invalid_summary_values_remain_inspectable_and_listable( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "invalid_summary_values") api, _service, _authoring = _draft_api(artifact_store) draft = _echo_draft() draft["name"] = 123 draft["start"] = ["echo"] created = await api.create_draft_workspace( workspace_id="repairable", draft=draft, ) listed = await api.list_draft_workspaces() assert created["status"] == "invalid" assert created["summary"]["name"] == 123 assert created["summary"]["start"] == ["echo"] assert listed["workspaces"][0]["workspace_id"] == "repairable" @pytest.mark.asyncio async def test_create_empty_draft_workspace_persists_invalid_skeleton( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_empty") _drafts, _service, authoring = _draft_api(artifact_store) facade = WorkflowApi(authoring.context) created = await facade.create_empty_draft_workspace( workspace_id="control_first", name="control_first", title="Control First", ) stored = await facade.get_draft_workspace( workspace_id="control_first", include_draft=True, ) assert created["revision"] == 1 assert created["status"] == "invalid" assert created["diagnostics"] assert stored["title"] == "Control First" assert stored["draft"] == { "name": "control_first", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "outcomes": ["ok"], "output": [], "start": "", "steps": {}, "routes": {}, } @pytest.mark.asyncio async def test_create_empty_draft_workspace_preserves_custom_contract( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_contract") _drafts, _service, authoring = _draft_api(artifact_store) facade = WorkflowApi(authoring.context) input_schema = { "type": "object", "properties": {"topic": {"type": "string"}}, } state_schema = { "type": "object", "properties": { "issues": { "type": "array", "items": {"type": "string"}, "x-reducer": "append", } }, } output_schema = { "type": "object", "properties": {"report": {"type": "string"}}, } await facade.create_empty_draft_workspace( workspace_id="custom_contract", name="custom_contract", title="Custom Contract", input_schema=input_schema, state_schema=state_schema, output_schema=output_schema, outcomes=("submitted", "cancelled"), ) input_schema["properties"]["late"] = {"type": "boolean"} stored = await facade.get_draft_workspace( workspace_id="custom_contract", include_draft=True, ) assert stored["title"] == "Custom Contract" assert stored["draft"]["input_schema"] == { "type": "object", "properties": {"topic": {"type": "string"}}, } assert stored["draft"]["state_schema"] == state_schema assert stored["draft"]["output_schema"] == output_schema assert stored["draft"]["outcomes"] == ["submitted", "cancelled"] @pytest.mark.asyncio async def test_create_empty_draft_workspace_isolates_default_schemas( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_schema_isolation") _drafts, _service, authoring = _draft_api(artifact_store) facade = WorkflowApi(authoring.context) input_schema = { "type": "object", "properties": {"topic": {"type": "string"}}, } await facade.create_empty_draft_workspace( workspace_id="isolated", name="isolated", input_schema=input_schema, ) input_schema["properties"]["late"] = {"type": "boolean"} stored = await facade.get_draft_workspace( workspace_id="isolated", include_draft=True, ) stored["draft"]["state_schema"]["properties"]["state_only"] = {"type": "string"} assert stored["draft"]["input_schema"] == { "type": "object", "properties": {"topic": {"type": "string"}}, } assert stored["draft"]["output_schema"] == { "type": "object", "properties": {}, } @pytest.mark.asyncio async def test_create_empty_draft_workspace_reports_duplicate_conflict( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_conflict") _drafts, _service, authoring = _draft_api(artifact_store) facade = WorkflowApi(authoring.context) await facade.create_empty_draft_workspace( workspace_id="control_first", name="control_first", ) duplicate = await facade.create_empty_draft_workspace( workspace_id="control_first", name="replacement", ) assert duplicate["status"] == "conflict" assert duplicate["diagnostics"][0]["code"] == "workspace_exists" @pytest.mark.asyncio @pytest.mark.parametrize( "contract", [ {"input_schema": cast(Any, [])}, {"input_schema": {"type": 5}}, {"outcomes": ()}, {"outcomes": cast(Any, (1,))}, {"outcomes": ("ok", " ")}, {"outcomes": ("ok", "ok")}, {"outcomes": ("ok", " ok ")}, ], ) async def test_create_empty_draft_workspace_rejects_invalid_contract_before_mutation( tmp_path: Path, contract: dict[str, Any], ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_create_rejected") _drafts, _service, authoring = _draft_api(artifact_store) facade = WorkflowApi(authoring.context) with pytest.raises(ValueError): await facade.create_empty_draft_workspace( workspace_id="rejected", name="rejected", **contract, ) assert await facade.list_draft_workspaces() == {"workspaces": []} @pytest.mark.asyncio async def test_set_draft_start_and_contract_replace_top_level_fields_atomically( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_set_lifecycle") _drafts, _service, authoring = _draft_api(artifact_store) facade = WorkflowApi(authoring.context) await facade.create_empty_draft_workspace( workspace_id="control_first", name="control_first", input_schema={ "type": "object", "properties": {"topic": {"type": "string"}}, }, ) state_schema = { "type": "object", "properties": { "issues": { "type": "array", "items": {"type": "string"}, "x-reducer": "append", } }, } output_schema = { "type": "object", "properties": {"report": {"type": "string"}}, } forward = await facade.set_draft_start( workspace_id="control_first", revision=1, step_id="gate", ) contract = await facade.set_draft_contract( workspace_id="control_first", revision=2, state_schema=state_schema, output_schema=output_schema, outcomes=("submitted", "cancelled"), ) stored = await facade.get_draft_workspace( workspace_id="control_first", include_draft=True, ) assert forward["revision"] == 2 assert forward["status"] == "invalid" assert contract["revision"] == 3 assert stored["draft"]["start"] == "gate" assert stored["draft"]["input_schema"] == { "type": "object", "properties": {"topic": {"type": "string"}}, } assert stored["draft"]["state_schema"] == state_schema assert stored["draft"]["output_schema"] == output_schema assert stored["draft"]["outcomes"] == ["submitted", "cancelled"] @pytest.mark.asyncio @pytest.mark.parametrize( ("operation", "arguments"), [ ("start", {"step_id": " "}), ("start", {"step_id": cast(Any, 1)}), ("contract", {}), ("contract", {"state_schema": cast(Any, [])}), ("contract", {"outcomes": ()}), ("contract", {"outcomes": cast(Any, (1,))}), ("contract", {"outcomes": ("ok", " ")}), ("contract", {"outcomes": ("ok", "ok")}), ], ) async def test_lifecycle_edits_reject_invalid_envelopes_without_mutation( tmp_path: Path, operation: str, arguments: dict[str, Any], ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / f"drafts_lifecycle_rejected_{operation}" ) _drafts, _service, authoring = _draft_api(artifact_store) facade = WorkflowApi(authoring.context) await facade.create_empty_draft_workspace( workspace_id="control_first", name="control_first", ) before = await facade.get_draft_workspace( workspace_id="control_first", include_draft=True, ) with pytest.raises(ValueError): if operation == "start": await facade.set_draft_start( workspace_id="control_first", revision=2, **arguments, ) else: await facade.set_draft_contract( workspace_id="control_first", revision=2, **arguments, ) after = await facade.get_draft_workspace( workspace_id="control_first", include_draft=True, ) assert after == before @pytest.mark.asyncio @pytest.mark.parametrize("operation", ["start", "contract"]) async def test_lifecycle_edits_report_stale_revision_without_mutation( tmp_path: Path, operation: str, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / f"drafts_lifecycle_stale_{operation}" ) _drafts, _service, authoring = _draft_api(artifact_store) facade = WorkflowApi(authoring.context) await facade.create_empty_draft_workspace( workspace_id="control_first", name="control_first", ) before = await facade.get_draft_workspace( workspace_id="control_first", include_draft=True, ) if operation == "start": result = await facade.set_draft_start( workspace_id="control_first", revision=2, step_id="gate", ) else: result = await facade.set_draft_contract( workspace_id="control_first", revision=2, outcomes=("submitted", "cancelled"), ) after = await facade.get_draft_workspace( workspace_id="control_first", include_draft=True, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_list_draft_workspaces_returns_sorted_summaries_without_drafts( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_list_workspaces") api, _service, _authoring = _draft_api(artifact_store) await api.create_draft_workspace( workspace_id="b_draft", title="B Draft", draft=_echo_draft(), ) await api.create_draft_workspace( workspace_id="a_draft", title="A Draft", draft=_echo_draft(), ) result = await api.list_draft_workspaces() assert [workspace["workspace_id"] for workspace in result["workspaces"]] == [ "a_draft", "b_draft", ] assert result["workspaces"][0]["title"] == "A Draft" assert "draft" not in result["workspaces"][0] @pytest.mark.asyncio async def test_delete_draft_workspace_is_idempotent(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_delete_workspace") api, _service, _authoring = _draft_api(artifact_store) await api.create_draft_workspace( workspace_id="echo_ws", draft=_echo_draft(), ) deleted = await api.delete_draft_workspace(workspace_id="echo_ws") deleted_again = await api.delete_draft_workspace(workspace_id="echo_ws") listed = await api.list_draft_workspaces() assert deleted["workspace_id"] == "echo_ws" assert deleted["deleted"] is True assert deleted["status"] == "deleted" assert deleted_again["workspace_id"] == "echo_ws" assert deleted_again["deleted"] is False assert deleted_again["status"] == "not_found" assert listed["workspaces"] == [] @pytest.mark.asyncio async def test_patch_draft_workspace_updates_revision(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_workspace") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="echo_ws", draft=_echo_draft(), ) patched = await api.patch_draft_workspace( workspace_id="echo_ws", revision=1, patch=[{"op": "replace", "path": "/name", "value": "echo_v2"}], ) assert patched["revision"] == 2 assert patched["status"] == "valid" @pytest.mark.asyncio async def test_patch_draft_workspace_stale_revision_does_not_mutate( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / "drafts_patch_workspace_stale" ) api, _service, _authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="echo_ws", draft=_echo_draft(), ) before = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) result = await api.patch_draft_workspace( workspace_id="echo_ws", revision=2, patch=[{"op": "replace", "path": "/name", "value": "must_not_apply"}], ) after = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_draft_workspace_patch_helpers_update_revision_and_bindings( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helpers") api, _service, _authoring = _draft_api(artifact_store) await api.create_draft_workspace( workspace_id="echo_ws", draft=_echo_draft(), ) named = await api.set_draft_name( workspace_id="echo_ws", revision=1, name="echo_v2", ) routed = await api.set_draft_route( workspace_id="echo_ws", revision=2, step_id="echo", outcome="error", target="__end__", ) input_mapped = await api.set_step_input_map( workspace_id="echo_ws", revision=3, step_id="echo", input_map={"input.text": "message"}, ) output_mapped = await api.set_step_output_map( workspace_id="echo_ws", revision=4, step_id="echo", output_map={"echoed": "state.echoed"}, ) fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True) assert named["revision"] == 2 assert routed["revision"] == 3 assert input_mapped["revision"] == 4 assert output_mapped["revision"] == 5 assert _required_draft(routed)["routes"]["echo"]["error"] == "__end__" assert fetched["draft"]["name"] == "echo_v2" assert fetched["draft"]["routes"]["echo"]["error"] == "__end__" assert fetched["draft"]["steps"]["echo"]["input"] == [ { "target": "message", "path": "input.text", } ] assert fetched["draft"]["steps"]["echo"]["output"] == [ { "source": "echoed", "target": "state.echoed", } ] @pytest.mark.asyncio async def test_step_map_helpers_merge_with_existing_bindings(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_helper_merge") api, _service, _authoring = _draft_api(artifact_store) await api.create_draft_workspace( workspace_id="echo_ws", draft=_echo_draft(), ) input_mapped = await api.set_step_input_map( workspace_id="echo_ws", revision=1, step_id="echo", input_map={"input.extra": "extra"}, merge=True, ) output_mapped = await api.set_step_output_map( workspace_id="echo_ws", revision=2, step_id="echo", output_map={"extra": "state.extra"}, merge=True, ) replaced = await api.set_step_input_map( workspace_id="echo_ws", revision=3, step_id="echo", input_map={"input.final": "final"}, ) fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True) assert input_mapped["revision"] == 2 assert output_mapped["revision"] == 3 assert replaced["revision"] == 4 assert fetched["draft"]["steps"]["echo"]["input"] == [ { "target": "final", "path": "input.final", } ] assert fetched["draft"]["steps"]["echo"]["output"] == [ { "source": "echoed", "target": "state.echoed", }, { "source": "extra", "target": "state.extra", }, ] @pytest.mark.asyncio async def test_step_input_map_merge_preserves_literal_prefix( tmp_path: Path, ) -> None: api, _service, _authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "input_merge_literal_prefix") ) draft = _echo_draft() draft["steps"]["echo"]["input"] = [ {"value": "seed", "target": "fallback"}, {"path": "input.text", "target": "text"}, ] await api.create_draft_workspace(workspace_id="echo_ws", draft=draft) result = await api.set_step_input_map( workspace_id="echo_ws", revision=1, step_id="echo", input_map={"input.extra": "extra"}, merge=True, ) fetched = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) assert result["revision"] == 2 assert fetched["draft"]["steps"]["echo"]["input"] == [ {"value": "seed", "target": "fallback"}, {"path": "input.text", "target": "text"}, {"path": "input.extra", "target": "extra"}, ] @pytest.mark.asyncio async def test_step_input_map_merge_rejects_canonical_source_fan_out( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "input_merge_fan_out") api, _service, _authoring = _draft_api(artifact_store) draft = _echo_draft() draft["steps"]["echo"]["input"] = [ {"path": "input.text", "target": "message"}, {"path": "input.text", "target": "audit.message"}, ] await api.create_draft_workspace(workspace_id="echo_ws", draft=draft) before = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) with pytest.raises(ValueError, match="complete canonical binding list"): await api.set_step_input_map( workspace_id="echo_ws", revision=1, step_id="echo", input_map={"input.extra": "extra"}, merge=True, ) after = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_step_input_map_merge_rejects_path_before_literal( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "input_merge_order") api, _service, _authoring = _draft_api(artifact_store) draft = _echo_draft() draft["steps"]["echo"]["input"] = [ {"path": "input.text", "target": "message"}, {"value": "markdown", "target": "format"}, ] await api.create_draft_workspace(workspace_id="echo_ws", draft=draft) before = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) with pytest.raises(ValueError, match="complete canonical binding list"): await api.set_step_input_map( workspace_id="echo_ws", revision=1, step_id="echo", input_map={"input.extra": "extra"}, merge=True, ) after = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_step_input_map_merge_checks_revision_before_lossless_preflight( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "input_merge_stale") api, _service, _authoring = _draft_api(artifact_store) draft = _echo_draft() draft["steps"]["echo"]["input"] = [ {"path": "input.text", "target": "message"}, {"path": "input.text", "target": "audit.message"}, ] await api.create_draft_workspace(workspace_id="echo_ws", draft=draft) await api.set_draft_name( workspace_id="echo_ws", revision=1, name="echo_v2", ) before = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) result = await api.set_step_input_map( workspace_id="echo_ws", revision=1, step_id="echo", input_map={"input.extra": "extra"}, merge=True, ) after = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_step_output_map_merge_rejects_canonical_source_fan_out( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "output_merge_fan_out") api, _service, _authoring = _draft_api(artifact_store) draft = _echo_draft() draft["state_schema"] = { "fields": { "echoed": {"type": "string"}, "other": {"type": "string"}, } } draft["steps"]["echo"]["output"] = [ {"source": "echoed", "target": "state.echoed"}, {"source": "echoed", "target": "state.other"}, ] await api.create_draft_workspace(workspace_id="echo_ws", draft=draft) before = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) with pytest.raises(ValueError, match="complete canonical binding list"): await api.set_step_output_map( workspace_id="echo_ws", revision=1, step_id="echo", output_map={"extra": "state.extra"}, merge=True, ) after = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_step_output_map_merge_checks_revision_before_lossless_preflight( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "output_merge_stale") api, _service, _authoring = _draft_api(artifact_store) draft = _echo_draft() draft["steps"]["echo"]["output"] = [ {"source": "echoed", "target": "state.echoed"}, {"source": "echoed", "target": "state.other"}, ] await api.create_draft_workspace(workspace_id="echo_ws", draft=draft) await api.set_draft_name( workspace_id="echo_ws", revision=1, name="echo_v2", ) before = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) result = await api.set_step_output_map( workspace_id="echo_ws", revision=1, step_id="echo", output_map={"extra": "state.extra"}, merge=True, ) after = await api.get_draft_workspace( workspace_id="echo_ws", include_draft=True, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_validate_draft_workspace_refreshes_status(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_validate_workspace") api, service, authoring = _draft_api(artifact_store, register_echo=True) draft = _echo_draft() draft["routes"]["echo"] = {"typo": "__end__"} await api.create_draft_workspace( workspace_id="echo_ws", draft=draft, ) payload = await api.validate_draft_workspace(workspace_id="echo_ws") fetched = await api.get_draft_workspace(workspace_id="echo_ws") assert payload["revision"] == 1 assert payload["status"] == "invalid" assert _required_draft(payload)["routes"]["echo"] == {"typo": "__end__"} assert payload["diagnostics"][0]["code"] in ( "unknown_outcome", "undeclared_edge_outcome", ) assert fetched["status"] == "invalid" @pytest.mark.asyncio async def test_replace_draft_workspace_document_uses_current_capability_definitions( tmp_path: Path, ) -> None: api, _service, _authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "replace_document_capability_defs"), register_echo=True, ) await api.create_draft_workspace( workspace_id="report", draft=_echo_draft(), ) replacement = _echo_draft() replacement["name"] = "replacement" replacement["steps"]["echo"]["output"] = [ {"source": "missing", "target": "state.echoed"} ] result = await api.replace_draft_workspace_document( workspace_id="report", revision=1, draft=replacement, ) assert result["revision"] == 2 assert result["status"] == "invalid" assert any( "missing" in str(diagnostic.get("message", "")) for diagnostic in result["diagnostics"] ) @pytest.mark.asyncio async def test_replace_draft_workspace_document_refreshes_semantic_diagnostics( tmp_path: Path, ) -> None: api, _service, _authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "replace_document_diagnostics"), register_echo=True, ) await api.create_draft_workspace( workspace_id="report", draft=_echo_draft(), ) before = await api.get_draft_workspace( workspace_id="report", include_draft=True, ) replacement = _echo_draft() replacement["routes"] = {"echo": {"ok": "missing_step"}} result = await api.replace_draft_workspace_document( workspace_id="report", revision=1, draft=replacement, ) after = await api.get_draft_workspace( workspace_id="report", include_draft=True, ) assert before["diagnostics"] == [] assert result["status"] == "invalid" assert result["diagnostics"] assert after["diagnostics"] == result["diagnostics"] assert after["draft"]["routes"] == replacement["routes"] @pytest.mark.asyncio async def test_validate_draft_workspace_suggests_bind( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_repair_hint") api, service, authoring = _draft_api(artifact_store) service.register_connection( ConnectionConfig(id="demo.personal", server="demo", account="personal") ) service.register_specs("demo.personal", _snapshot_tool) await api.create_draft_workspace( workspace_id="snapshot_ws", draft={ "name": "snapshot", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "snap", "steps": { "snap": { "use": "demo.personal.snapshot_tool", "input": [], "output": [ { "source": {"root": "local", "parts": ["after"]}, "target": {"root": "state", "parts": ["after"]}, } ], } }, "routes": {"snap": {"ok": "__end__"}}, }, ) payload = await api.validate_draft_workspace(workspace_id="snapshot_ws") diagnostic = payload["diagnostics"][0] assert diagnostic["code"] == "invalid_destination_path" assert diagnostic.get("step_id") == "snap" assert diagnostic.get("repair_hint") == ( "wf draft bind snapshot_ws --revision 1 " "--step snap --from local.after --to state.after" ) @pytest.mark.asyncio async def test_patch_draft_workspace_validates_new_use_step_with_context_specs( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_patch_new_use") api, service, authoring = _draft_api(artifact_store, register_echo=True) service.register_specs("demo.personal", echo_tool, _snapshot_tool) await api.create_draft_workspace( workspace_id="echo_ws", draft=_echo_draft(), ) patched = await api.patch_draft_workspace( workspace_id="echo_ws", revision=1, patch=[ { "op": "add", "path": "/steps/snap", "value": { "use": "demo.personal.snapshot_tool", "input": [], "output": [ { "source": {"root": "local", "parts": ["after"]}, "target": {"root": "state", "parts": ["after"]}, } ], }, }, { "op": "replace", "path": "/routes/echo/ok", "value": "snap", }, { "op": "add", "path": "/routes/snap", "value": {"ok": "__end__"}, }, ], ) diagnostic = patched["diagnostics"][0] assert patched["status"] == "invalid" assert diagnostic["code"] == "invalid_destination_path" assert diagnostic.get("step_id") == "snap" assert diagnostic.get("details") == { "output_field": "after", "state_path": "state.after", } @pytest.mark.asyncio async def test_create_minimal_draft_workspace_minimal_success_path( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_minimal_workspace") api, _service, authoring = _draft_api(artifact_store, register_echo=True) result = await authoring.create_minimal_draft_workspace( workspace_id="echo_minimal", name="echo", capability_name="demo.personal.echo_tool", input_schema={ "type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"], }, state_schema={"fields": {"echoed": {"type": "string"}}}, output_schema={ "type": "object", "properties": {"echoed": {"type": "string"}}, "required": ["echoed"], }, input_map={"input.text": "text"}, output_map={"echoed": "state.echoed"}, ) assert result["workspace_id"] == "echo_minimal" fetched = await api.get_draft_workspace( workspace_id="echo_minimal", include_draft=True ) assert fetched["draft"]["routes"]["call"]["ok"] == "__end__" assert fetched["draft"]["steps"]["call"]["use"] == "demo.personal.echo_tool" @pytest.mark.asyncio async def test_delegation_smoke_validate_draft_equivalence(tmp_path: Path) -> None: """WorkflowSurfaceHandlers.validate_draft delegates to WorkflowDraftApi.""" artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_delegation_smoke") mcp_root = artifact_store.root / "delegation_mcp" service = WfMcpService( store=FileStore(mcp_root), artifact_store=artifact_store, draft_workspace_store=FileDraftWorkspaceStore(mcp_root), ) service.register_connection( ConnectionConfig(id="demo.personal", server="demo", account="personal") ) service.register_specs("demo.personal", echo_tool) h = WorkflowSurfaceHandlers(service) context = context_from_service(service) api = WorkflowDraftApi(context) draft = _echo_draft() handler_result = await h.validate_draft(draft=draft) api_result = await api.validate_draft(draft=draft) assert handler_result["status"] == api_result["status"] assert handler_result["diagnostics"] == api_result["diagnostics"] assert ( _compiled_workspace(handler_result)["nodes"] == _compiled_workspace(api_result)["nodes"] ) @pytest.mark.asyncio async def test_facade_delegates_semantic_authoring_to_authoring_service( tmp_path: Path, ) -> None: """WorkflowApi constructs a sibling WorkflowDraftAuthoringApi.""" artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_facade_delegation") mcp_root = artifact_store.root / "facade_mcp" service = WfMcpService( store=FileStore(mcp_root), artifact_store=artifact_store, draft_workspace_store=FileDraftWorkspaceStore(mcp_root), ) service.register_connection( ConnectionConfig(id="demo.personal", server="demo", account="personal") ) service.register_specs("demo.personal", echo_tool, _snapshot_tool) context = context_from_service(service) facade = WorkflowApi(context) assert facade.draft_authoring is not None assert isinstance(facade.draft_authoring, WorkflowDraftAuthoringApi) await facade.create_draft_workspace( workspace_id="ws1", draft=_echo_draft(), ) result = await facade.bind_draft( workspace_id="ws1", revision=1, step_id="echo", source_path="local.echoed", target_path="state.echoed", ) assert result["revision"] == 2 @pytest.mark.asyncio async def test_bind_draft_workflow_input_to_step_input_projects_input_schema( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_input") api, service, authoring = _draft_api(artifact_store, register_echo=True) draft = {**_echo_draft(), "input_schema": {"type": "object", "properties": {}}} await api.create_draft_workspace( workspace_id="bind_ws", draft=draft, ) result = await authoring.bind_draft( workspace_id="bind_ws", revision=1, step_id="echo", source_path="input.text", target_path="local.text", ) workspace = await api.get_draft_workspace( workspace_id="bind_ws", include_draft=True ) assert result["revision"] == 2 assert workspace["draft"]["input_schema"]["properties"]["text"]["type"] == "string" assert workspace["draft"]["steps"]["echo"]["input"] == [ {"target": "text", "path": "input.text"} ] @pytest.mark.asyncio async def test_bind_draft_workflow_input_to_step_input_reuses_existing_schema( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / "drafts_bind_existing_input_schema" ) api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="bind_ws", draft=_echo_draft(), ) result = await authoring.bind_draft( workspace_id="bind_ws", revision=1, step_id="echo", source_path="input.text", target_path="local.text", ) workspace = await api.get_draft_workspace( workspace_id="bind_ws", include_draft=True ) assert result["status"] == "valid", result["diagnostics"] assert result["revision"] == 2 assert workspace["draft"]["steps"]["echo"]["input"] == [ {"target": "text", "path": "input.text"} ] @pytest.mark.asyncio async def test_bind_draft_workflow_input_to_step_input_can_repeat( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_repeat") api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace(workspace_id="bind_ws", draft=_echo_draft()) first = await authoring.bind_draft( workspace_id="bind_ws", revision=1, step_id="echo", source_path="input.text", target_path="local.text", ) second = await authoring.bind_draft( workspace_id="bind_ws", revision=first["revision"], step_id="echo", source_path="input.text", target_path="local.text", ) workspace = await api.get_draft_workspace( workspace_id="bind_ws", include_draft=True ) assert first["status"] == "valid" assert second["status"] == "valid" assert second["revision"] == 3 assert workspace["draft"]["steps"]["echo"]["input"] == [ {"target": "text", "path": "input.text"} ] @pytest.mark.asyncio async def test_bind_draft_input_preserves_literals_and_unrelated_fan_out( tmp_path: Path, ) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "bind_preserves_canonical_inputs"), register_echo=True, ) service.register_specs("demo.personal", _structured_report) draft = _structured_report_draft() draft["state_schema"] = { "type": "object", "properties": {"shared": {"type": "string"}}, } draft["steps"]["report"]["input"] = [ {"path": "state.shared", "target": "request.title"}, {"path": "state.shared", "target": "request.format"}, {"value": "audit", "target": "audit.note"}, ] await api.create_draft_workspace(workspace_id="report", draft=draft) result = await authoring.bind_draft( workspace_id="report", revision=1, step_id="report", source_path="input.body", target_path="local.request.body", ) workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True) assert result["revision"] == 2 assert workspace["draft"]["steps"]["report"]["input"] == [ {"path": "state.shared", "target": "request.title"}, {"path": "state.shared", "target": "request.format"}, {"value": "audit", "target": "audit.note"}, {"path": "input.body", "target": "request.body"}, ] @pytest.mark.asyncio async def test_bind_draft_workflow_state_to_step_input_reuses_existing_schema( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / "drafts_bind_existing_state_schema" ) api, _service, authoring = _draft_api(artifact_store, register_echo=True) draft = { **_echo_draft(), "state_schema": { "type": "object", "properties": {"text": {"type": "string"}}, }, "steps": { "echo": { "use": "demo.personal.echo_tool", "input": [], "output": [], } }, } await api.create_draft_workspace(workspace_id="bind_ws", draft=draft) result = await authoring.bind_draft( workspace_id="bind_ws", revision=1, step_id="echo", source_path="state.text", target_path="local.text", ) workspace = await api.get_draft_workspace( workspace_id="bind_ws", include_draft=True ) assert result["status"] == "valid", result["diagnostics"] assert result["revision"] == 2 assert workspace["draft"]["steps"]["echo"]["input"] == [ {"target": "text", "path": "state.text"} ] @pytest.mark.asyncio async def test_bind_draft_output_to_nested_state_projects_state_schema( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_output_nested") api, service, authoring = _draft_api(artifact_store, register_echo=True) service.register_specs("demo.personal", _snapshot_tool) await api.create_draft_workspace( workspace_id="snapshot_ws", draft={ "name": "snapshot", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "snap", "steps": { "snap": { "use": "demo.personal.snapshot_tool", "input": [], "output": [], } }, "routes": {"snap": {"ok": "__end__"}}, }, ) result = await authoring.bind_draft( workspace_id="snapshot_ws", revision=1, step_id="snap", source_path="local.after", target_path="state.session.after", ) workspace = await api.get_draft_workspace( workspace_id="snapshot_ws", include_draft=True ) assert result["revision"] == 2 assert ( workspace["draft"]["state_schema"]["properties"]["session"]["properties"][ "after" ]["$ref"] == "#/$defs/_Snapshot" ) assert workspace["draft"]["steps"]["snap"]["output"] == [ {"source": "after", "target": "state.session.after"} ] @pytest.mark.asyncio async def test_bind_draft_projects_nested_local_input_schema(tmp_path: Path) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "nested_local_input"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) await api.create_draft_workspace( workspace_id="nested", draft=_nested_report_draft(), ) result = await authoring.bind_draft( workspace_id="nested", revision=1, step_id="render", source_path="input.title", target_path="local.report.title", ) workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True) draft = workspace["draft"] assert result["revision"] == 2 assert draft["input_schema"]["properties"]["title"]["type"] == "string" assert draft["steps"]["render"]["input"] == [ {"target": "report.title", "path": "input.title"} ] @pytest.mark.asyncio async def test_bind_draft_reuses_nested_state_for_nested_local_input( tmp_path: Path, ) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "nested_local_state_input"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) draft = _nested_report_draft() draft["state_schema"] = { "type": "object", "properties": { "report": { "type": "object", "properties": {"title": {"type": "string"}}, } }, } await api.create_draft_workspace(workspace_id="nested", draft=draft) result = await authoring.bind_draft( workspace_id="nested", revision=1, step_id="render", source_path="state.report.title", target_path="local.report.title", ) workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True) assert result["revision"] == 2 assert workspace["draft"]["steps"]["render"]["input"] == [ {"target": "report.title", "path": "state.report.title"} ] @pytest.mark.asyncio async def test_bind_draft_projects_nested_local_output_to_state(tmp_path: Path) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "nested_local_output_state"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) await api.create_draft_workspace( workspace_id="nested", draft=_nested_report_draft(), ) result = await authoring.bind_draft( workspace_id="nested", revision=1, step_id="render", source_path="local.report.markdown", target_path="state.report.markdown", ) workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True) draft = workspace["draft"] assert result["revision"] == 2 assert draft["steps"]["render"]["output"] == [ {"source": "report.markdown", "target": "state.report.markdown"} ] assert ( draft["state_schema"]["properties"]["report"]["properties"]["markdown"]["type"] == "string" ) @pytest.mark.asyncio async def test_bind_draft_lowers_nested_local_output_to_public_output( tmp_path: Path, ) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "nested_local_public_output"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) await api.create_draft_workspace( workspace_id="nested", draft=_nested_report_draft(), ) result = await authoring.bind_draft( workspace_id="nested", revision=1, step_id="render", source_path="local.report.markdown", target_path="output.report.markdown", ) workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True) draft = workspace["draft"] assert result["revision"] == 2 assert draft["steps"]["render"]["output"] == [ {"source": "report.markdown", "target": "state.report.markdown"} ] assert draft["output"] == [ {"path": "state.report.markdown", "target": "report.markdown"} ] assert ( draft["state_schema"]["properties"]["report"]["properties"]["markdown"]["type"] == "string" ) assert ( draft["output_schema"]["properties"]["report"]["properties"]["markdown"]["type"] == "string" ) @pytest.mark.asyncio async def test_bind_draft_output_preserves_unrelated_source_fan_out( tmp_path: Path, ) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "bind_preserves_canonical_outputs"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) draft = _nested_report_draft() draft["state_schema"] = { "type": "object", "properties": { "report": { "type": "object", "properties": { "title": {"type": "string"}, "audit_title": {"type": "string"}, }, } }, } draft["steps"]["render"]["output"] = [ {"source": "report.title", "target": "state.report.title"}, {"source": "report.title", "target": "state.report.audit_title"}, ] await api.create_draft_workspace(workspace_id="nested", draft=draft) result = await authoring.bind_draft( workspace_id="nested", revision=1, step_id="render", source_path="local.report.markdown", target_path="state.report.markdown", ) workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True) assert result["revision"] == 2 assert workspace["draft"]["steps"]["render"]["output"] == [ {"source": "report.title", "target": "state.report.title"}, {"source": "report.title", "target": "state.report.audit_title"}, {"source": "report.markdown", "target": "state.report.markdown"}, ] @pytest.mark.asyncio async def test_bind_draft_rebinds_nested_local_public_output(tmp_path: Path) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "rebind_nested_local_public_output"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) await api.create_draft_workspace( workspace_id="nested", draft=_nested_report_draft(), ) first = await authoring.bind_draft( workspace_id="nested", revision=1, step_id="render", source_path="local.report.markdown", target_path="output.report.markdown", ) second = await authoring.bind_draft( workspace_id="nested", revision=first["revision"], step_id="render", source_path="local.report.markdown", target_path="output.published.markdown", ) workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True) assert second["revision"] == 3 assert workspace["draft"]["steps"]["render"]["output"] == [ {"source": "report.markdown", "target": "state.published.markdown"} ] assert workspace["draft"]["output"] == [ {"path": "state.published.markdown", "target": "published.markdown"} ] @pytest.mark.asyncio async def test_bind_draft_rejects_missing_nested_local_output_without_mutation( tmp_path: Path, ) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "missing_nested_local_output"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) await api.create_draft_workspace( workspace_id="nested", draft=_nested_report_draft(), ) with pytest.raises( ValueError, match="source schema path 'report.missing' is not declared", ): await authoring.bind_draft( workspace_id="nested", revision=1, step_id="render", source_path="local.report.missing", target_path="state.report.missing", ) workspace = await api.get_draft_workspace(workspace_id="nested", include_draft=True) assert workspace["revision"] == 1 assert workspace["draft"]["steps"]["render"]["output"] == [] @pytest.mark.asyncio async def test_bind_draft_stale_revision_precedes_nested_local_path_error( tmp_path: Path, ) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "stale_nested_local_output"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) await api.create_draft_workspace( workspace_id="nested", draft=_nested_report_draft(), ) await api.patch_draft_workspace( workspace_id="nested", revision=1, patch=[{"op": "replace", "path": "/name", "value": "nested_v2"}], ) result = await authoring.bind_draft( workspace_id="nested", revision=1, step_id="render", source_path="local.report.missing", target_path="state.report.missing", ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" @pytest.mark.asyncio async def test_set_step_input_bindings_replaces_structured_inputs_in_order( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_structured_binding_api( tmp_path, "structured_input", ) result = await api.set_step_input_bindings( workspace_id="structured_input", revision=1, step_id="report", bindings=[ InputPathBinding( path=GraphSourcePath.state("report", "title"), target=LocalPath.of("request", "title"), ), InputPathBinding( path=GraphSourcePath.state("report", "markdown"), target=LocalPath.of("request", "body"), ), InputValueBinding( target=LocalPath.of("request", "format"), value="markdown", ), ], ) inspected = await draft_api.get_draft_workspace( workspace_id="structured_input", include_draft=True, ) assert result["revision"] == 2 assert inspected["draft"]["steps"]["report"]["input"] == [ {"target": "request.title", "path": "state.report.title"}, {"target": "request.body", "path": "state.report.markdown"}, {"target": "request.format", "value": "markdown"}, ] @pytest.mark.asyncio async def test_set_step_input_bindings_preserves_source_fan_out(tmp_path: Path) -> None: draft_api, _service, api = await _create_structured_binding_api( tmp_path, "input_fan_out", ) await api.set_step_input_bindings( workspace_id="input_fan_out", revision=1, step_id="report", bindings=[ InputPathBinding( path=GraphSourcePath.state("report", "title"), target=LocalPath.of("request", "title"), ), InputPathBinding( path=GraphSourcePath.state("report", "title"), target=LocalPath.of("audit", "title"), ), ], ) inspected = await draft_api.get_draft_workspace( workspace_id="input_fan_out", include_draft=True, ) assert inspected["draft"]["steps"]["report"]["input"] == [ {"target": "request.title", "path": "state.report.title"}, {"target": "audit.title", "path": "state.report.title"}, ] @pytest.mark.asyncio async def test_set_step_output_bindings_replaces_in_order_and_preserves_source_fan_out( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "draft-output-bindings", ) result = await api.set_step_output_bindings( workspace_id="draft-output-bindings", revision=1, step_id="render", bindings=[ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ), OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.audit.title"), ), ], ) inspected = await draft_api.get_draft_workspace( workspace_id="draft-output-bindings", include_draft=True, ) assert result["revision"] == 2 assert inspected["draft"]["steps"]["render"]["output"] == [ {"source": "report.title", "target": "state.report.title"}, {"source": "report.title", "target": "state.audit.title"}, ] state_schema = inspected["draft"]["state_schema"] assert state_schema["properties"]["report"]["properties"]["title"] == { "type": "string" } assert state_schema["properties"]["audit"]["properties"]["title"] == { "type": "string" } @pytest.mark.asyncio async def test_set_step_output_bindings_projects_whole_capability_payload( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "whole-output-binding", ) await api.set_step_output_bindings( workspace_id="whole-output-binding", revision=1, step_id="render", bindings=[ OutputBinding( source=LocalPath.root(), target=StatePath.parse("state.raw_result"), ) ], ) inspected = await draft_api.get_draft_workspace( workspace_id="whole-output-binding", include_draft=True, ) raw_result_schema = inspected["draft"]["state_schema"]["properties"]["raw_result"] assert raw_result_schema["type"] == "object" assert raw_result_schema["properties"]["report"]["properties"]["title"] == { "type": "string" } @pytest.mark.asyncio async def test_set_step_output_bindings_accepts_exact_existing_target_and_is_noop( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "equivalent-output-binding", ) binding = OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ) first = await api.set_step_output_bindings( workspace_id="equivalent-output-binding", revision=1, step_id="render", bindings=[binding], ) second = await api.set_step_output_bindings( workspace_id="equivalent-output-binding", revision=first["revision"], step_id="render", bindings=[binding], ) assert first["revision"] == 2 assert second["revision"] == 2 @pytest.mark.asyncio @pytest.mark.parametrize( ("bindings", "message"), [ ( [ OutputBinding( source=LocalPath.parse("report.missing"), target=StatePath.parse("state.report.missing"), ) ], r"bindings\[0\]\.source 'report\.missing' is not declared", ), ( [ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ), OutputBinding( source=LocalPath.parse("report.markdown"), target=StatePath.parse("state.report.title"), ), ], r"bindings\[0\]\.target 'state\.report\.title' overlaps " r"bindings\[1\]\.target 'state\.report\.title'", ), ( [ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report"), ), OutputBinding( source=LocalPath.parse("report.markdown"), target=StatePath.parse("state.report.title"), ), ], r"bindings\[0\]\.target 'state\.report' overlaps " r"bindings\[1\]\.target 'state\.report\.title'", ), ], ) async def test_set_step_output_bindings_rejects_semantic_errors_without_mutation( tmp_path: Path, bindings: list[OutputBinding], message: str, ) -> None: workspace_id = f"invalid_output_bindings_{len(message)}" draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, workspace_id, ) before = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) with pytest.raises(ValueError, match=message): await api.set_step_output_bindings( workspace_id=workspace_id, revision=1, step_id="render", bindings=bindings, ) after = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_set_step_output_bindings_reports_invalid_source_before_overlap( tmp_path: Path, ) -> None: _draft_api_instance, _service, api = await _create_nested_output_binding_api( tmp_path, "invalid_source_before_overlap", ) with pytest.raises( ValueError, match=r"bindings\[0\]\.source 'report\.missing' is not declared", ): await api.set_step_output_bindings( workspace_id="invalid_source_before_overlap", revision=1, step_id="render", bindings=[ OutputBinding( source=LocalPath.parse("report.missing"), target=StatePath.parse("state.report.title"), ), OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ), ], ) @pytest.mark.asyncio async def test_set_step_output_bindings_rejects_incompatible_existing_target( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "incompatible-output-binding", ) await draft_api.patch_draft_workspace( workspace_id="incompatible-output-binding", revision=1, patch=[ { "op": "replace", "path": "/state_schema", "value": { "type": "object", "properties": { "report": { "type": "object", "properties": {"title": {"type": "integer"}}, } }, }, } ], ) before = await draft_api.get_draft_workspace( workspace_id="incompatible-output-binding", include_draft=True, ) with pytest.raises( ValueError, match=( r"bindings\[0\]\.target 'state\.report\.title' cannot receive " r"source 'report\.title'" ), ): await api.set_step_output_bindings( workspace_id="incompatible-output-binding", revision=2, step_id="render", bindings=[ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ) ], ) after = await draft_api.get_draft_workspace( workspace_id="incompatible-output-binding", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_set_step_output_bindings_clears_outputs_without_removing_projection( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "clear-output-bindings", ) first = await api.set_step_output_bindings( workspace_id="clear-output-bindings", revision=1, step_id="render", bindings=[ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ) ], ) cleared = await api.set_step_output_bindings( workspace_id="clear-output-bindings", revision=first["revision"], step_id="render", bindings=[], ) inspected = await draft_api.get_draft_workspace( workspace_id="clear-output-bindings", include_draft=True, ) assert cleared["revision"] == 3 assert inspected["draft"]["steps"]["render"]["output"] == [] assert ( inspected["draft"]["state_schema"]["properties"]["report"]["properties"][ "title" ]["type"] == "string" ) assert set( inspected["draft"]["state_schema"]["properties"]["report"]["properties"][ "title" ] ) == {"type"} @pytest.mark.asyncio async def test_set_step_output_bindings_rejects_missing_step_without_mutation( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "missing-output-step", ) before = await draft_api.get_draft_workspace( workspace_id="missing-output-step", include_draft=True, ) with pytest.raises(KeyError, match="missing"): await api.set_step_output_bindings( workspace_id="missing-output-step", revision=1, step_id="missing", bindings=[], ) after = await draft_api.get_draft_workspace( workspace_id="missing-output-step", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_set_step_output_bindings_rejects_non_capability_step_without_mutation( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "non-capability-output-step", ) await draft_api.patch_draft_workspace( workspace_id="non-capability-output-step", revision=1, patch=[{"op": "replace", "path": "/steps/render", "value": {"join": {}}}], ) before = await draft_api.get_draft_workspace( workspace_id="non-capability-output-step", include_draft=True, ) with pytest.raises(ValueError, match="does not declare a capability use"): await api.set_step_output_bindings( workspace_id="non-capability-output-step", revision=2, step_id="render", bindings=[], ) after = await draft_api.get_draft_workspace( workspace_id="non-capability-output-step", include_draft=True, ) assert after == before @pytest.mark.asyncio @pytest.mark.parametrize( "bindings", [ [ OutputBinding( source=LocalPath.parse("report.missing"), target=StatePath.parse("state.report.missing"), ) ], [ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report"), ), OutputBinding( source=LocalPath.parse("report.markdown"), target=StatePath.parse("state.report.title"), ), ], ], ) async def test_set_step_output_bindings_stale_revision_precedes_semantic_errors( tmp_path: Path, bindings: list[OutputBinding], ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, f"stale-output-binding-{len(bindings)}", ) workspace_id = f"stale-output-binding-{len(bindings)}" result = await api.set_step_output_bindings( workspace_id=workspace_id, revision=2, step_id="render", bindings=bindings, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" inspected = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) assert inspected["revision"] == 1 @pytest.mark.asyncio async def test_set_step_output_bindings_stale_revision_precedes_missing_step( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "stale-output-missing-step", ) before = await draft_api.get_draft_workspace( workspace_id="stale-output-missing-step", include_draft=True, ) result = await api.set_step_output_bindings( workspace_id="stale-output-missing-step", revision=2, step_id="missing", bindings=[], ) after = await draft_api.get_draft_workspace( workspace_id="stale-output-missing-step", include_draft=True, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_set_step_output_bindings_stale_revision_precedes_non_capability_step( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "stale-output-non-capability", ) await draft_api.patch_draft_workspace( workspace_id="stale-output-non-capability", revision=1, patch=[{"op": "replace", "path": "/steps/render", "value": {"join": {}}}], ) before = await draft_api.get_draft_workspace( workspace_id="stale-output-non-capability", include_draft=True, ) result = await api.set_step_output_bindings( workspace_id="stale-output-non-capability", revision=1, step_id="render", bindings=[], ) after = await draft_api.get_draft_workspace( workspace_id="stale-output-non-capability", include_draft=True, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_set_step_output_bindings_stale_revision_precedes_duplicate_target( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "stale-output-duplicate-target", ) before = await draft_api.get_draft_workspace( workspace_id="stale-output-duplicate-target", include_draft=True, ) result = await api.set_step_output_bindings( workspace_id="stale-output-duplicate-target", revision=2, step_id="render", bindings=[ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ), OutputBinding( source=LocalPath.parse("report.markdown"), target=StatePath.parse("state.report.title"), ), ], ) after = await draft_api.get_draft_workspace( workspace_id="stale-output-duplicate-target", include_draft=True, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_set_step_output_bindings_stale_revision_precedes_incompatible_schema( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_nested_output_binding_api( tmp_path, "stale-output-incompatible-schema", ) await draft_api.patch_draft_workspace( workspace_id="stale-output-incompatible-schema", revision=1, patch=[ { "op": "replace", "path": "/state_schema", "value": { "type": "object", "properties": { "report": { "type": "object", "properties": {"title": {"type": "integer"}}, } }, }, } ], ) before = await draft_api.get_draft_workspace( workspace_id="stale-output-incompatible-schema", include_draft=True, ) result = await api.set_step_output_bindings( workspace_id="stale-output-incompatible-schema", revision=1, step_id="render", bindings=[ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ) ], ) after = await draft_api.get_draft_workspace( workspace_id="stale-output-incompatible-schema", include_draft=True, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio @pytest.mark.parametrize( ("bindings", "message"), [ ( [ InputValueBinding( target=LocalPath.of("request", "missing"), value="value", ) ], r"bindings\[0\]\.target 'request.missing' is not declared", ), ( [ InputValueBinding( target=LocalPath.of("request", "title"), value="first", ), InputValueBinding( target=LocalPath.of("request", "title"), value="second", ), ], r"bindings\[0\]\.target 'request.title' overlaps bindings\[1\]", ), ( [ InputValueBinding( target=LocalPath.of("request"), value={ "title": "Report", "body": "Body", "format": "markdown", }, ), InputValueBinding( target=LocalPath.of("request", "title"), value="Report", ), ], r"bindings\[0\]\.target 'request' overlaps bindings\[1\]", ), ( [ InputValueBinding( target=LocalPath.of("request", "format"), value="html", ) ], r"bindings\[0\]\.value does not satisfy schema at 'request.format'", ), ( [InputValueBinding(target=LocalPath.root(), value="not-an-object")], r"bindings\[0\]\.value for target '\.' must be a JSON object", ), ], ) async def test_set_step_input_bindings_rejects_semantic_errors_without_mutation( tmp_path: Path, bindings: list[InputPathBinding | InputValueBinding], message: str, ) -> None: workspace_id = f"invalid_bindings_{len(message)}" draft_api, _service, api = await _create_structured_binding_api( tmp_path, workspace_id, ) before = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) with pytest.raises(ValueError, match=message): await api.set_step_input_bindings( workspace_id=workspace_id, revision=1, step_id="report", bindings=bindings, ) after = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_set_step_input_bindings_rejects_remote_target_reference_without_mutation( tmp_path: Path, ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "remote_binding_target"), register_echo=True, ) remote_spec = replace( _structured_report, name="remote_structured_report", input_schema_contract={ "type": "object", "properties": {"request": {"$ref": "https://example.com/request.json"}}, }, ) service.register_specs("demo.personal", remote_spec) await draft_api.create_draft_workspace( workspace_id="remote_target", draft=_structured_report_draft("demo.personal.remote_structured_report"), ) api = WorkflowApi(authoring.context) before = await draft_api.get_draft_workspace( workspace_id="remote_target", include_draft=True, ) with pytest.raises(ValueError, match="unsupported reference"): await api.set_step_input_bindings( workspace_id="remote_target", revision=1, step_id="report", bindings=[ InputValueBinding( target=LocalPath.of("request"), value={}, ) ], ) after = await draft_api.get_draft_workspace( workspace_id="remote_target", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_set_step_input_bindings_rejects_non_capability_step_without_mutation( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "non_capability_binding"), register_echo=True, ) draft = _structured_report_draft() draft["steps"]["report"] = {"join": {}} await draft_api.create_draft_workspace(workspace_id="non_capability", draft=draft) api = WorkflowApi(authoring.context) before = await draft_api.get_draft_workspace( workspace_id="non_capability", include_draft=True, ) with pytest.raises(ValueError, match="does not declare a capability use"): await api.set_step_input_bindings( workspace_id="non_capability", revision=1, step_id="report", bindings=[], ) after = await draft_api.get_draft_workspace( workspace_id="non_capability", include_draft=True, ) assert after == before @pytest.mark.asyncio @pytest.mark.parametrize( "binding", [ InputValueBinding(target=LocalPath.of("missing"), value="value"), InputValueBinding( target=LocalPath.of("request", "format"), value="html", ), ], ) async def test_set_step_input_bindings_stale_revision_wins_over_semantic_errors( tmp_path: Path, binding: InputValueBinding, ) -> None: draft_api, _service, api = await _create_structured_binding_api( tmp_path, f"stale_binding_{len(str(binding.target))}", ) workspace_id = f"stale_binding_{len(str(binding.target))}" result = await api.set_step_input_bindings( workspace_id=workspace_id, revision=2, step_id="report", bindings=[binding], ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" inspected = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) assert inspected["revision"] == 1 @pytest.mark.asyncio async def test_set_step_input_bindings_projects_whole_capability_payload( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_structured_binding_api( tmp_path, "whole_payload_binding", ) await api.set_step_input_bindings( workspace_id="whole_payload_binding", revision=1, step_id="report", bindings=[ InputPathBinding( path=GraphSourcePath.input("payload"), target=LocalPath.root(), ) ], ) inspected = await draft_api.get_draft_workspace( workspace_id="whole_payload_binding", include_draft=True, ) payload_schema = inspected["draft"]["input_schema"]["properties"]["payload"] assert payload_schema["properties"]["request"]["$ref"].endswith( "/_StructuredRequest" ) assert inspected["draft"]["steps"]["report"]["input"] == [ {"target": ".", "path": "input.payload"} ] @pytest.mark.asyncio async def test_set_step_input_bindings_projects_input_and_state_but_not_context( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_structured_binding_api( tmp_path, "multi_schema_binding", ) await api.set_step_input_bindings( workspace_id="multi_schema_binding", revision=1, step_id="report", bindings=[ InputPathBinding( path=GraphSourcePath.input("title"), target=LocalPath.of("request", "title"), ), InputPathBinding( path=GraphSourcePath.state("body"), target=LocalPath.of("request", "body"), ), InputValueBinding( target=LocalPath.of("request", "format"), value="markdown", ), InputPathBinding( path=GraphSourcePath.context("prior_outcome"), target=LocalPath.of("request", "note"), ), ], ) inspected = await draft_api.get_draft_workspace( workspace_id="multi_schema_binding", include_draft=True, ) draft = inspected["draft"] assert draft["input_schema"]["properties"]["title"]["type"] == "string" assert draft["state_schema"]["properties"]["body"]["type"] == "string" assert set(draft["input_schema"]["properties"]) == {"title"} assert set(draft["state_schema"]["properties"]) == {"body"} @pytest.mark.asyncio async def test_set_step_input_bindings_accepts_explicit_null_and_exact_noop( tmp_path: Path, ) -> None: draft_api, _service, api = await _create_structured_binding_api( tmp_path, "null_and_noop_binding", ) bindings = [ InputValueBinding( target=LocalPath.of("request", "note"), value=None, ) ] first = await api.set_step_input_bindings( workspace_id="null_and_noop_binding", revision=1, step_id="report", bindings=bindings, ) second = await api.set_step_input_bindings( workspace_id="null_and_noop_binding", revision=first["revision"], step_id="report", bindings=bindings, ) assert first["revision"] == 2 assert second["revision"] == 2 inspected = await draft_api.get_draft_workspace( workspace_id="null_and_noop_binding", include_draft=True, ) assert inspected["draft"]["steps"]["report"]["input"] == [ {"target": "request.note", "value": None} ] @pytest.mark.asyncio async def test_set_step_input_bindings_compiles_and_assembles_nested_payload( tmp_path: Path, ) -> None: draft_api, service, api = await _create_structured_binding_api( tmp_path, "execute_structured_binding", ) bindings = [ InputPathBinding( path=GraphSourcePath.input("title"), target=LocalPath.of("request", "title"), ), InputPathBinding( path=GraphSourcePath.input("body"), target=LocalPath.of("request", "body"), ), InputValueBinding( target=LocalPath.of("request", "format"), value="markdown", ), ] await api.set_step_input_bindings( workspace_id="execute_structured_binding", revision=1, step_id="report", bindings=bindings, ) compiled = await draft_api.compile_draft_workspace( workspace_id="execute_structured_binding" ) plan = RawWorkflowPlan.model_validate(_compiled_workspace(compiled)) run = await service.run_workflow_from_plan( plan, {"title": "Thesis", "body": "Evidence"}, ) assert run.error is None assert run.trace[0].output == {"rendered": "Thesis|Evidence|markdown"} @pytest.mark.asyncio async def test_set_step_output_bindings_compile_and_execute_source_fan_out( tmp_path: Path, ) -> None: draft_api, service, api = await _create_structured_binding_api( tmp_path, "execute_output_fan_out", ) input_result = await api.set_step_input_bindings( workspace_id="execute_output_fan_out", revision=1, step_id="report", bindings=[ InputPathBinding( path=GraphSourcePath.input("title"), target=LocalPath.of("request", "title"), ), InputPathBinding( path=GraphSourcePath.input("body"), target=LocalPath.of("request", "body"), ), InputValueBinding( target=LocalPath.of("request", "format"), value="markdown", ), ], ) await api.set_step_output_bindings( workspace_id="execute_output_fan_out", revision=input_result["revision"], step_id="report", bindings=[ OutputBinding( source=LocalPath.parse("rendered"), target=StatePath.parse("state.report"), ), OutputBinding( source=LocalPath.parse("rendered"), target=StatePath.parse("state.audit"), ), ], ) compiled = await draft_api.compile_draft_workspace( workspace_id="execute_output_fan_out" ) plan = RawWorkflowPlan.model_validate(_compiled_workspace(compiled)) run = await service.run_workflow_from_plan( plan, {"title": "Thesis", "body": "Evidence"}, ) assert run.error is None assert run.state["report"] == "Thesis|Evidence|markdown" assert run.state["audit"] == "Thesis|Evidence|markdown" @pytest.mark.asyncio async def test_bind_draft_rejects_unsupported_direction(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_bad_direction") api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace(workspace_id="bind_ws", draft=_echo_draft()) with pytest.raises(ValueError, match="unsupported bind direction"): await authoring.bind_draft( workspace_id="bind_ws", revision=1, step_id="echo", source_path="input.message", target_path="state.message", ) @pytest.mark.asyncio async def test_add_step_from_capability_wires_route_inputs_and_state_outputs( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step") api, service, authoring = _draft_api(artifact_store, register_echo=True) service.register_specs("demo.personal", echo_tool, _snapshot_tool) await api.create_draft_workspace( workspace_id="echo_ws", draft=_echo_draft(), ) result = await authoring.add_step_from_capability( workspace_id="echo_ws", revision=1, step_id="snap", capability_name="demo.personal.snapshot_tool", route_from_step="echo", route_from_outcome="ok", routes={"ok": "__end__", "skipped": "__end__"}, input_map={}, bind_outputs={"after": "state.after"}, ) assert result["revision"] == 2 assert result["status"] == "valid" fetched = await api.get_draft_workspace(workspace_id="echo_ws", include_draft=True) draft = fetched["draft"] assert draft["steps"]["snap"]["use"] == "demo.personal.snapshot_tool" assert draft["routes"]["echo"]["ok"] == "snap" assert draft["routes"]["snap"]["ok"] == "__end__" assert draft["steps"]["snap"]["output"] == [ { "source": "after", "target": "state.after", } ] assert draft["state_schema"]["properties"]["after"]["$ref"] == "#/$defs/_Snapshot" assert draft["state_schema"]["$defs"]["_Snapshot"]["properties"]["clicked"] == { "title": "Clicked", "type": "boolean", } @pytest.mark.asyncio async def test_add_step_from_capability_projects_nested_local_input( tmp_path: Path, ) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "nested_capability_input"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) draft = _nested_report_draft() draft["steps"] = {} draft["routes"] = {} await api.create_draft_workspace(workspace_id="nested_add", draft=draft) result = await authoring.add_step_from_capability( workspace_id="nested_add", revision=1, step_id="render", capability_name="demo.personal.nested_report", routes={"ok": "__end__"}, input_map={"input.title": "report.title"}, bind_outputs={}, ) workspace = await api.get_draft_workspace( workspace_id="nested_add", include_draft=True ) validated = await api.validate_draft_workspace(workspace_id="nested_add") assert result["revision"] == 2 assert workspace["draft"]["input_schema"]["properties"]["title"]["type"] == ( "string" ) assert workspace["draft"]["steps"]["render"]["input"] == [ {"target": "report.title", "path": "input.title"} ] assert validated["status"] == "valid", validated["diagnostics"] @pytest.mark.asyncio async def test_add_step_from_capability_preserves_whole_payload_input( tmp_path: Path, ) -> None: api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "whole_payload_capability_input"), register_echo=True, ) service.register_specs("demo.personal", _nested_report) draft = _nested_report_draft() draft["steps"] = {} draft["routes"] = {} await api.create_draft_workspace(workspace_id="whole_payload", draft=draft) result = await authoring.add_step_from_capability( workspace_id="whole_payload", revision=1, step_id="render", capability_name="demo.personal.nested_report", routes={"ok": "__end__"}, input_map={"input.payload": "."}, bind_outputs={}, ) workspace = await api.get_draft_workspace( workspace_id="whole_payload", include_draft=True ) assert result["revision"] == 2 assert workspace["draft"]["steps"]["render"]["input"] == [ {"target": ".", "path": "input.payload"} ] @pytest.mark.asyncio async def test_add_step_from_capability_rejects_existing_step_id( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_add_step_duplicate") api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="echo_ws", draft=_echo_draft(), ) with pytest.raises(ValueError, match="draft step 'echo' already exists"): await authoring.add_step_from_capability( workspace_id="echo_ws", revision=1, step_id="echo", capability_name="demo.personal.echo_tool", route_from_step=None, route_from_outcome="ok", routes={"ok": "__end__"}, input_map={}, bind_outputs={}, ) @pytest.mark.asyncio @pytest.mark.parametrize( ("step_name", "step_payload"), [ ( "use", { "use": "demo.personal.echo_tool", "input": [], "output": [], }, ), ( "foreach", {"foreach": {"over": "state.items", "as": "item"}}, ), ( "interrupt", {"interrupt": {"kind": "review", "outcomes": ["submitted"]}}, ), ("join", {"join": {}}), ("end", {"end": {"outcome": "ok"}}), ( "when", { "when": { "if": {"op": "exists", "path": "state.ready"}, "then": "echo", } }, ), ( "choose", { "choose": { "clauses": [ { "if": {"op": "exists", "path": "state.ready"}, "then": "echo", } ] } }, ), ( "match", { "match": { "value": "state.status", "cases": [{"equals": "ready", "then": "echo"}], } }, ), ( "subgraph", { "subgraph": { "workflow": {"name": "child"}, "outcomes": ["ok"], } }, ), ], ) async def test_add_step_accepts_every_typed_draft_step( tmp_path: Path, step_name: str, step_payload: dict[str, Any], ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / f"draft_add_{step_name}") draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True) api = WorkflowApi(authoring.context) await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft()) step = TypeAdapter(DraftStep).validate_python(step_payload) result = await api.add_step( workspace_id="draft_ws", revision=1, step_id="new_step", step=step, ) assert result["revision"] == 2 workspace = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert workspace["draft"]["steps"]["new_step"] == step.model_dump( mode="json", by_alias=True ) @pytest.mark.asyncio async def test_add_step_routes_incoming_and_outgoing_edges_atomically( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_routes") draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True) api = WorkflowApi(authoring.context) await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft()) step = TypeAdapter(DraftStep).validate_python( {"use": "demo.personal.echo_tool", "input": [], "output": []} ) result = await api.add_step( workspace_id="draft_ws", revision=1, step_id="new_step", step=step, incoming=RouteSource("echo", "ok"), routes={"ok": "__end__"}, ) assert result["revision"] == 2 workspace = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert workspace["draft"]["routes"]["echo"]["ok"] == "new_step" assert workspace["draft"]["routes"]["new_step"] == {"ok": "__end__"} @pytest.mark.asyncio async def test_add_step_stale_revision_wins_over_content_preflight( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_stale") draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True) api = WorkflowApi(authoring.context) await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft()) before = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) step = TypeAdapter(DraftStep).validate_python({"join": {}}) result = await api.add_step( workspace_id="draft_ws", revision=2, step_id="echo", step=step, ) after = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio @pytest.mark.parametrize("operation", ["bind", "capability_add"]) async def test_capability_aware_edits_stale_revision_wins_over_semantic_errors( tmp_path: Path, operation: str, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / f"draft_capability_stale_{operation}" ) api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="draft_ws", draft=_echo_draft(), ) before = await api.get_draft_workspace( workspace_id="draft_ws", include_draft=True, ) if operation == "bind": result = await authoring.bind_draft( workspace_id="draft_ws", revision=2, step_id="missing", source_path="input.text", target_path="local.text", ) else: result = await authoring.add_step_from_capability( workspace_id="draft_ws", revision=2, step_id="new_step", capability_name="missing.connection.unknown_tool", ) after = await api.get_draft_workspace( workspace_id="draft_ws", include_draft=True, ) assert result["status"] == "conflict" assert result["revision"] == before["revision"] assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_add_step_adds_missing_incoming_route_parent_atomically( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_missing_parent") draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True) api = WorkflowApi(authoring.context) draft = _echo_draft() draft["routes"] = {} await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=draft) step = TypeAdapter(DraftStep).validate_python( {"use": "demo.personal.echo_tool", "input": [], "output": []} ) result = await api.add_step( workspace_id="draft_ws", revision=1, step_id="new_step", step=step, incoming=RouteSource("echo", "ok"), ) assert result["revision"] == 2 workspace = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert workspace["draft"]["routes"]["echo"] == {"ok": "new_step"} @pytest.mark.asyncio async def test_add_step_distinguishes_missing_and_explicit_empty_routes( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_empty_routes") draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True) api = WorkflowApi(authoring.context) await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft()) step_adapter = TypeAdapter(DraftStep) step_without_routes = step_adapter.validate_python( {"use": "demo.personal.echo_tool", "input": [], "output": []} ) first = await api.add_step( workspace_id="draft_ws", revision=1, step_id="no_routes", step=step_without_routes, routes=None, ) assert first["revision"] == 2 after_none = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert "no_routes" not in after_none["draft"]["routes"] step_with_empty_routes = step_adapter.validate_python( {"use": "demo.personal.echo_tool", "input": [], "output": []} ) second = await api.add_step( workspace_id="draft_ws", revision=2, step_id="empty_routes", step=step_with_empty_routes, routes={}, ) assert second["revision"] == 3 after_empty = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert after_empty["draft"]["routes"]["empty_routes"] == {} @pytest.mark.asyncio async def test_add_step_rejects_unknown_incoming_outcome_without_mutation( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_bad_incoming") draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True) api = WorkflowApi(authoring.context) await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft()) draft_store = authoring.drafts._draft_store() assert isinstance(draft_store, FileDraftWorkspaceStore) workspace_path = draft_store._workspace_path("draft_ws") before_bytes = workspace_path.read_bytes() before = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) step = TypeAdapter(DraftStep).validate_python( {"use": "demo.personal.echo_tool", "input": [], "output": []} ) with pytest.raises(ValueError, match="unknown incoming route outcome"): await api.add_step( workspace_id="draft_ws", revision=1, step_id="new_step", step=step, incoming=RouteSource("echo", "missing"), ) after = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert workspace_path.read_bytes() == before_bytes assert after["revision"] == before["revision"] assert after["draft"] == before["draft"] async def _assert_add_step_rejected_without_mutation( api: WorkflowApi, draft_api: WorkflowDraftApi, *, step_id: str = "new_step", incoming: RouteSource | None = None, routes: dict[str, str] | None = None, message: str, ) -> None: before = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) step = TypeAdapter(DraftStep).validate_python( {"use": "demo.personal.echo_tool", "input": [], "output": []} ) with pytest.raises(ValueError, match=message): await api.add_step( workspace_id="draft_ws", revision=1, step_id=step_id, step=step, incoming=incoming, routes=routes, ) after = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert after["revision"] == before["revision"] assert after["draft"] == before["draft"] @pytest.mark.asyncio async def test_add_step_rejects_invalid_routing_inputs_atomically( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_errors") draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True) api = WorkflowApi(authoring.context) await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft()) await _assert_add_step_rejected_without_mutation( api, draft_api, incoming=RouteSource("missing", "ok"), message="unknown incoming source", ) await _assert_add_step_rejected_without_mutation( api, draft_api, step_id="echo", routes={"ok": "__end__"}, message="already exists", ) await _assert_add_step_rejected_without_mutation( api, draft_api, routes={"typo": "__end__"}, message="unknown route outcome", ) @pytest.mark.asyncio @pytest.mark.parametrize( "step_payload", [ {"end": {"outcome": "ok"}}, { "when": { "if": {"op": "exists", "path": "state.ready"}, "then": "echo", } }, { "choose": { "clauses": [ { "if": {"op": "exists", "path": "state.ready"}, "then": "echo", } ] } }, { "match": { "value": "state.status", "cases": [{"equals": "ready", "then": "echo"}], } }, ], ) async def test_add_step_rejects_routes_for_non_routable_steps_atomically( tmp_path: Path, step_payload: dict[str, Any], ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_forbidden_routes") draft_api, _service, authoring = _draft_api(artifact_store, register_echo=True) api = WorkflowApi(authoring.context) await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft()) before = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) step = TypeAdapter(DraftStep).validate_python(step_payload) with pytest.raises(ValueError, match="routes are not allowed"): await api.add_step( workspace_id="draft_ws", revision=1, step_id="new_step", step=step, routes={"ok": "__end__"}, ) after = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert after["revision"] == before["revision"] assert after["draft"] == before["draft"] @pytest.mark.asyncio async def test_add_step_accepts_incomplete_declared_route_subset( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "draft_add_partial_routes") draft_api, service, authoring = _draft_api(artifact_store, register_echo=True) service.register_specs("demo.personal", echo_tool, _snapshot_tool) api = WorkflowApi(authoring.context) await draft_api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft()) step = TypeAdapter(DraftStep).validate_python( {"use": "demo.personal.snapshot_tool", "input": [], "output": []} ) result = await api.add_step( workspace_id="draft_ws", revision=1, step_id="new_step", step=step, routes={"ok": "__end__"}, ) assert result["revision"] == 2 workspace = await draft_api.get_draft_workspace( workspace_id="draft_ws", include_draft=True ) assert workspace["draft"]["routes"]["new_step"] == {"ok": "__end__"} @pytest.mark.asyncio async def test_branch_draft_updates_routes_atomically(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch") api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="branching", draft={ "name": "branching", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "classify", "steps": { "classify": { "use": "demo.personal.echo_tool", "input": [], "output": [], }, "tool_error": { "use": "demo.personal.echo_tool", "input": [], "output": [], }, }, "routes": { "classify": {"ok": "classify"}, "tool_error": {"ok": "__end__"}, }, }, ) result = await authoring.branch_draft( workspace_id="branching", revision=1, step_id="classify", routes={"ok": "classify", "error": "tool_error"}, ) assert result["revision"] == 2 workspace = await api.get_draft_workspace( workspace_id="branching", include_draft=True ) assert workspace["draft"]["routes"]["classify"] == { "ok": "classify", "error": "tool_error", } assert "tool_error" in workspace["draft"]["steps"] @pytest.mark.asyncio async def test_handle_draft_updates_multiple_source_outcomes(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle") api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="handling", draft={ "name": "handling", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "lookup", "steps": { "lookup": { "use": "demo.personal.echo_tool", "input": [], "output": [], }, "transform": { "use": "demo.personal.echo_tool", "input": [], "output": [], }, }, "routes": { "lookup": {"ok": "transform", "error": "lookup"}, "transform": {"ok": "__end__", "error": "transform"}, }, }, ) result = await authoring.handle_draft( workspace_id="handling", revision=1, branches=[ RouteSource(step_id="lookup", outcome="error"), RouteSource(step_id="transform", outcome="error"), ], target="__end__", ) assert result["revision"] == 2 workspace = await api.get_draft_workspace( workspace_id="handling", include_draft=True ) assert workspace["draft"]["routes"]["lookup"]["error"] == "__end__" assert workspace["draft"]["routes"]["transform"]["error"] == "__end__" assert workspace["draft"]["routes"]["lookup"]["ok"] == "transform" assert workspace["draft"]["routes"]["transform"]["ok"] == "__end__" @pytest.mark.asyncio async def test_branch_draft_no_change_when_routes_unchanged(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch_noop") api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="noop_ws", draft=_echo_draft(), ) before = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True) result = await authoring.branch_draft( workspace_id="noop_ws", revision=1, step_id="echo", routes={"ok": "__end__"}, ) after = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True) assert result["revision"] == 1 assert after == before @pytest.mark.asyncio async def test_branch_draft_no_change_still_checks_revision(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_branch_noop_stale") api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="noop_ws", draft=_echo_draft(), ) result = await authoring.branch_draft( workspace_id="noop_ws", revision=2, step_id="echo", routes={"ok": "__end__"}, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" @pytest.mark.asyncio async def test_handle_draft_empty_branches_noop(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop") api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="noop_ws", draft=_echo_draft(), ) before = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True) result = await authoring.handle_draft( workspace_id="noop_ws", revision=1, branches=[], target="fail", ) after = await api.get_draft_workspace(workspace_id="noop_ws", include_draft=True) assert result["revision"] == 1 assert after == before @pytest.mark.asyncio async def test_handle_draft_no_change_still_checks_revision(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_handle_noop_stale") api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="noop_ws", draft=_echo_draft(), ) result = await authoring.handle_draft( workspace_id="noop_ws", revision=2, branches=[], target="fail", ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" @pytest.mark.asyncio @pytest.mark.parametrize( "operation", ["branch", "handle", "remove_route", "remove_step", "remove_binding"], ) async def test_route_and_remove_edits_stale_revision_wins_over_semantic_errors( tmp_path: Path, operation: str, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / f"draft_route_remove_stale_{operation}" ) api, _service, authoring = _draft_api(artifact_store, register_echo=True) draft = _echo_draft() if operation in {"branch", "handle", "remove_route"}: draft["routes"] = "not-an-object" elif operation == "remove_step": draft["steps"] = "not-an-object" await api.create_draft_workspace(workspace_id="draft_ws", draft=draft) before = await api.get_draft_workspace( workspace_id="draft_ws", include_draft=True, ) if operation == "branch": result = await authoring.branch_draft( workspace_id="draft_ws", revision=2, step_id="echo", routes={"error": "__end__"}, ) elif operation == "handle": result = await authoring.handle_draft( workspace_id="draft_ws", revision=2, branches=[RouteSource(step_id="echo", outcome="error")], target="__end__", ) elif operation == "remove_route": result = await authoring.remove_draft_route( workspace_id="draft_ws", revision=2, step_id="echo", outcome="ok", ) elif operation == "remove_step": result = await authoring.remove_draft_step( workspace_id="draft_ws", revision=2, step_id="echo", ) else: result = await authoring.remove_draft_binding( workspace_id="draft_ws", revision=2, step_id="missing", inputs=("text",), ) after = await api.get_draft_workspace( workspace_id="draft_ws", include_draft=True, ) assert result["status"] == "conflict" assert result["revision"] == before["revision"] assert result["diagnostics"][0]["code"] == "revision_conflict" assert after == before @pytest.mark.asyncio async def test_add_step_from_capability_infers_single_outcome_route( tmp_path: Path, ) -> None: """One declared outcome named 'done', no routes supplied -> routes to __end__.""" artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_single_outcome") api, service, authoring = _draft_api(artifact_store, register_echo=True) service.register_specs("demo.personal", echo_tool) await api.create_draft_workspace( workspace_id="single", draft=_echo_draft(), ) result = await authoring.add_step_from_capability( workspace_id="single", revision=1, step_id="done_step", capability_name="demo.personal.echo_tool", ) assert result["revision"] == 2 workspace = await api.get_draft_workspace(workspace_id="single", include_draft=True) assert workspace["draft"]["routes"]["done_step"] == {"ok": "__end__"} @pytest.mark.asyncio async def test_add_step_from_capability_requires_complete_routes_for_multi_outcome( tmp_path: Path, ) -> None: """Multiple declared outcomes with incomplete explicit routes raises ValueError.""" artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_multi_outcome") api, service, authoring = _draft_api(artifact_store, register_echo=True) service.register_specs("demo.personal", echo_tool, _snapshot_tool) await api.create_draft_workspace( workspace_id="multi", draft=_echo_draft(), ) with pytest.raises(ValueError, match="missing routes"): await authoring.add_step_from_capability( workspace_id="multi", revision=1, step_id="snap", capability_name="demo.personal.snapshot_tool", routes={"ok": "__end__"}, ) @pytest.mark.asyncio async def test_add_step_from_capability_rejects_unknown_routes_for_multi_outcome( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_unknown_outcome") api, service, authoring = _draft_api(artifact_store, register_echo=True) service.register_specs("demo.personal", _snapshot_tool) await api.create_draft_workspace( workspace_id="unknown_multi", draft=_echo_draft(), ) with pytest.raises(ValueError, match="unknown routes") as exc_info: await authoring.add_step_from_capability( workspace_id="unknown_multi", revision=1, step_id="snap", capability_name="demo.personal.snapshot_tool", routes={"ok": "__end__", "skipped": "__end__", "typo": "__end__"}, ) message = str(exc_info.value) assert "declares outcomes ('ok', 'skipped')" in message assert "unknown routes ['typo']" in message assert "remove --route entries for ['typo']" in message @pytest.mark.asyncio async def test_add_step_rejects_unknown_single_outcome_route_with_repair( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / "drafts_unknown_single_outcome" ) api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="unknown_single", draft=_echo_draft(), ) with pytest.raises(ValueError) as exc_info: await authoring.add_step_from_capability( workspace_id="unknown_single", revision=1, step_id="second", capability_name="demo.personal.echo_tool", routes={"ok": "__end__", "error": "fail"}, ) message = str(exc_info.value) assert "declares outcomes ('ok',)" in message assert "unknown routes ['error']" in message assert "remove --route entries for ['error']" in message @pytest.mark.asyncio async def test_add_step_rejects_missing_outcomes_only_with_repair( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_missing_outcome_only") api, service, authoring = _draft_api(artifact_store, register_echo=True) service.register_specs("demo.personal", _snapshot_tool) await api.create_draft_workspace( workspace_id="missing_single", draft=_echo_draft(), ) with pytest.raises(ValueError) as exc_info: await authoring.add_step_from_capability( workspace_id="missing_single", revision=1, step_id="snap", capability_name="demo.personal.snapshot_tool", routes={"ok": "__end__"}, ) message = str(exc_info.value) assert "declares outcomes ('ok', 'skipped')" in message assert "missing routes ['skipped']" in message assert "add --route OUTCOME=TARGET for ['skipped']" in message # -- Draft remove helpers -- @pytest.mark.asyncio async def test_remove_draft_route_persists_invalid_workspace(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_route") api, _service, authoring = _draft_api(artifact_store) await api.create_draft_workspace( workspace_id="route_ws", draft={ "name": "route_ws", "start": "echo", "steps": { "echo": {"use": "demo.personal.echo_tool", "input": [], "output": []} }, "routes": {"echo": {"ok": "__end__"}}, "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, }, ) result = await authoring.remove_draft_route( workspace_id="route_ws", revision=1, step_id="echo", outcome="ok", ) assert result["revision"] == 2 assert result["status"] == "invalid" fetched = await api.get_draft_workspace( workspace_id="route_ws", include_draft=True, ) assert fetched["draft"]["routes"]["echo"] == {} @pytest.mark.asyncio async def test_remove_draft_step_removes_outgoing_routes_not_inbound_routes( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_step") api, _service, authoring = _draft_api(artifact_store) await api.create_draft_workspace( workspace_id="step_ws", draft={ "name": "step_ws", "start": "first", "steps": { "first": {"use": "demo.personal.echo_tool", "input": [], "output": []}, "second": {"use": "demo.personal.echo_tool", "input": [], "output": []}, }, "routes": { "first": {"ok": "second"}, "second": {"ok": "__end__"}, }, "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, }, ) result = await authoring.remove_draft_step( workspace_id="step_ws", revision=1, step_id="second", ) assert result["revision"] == 2 assert result["status"] == "invalid" assert any( item["code"] == "unknown_edge_destination" for item in result["diagnostics"] ) fetched = await api.get_draft_workspace( workspace_id="step_ws", include_draft=True, ) assert "second" not in fetched["draft"]["steps"] assert "second" not in fetched["draft"]["routes"] assert fetched["draft"]["routes"]["first"]["ok"] == "second" @pytest.mark.asyncio async def test_remove_draft_binding_removes_input_and_output_bindings( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_binding") api, _service, authoring = _draft_api(artifact_store) await api.create_draft_workspace( workspace_id="binding_ws", draft={ "name": "binding_ws", "start": "echo", "steps": { "echo": { "use": "demo.personal.echo_tool", "input": [ {"path": "input.message", "target": "message"}, {"path": "input.extra", "target": "extra"}, ], "output": [ {"source": "echoed", "target": "state.echoed"}, {"source": "debug", "target": "state.debug"}, ], } }, "routes": {"echo": {"ok": "__end__"}}, "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, }, ) result = await authoring.remove_draft_binding( workspace_id="binding_ws", revision=1, step_id="echo", inputs=("message",), outputs=("debug",), ) assert result["revision"] == 2 fetched = await api.get_draft_workspace( workspace_id="binding_ws", include_draft=True, ) assert fetched["draft"]["steps"]["echo"]["input"] == [ {"path": "input.extra", "target": "extra"} ] assert fetched["draft"]["steps"]["echo"]["output"] == [ {"source": "echoed", "target": "state.echoed"} ] @pytest.mark.asyncio async def test_remove_draft_binding_envelope_error_precedes_revision_check( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / "draft_remove_binding_envelope_precedence" ) api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace(workspace_id="draft_ws", draft=_echo_draft()) before = await api.get_draft_workspace( workspace_id="draft_ws", include_draft=True, ) with pytest.raises( ValueError, match="pass at least one input or output binding to remove", ): await authoring.remove_draft_binding( workspace_id="draft_ws", revision=2, step_id="echo", ) after = await api.get_draft_workspace( workspace_id="draft_ws", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_remove_missing_draft_element_is_noop(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "remove_noop") api, _service, authoring = _draft_api(artifact_store) await api.create_draft_workspace( workspace_id="noop_ws", draft={ "name": "noop_ws", "start": "echo", "steps": { "echo": {"use": "demo.personal.echo_tool", "input": [], "output": []} }, "routes": {"echo": {"ok": "__end__"}}, "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, }, ) result = await authoring.remove_draft_route( workspace_id="noop_ws", revision=1, step_id="echo", outcome="missing", ) assert result["revision"] == 1 @pytest.mark.asyncio async def test_compile_draft_workspace_returns_compiled_plan(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="compile_me", draft=_echo_draft(), ) before = await api.get_draft_workspace( workspace_id="compile_me", include_draft=True ) result = await api.compile_draft_workspace(workspace_id="compile_me") after = await api.get_draft_workspace(workspace_id="compile_me", include_draft=True) assert _compiled_workspace(result)["name"] == "echo" assert "required_capabilities" in result assert result["required_capabilities"] assert after == before @pytest.mark.asyncio async def test_compile_draft_workspace_invalid_returns_diagnostics( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_compile_invalid") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) draft = _echo_draft() draft["routes"]["echo"] = {"typo": "__end__"} await api.create_draft_workspace( workspace_id="invalid_ws", draft=draft, ) result = await api.compile_draft_workspace(workspace_id="invalid_ws") assert "status" in result assert result["status"] == "invalid" assert "compiled_plan" not in result assert result["diagnostics"] def _workflow_output_binding_draft() -> dict[str, Any]: draft = _echo_draft() draft["state_schema"] = { "type": "object", "properties": { "report": { "type": "object", "properties": {"title": {"type": "string"}}, }, "items": {"type": "array", "items": {"type": "string"}}, }, } draft["output_schema"] = { "type": "object", "properties": { "format": {"type": "string"}, "metadata": { "type": "object", "properties": {"reviewed": {"type": "boolean"}}, "required": ["reviewed"], }, "tags": {"type": "array", "items": {"type": "string"}}, "note": {"type": ["string", "null"]}, "context_value": {}, }, } draft["output"] = [] return draft @pytest.mark.asyncio async def test_set_workflow_output_bindings_projects_nested_paths_and_literals( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "workflow_outputs"), register_echo=True, ) draft = _workflow_output_binding_draft() await draft_api.create_draft_workspace(workspace_id="report", draft=draft) bindings: list[InputBinding] = [ InputPathBinding( path=GraphSourcePath.state("report", "title"), target=LocalPath.of("report", "title"), ), InputPathBinding( path=GraphSourcePath.state("report", "title"), target=LocalPath.of("audit", "title"), ), InputValueBinding(target=LocalPath.of("format"), value="markdown"), ] first = await authoring.set_workflow_output_bindings( workspace_id="report", revision=1, bindings=bindings, ) second = await authoring.set_workflow_output_bindings( workspace_id="report", revision=first["revision"], bindings=bindings, ) inspected = await draft_api.get_draft_workspace( workspace_id="report", include_draft=True, ) assert first["revision"] == 2 assert second["revision"] == 2 assert inspected["draft"]["output"] == [ {"path": "state.report.title", "target": "report.title"}, {"path": "state.report.title", "target": "audit.title"}, {"value": "markdown", "target": "format"}, ] assert inspected["draft"]["output_schema"]["properties"]["report"]["properties"][ "title" ] == {"type": "string"} assert inspected["draft"]["output_schema"]["properties"]["audit"]["properties"][ "title" ] == {"type": "string"} cleared = await authoring.set_workflow_output_bindings( workspace_id="report", revision=2, bindings=[], ) cleared_workspace = await draft_api.get_draft_workspace( workspace_id="report", include_draft=True, ) assert cleared["revision"] == 3 assert cleared_workspace["draft"]["output"] == [] assert ( cleared_workspace["draft"]["output_schema"] == inspected["draft"]["output_schema"] ) @pytest.mark.asyncio async def test_set_workflow_output_bindings_projects_input_and_whole_state( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "workflow_output_sources"), register_echo=True, ) draft = _workflow_output_binding_draft() draft["input_schema"]["properties"]["request"] = { "type": "object", "properties": {"title": {"type": "string"}}, } await draft_api.create_draft_workspace(workspace_id="report", draft=draft) await authoring.set_workflow_output_bindings( workspace_id="report", revision=1, bindings=[ InputPathBinding( path=GraphSourcePath.input("request", "title"), target=LocalPath.of("input_title"), ), InputPathBinding( path=GraphSourcePath.state(), target=LocalPath.of("snapshot"), ), ], ) inspected = await draft_api.get_draft_workspace( workspace_id="report", include_draft=True, ) output_schema = inspected["draft"]["output_schema"]["properties"] assert output_schema["input_title"] == {"type": "string"} assert output_schema["snapshot"]["properties"]["report"]["properties"]["title"] == { "type": "string" } @pytest.mark.asyncio async def test_set_workflow_output_bindings_accepts_declared_context_and_literals( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "workflow_output_values"), register_echo=True, ) await draft_api.create_draft_workspace( workspace_id="report", draft=_workflow_output_binding_draft(), ) bindings: list[InputBinding] = [ InputPathBinding( path=GraphSourcePath.context("prior_outcome"), target=LocalPath.of("context_value"), ), InputValueBinding( target=LocalPath.of("metadata"), value={"reviewed": True}, ), InputValueBinding(target=LocalPath.of("tags"), value=["thesis"]), InputValueBinding(target=LocalPath.of("note"), value=None), ] result = await authoring.set_workflow_output_bindings( workspace_id="report", revision=1, bindings=bindings, ) inspected = await draft_api.get_draft_workspace( workspace_id="report", include_draft=True, ) assert result["revision"] == 2 assert inspected["draft"]["output"] == [ {"path": "context.prior_outcome", "target": "context_value"}, {"value": {"reviewed": True}, "target": "metadata"}, {"value": ["thesis"], "target": "tags"}, {"value": None, "target": "note"}, ] @pytest.mark.asyncio async def test_set_workflow_output_bindings_accepts_strict_root_bindings( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "workflow_output_root"), register_echo=True, ) schema = { "type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"], } draft = _echo_draft() draft["input_schema"] = schema draft["output_schema"] = schema draft["output"] = [] await draft_api.create_draft_workspace(workspace_id="report", draft=draft) path_result = await authoring.set_workflow_output_bindings( workspace_id="report", revision=1, bindings=[ InputPathBinding( path=GraphSourcePath.input(), target=LocalPath.root(), ) ], ) literal_result = await authoring.set_workflow_output_bindings( workspace_id="report", revision=path_result["revision"], bindings=[ InputValueBinding( target=LocalPath.root(), value={"text": "Thesis"}, ) ], ) assert path_result["revision"] == 2 assert literal_result["revision"] == 3 @pytest.mark.asyncio async def test_set_workflow_output_bindings_validates_root_literal_complete_schema( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "workflow_output_root_literal"), register_echo=True, ) draft = _echo_draft() draft["output_schema"] = { "type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"], } draft["output"] = [] await draft_api.create_draft_workspace(workspace_id="report", draft=draft) before = await draft_api.get_draft_workspace( workspace_id="report", include_draft=True, ) with pytest.raises( ValueError, match=r"bindings\[0\]\.value does not satisfy schema at '\.'", ): await authoring.set_workflow_output_bindings( workspace_id="report", revision=1, bindings=[ InputValueBinding( target=LocalPath.root(), value={"wrong": "field"}, ) ], ) after = await draft_api.get_draft_workspace( workspace_id="report", include_draft=True, ) assert after == before @pytest.mark.asyncio @pytest.mark.parametrize( ("bindings", "message"), [ ( [ InputPathBinding( path=GraphSourcePath.state("missing"), target=LocalPath.of("missing"), ) ], r"bindings\[0\]\.path 'state\.missing' is not declared", ), ( [ InputPathBinding( path=GraphSourcePath.context("prior_outcome"), target=LocalPath.of("missing"), ) ], r"bindings\[0\]\.path 'context\.prior_outcome' requires a declared " "output target", ), ( [InputValueBinding(target=LocalPath.of("missing"), value="x")], r"bindings\[0\]\.target 'missing' is not declared", ), ( [ InputPathBinding( path=GraphSourcePath.state("report"), target=LocalPath.of("report"), ), InputValueBinding( target=LocalPath.of("report", "format"), value="markdown", ), ], r"bindings\[0\]\.target 'report' overlaps bindings\[1\]", ), ( [ InputValueBinding( target=LocalPath.of("format"), value="markdown", ), InputValueBinding( target=LocalPath.of("format"), value="json", ), ], r"bindings\[0\]\.target 'format' overlaps bindings\[1\]", ), ( [ InputPathBinding( path=GraphSourcePath.state(), target=LocalPath.root(), ), InputValueBinding( target=LocalPath.of("format"), value="markdown", ), ], r"bindings\[0\]\.target '\.' overlaps bindings\[1\]", ), ( [ InputPathBinding( path=GraphSourcePath.state("missing"), target=LocalPath.of("report"), ), InputValueBinding( target=LocalPath.of("report", "format"), value="markdown", ), ], r"bindings\[0\]\.path 'state\.missing' is not declared", ), ( [ InputPathBinding( path=GraphSourcePath.state("report"), target=LocalPath.of("format"), ) ], r"bindings\[0\]\.target 'format' cannot receive source " r"'state\.report'", ), ( [ InputPathBinding( path=GraphSourcePath.state("report"), target=LocalPath.root(), ) ], r"bindings\[0\]\.target '\.' already has an incompatible schema", ), ( [ InputPathBinding( path=GraphSourcePath.context("prior_outcome"), target=LocalPath.root(), ) ], r"bindings\[0\]\.path 'context\.prior_outcome' cannot target '\.'", ), ( [InputValueBinding(target=LocalPath.root(), value="not-an-object")], r"bindings\[0\]\.value for root target must be an object", ), ], ) async def test_set_workflow_output_bindings_rejects_without_mutation( tmp_path: Path, bindings: list[InputBinding], message: str, ) -> None: # Pytest gives every parameter case an isolated tmp_path, so a stable id is # sufficient and keeps failure artifacts reproducible across processes. workspace_id = "invalid_output" draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / workspace_id), register_echo=True, ) await draft_api.create_draft_workspace( workspace_id=workspace_id, draft=_workflow_output_binding_draft(), ) before = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) with pytest.raises(ValueError, match=message): await authoring.set_workflow_output_bindings( workspace_id=workspace_id, revision=1, bindings=bindings, ) after = await draft_api.get_draft_workspace( workspace_id=workspace_id, include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_set_workflow_output_bindings_stale_revision_wins_without_mutation( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "stale_workflow_output"), register_echo=True, ) await draft_api.create_draft_workspace( workspace_id="report", draft=_workflow_output_binding_draft(), ) before = await draft_api.get_draft_workspace( workspace_id="report", include_draft=True, ) result = await authoring.set_workflow_output_bindings( workspace_id="report", revision=2, bindings=[ InputValueBinding(target=LocalPath.of("missing"), value="x"), InputValueBinding(target=LocalPath.of("missing", "child"), value="y"), ], ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" after = await draft_api.get_draft_workspace( workspace_id="report", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_set_workflow_output_bindings_compile_and_execute( tmp_path: Path, ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "execute_workflow_output"), register_echo=True, ) draft = _echo_draft() draft["state_schema"] = { "type": "object", "properties": { "report": { "type": "object", "properties": {"title": {"type": "string"}}, } }, } draft["steps"]["echo"]["output"] = [ {"source": "echoed", "target": "state.report.title"} ] draft["output_schema"] = { "type": "object", "properties": {"format": {"type": "string"}}, } draft["output"] = [] await draft_api.create_draft_workspace(workspace_id="report", draft=draft) await authoring.set_workflow_output_bindings( workspace_id="report", revision=1, bindings=[ InputPathBinding( path=GraphSourcePath.state("report", "title"), target=LocalPath.of("report", "title"), ), InputPathBinding( path=GraphSourcePath.state("report", "title"), target=LocalPath.of("audit", "title"), ), InputValueBinding(target=LocalPath.of("format"), value="markdown"), ], ) compiled = await draft_api.compile_draft_workspace(workspace_id="report") run = await service.run_workflow_from_plan( RawWorkflowPlan.model_validate(_compiled_workspace(compiled)), {"text": "Thesis"}, ) assert run.error is None assert run.output == { "report": {"title": "Thesis"}, "audit": {"title": "Thesis"}, "format": "markdown", } @pytest.mark.asyncio async def test_cleared_workflow_output_bindings_preserve_state_fallback( tmp_path: Path, ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "execute_workflow_output_fallback"), register_echo=True, ) draft = _echo_draft() draft["output"] = [] await draft_api.create_draft_workspace(workspace_id="report", draft=draft) first = await authoring.set_workflow_output_bindings( workspace_id="report", revision=1, bindings=[InputValueBinding(target=LocalPath.of("echoed"), value="explicit")], ) await authoring.set_workflow_output_bindings( workspace_id="report", revision=first["revision"], bindings=[], ) compiled = await draft_api.compile_draft_workspace(workspace_id="report") run = await service.run_workflow_from_plan( RawWorkflowPlan.model_validate(_compiled_workspace(compiled)), {"text": "Thesis"}, ) assert run.error is None assert run.output == {"echoed": "Thesis"} @pytest.mark.asyncio async def test_set_workflow_output_map_replaces_top_level_output( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_replace") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace(workspace_id="report", draft=_echo_draft()) result = await api.set_workflow_output_map( workspace_id="report", revision=1, output_map={"state.echoed": "echoed"}, ) assert result["revision"] == 2 assert result["status"] == "valid", result["diagnostics"] fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True) assert fetched["draft"]["output"] == [{"path": "state.echoed", "target": "echoed"}] @pytest.mark.asyncio async def test_set_workflow_output_map_merges_top_level_output(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_merge") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) draft = { **_echo_draft(), "state_schema": { "fields": { "echoed": {"type": "string"}, "other": {"type": "string"}, } }, "output_schema": { "type": "object", "properties": { "echoed": {"type": "string"}, "kind": {"type": "string"}, "other": {"type": "string"}, }, }, "output": [ {"path": "state.echoed", "target": "echoed"}, {"value": "report", "target": "kind"}, ], } await api.create_draft_workspace(workspace_id="report", draft=draft) result = await api.set_workflow_output_map( workspace_id="report", revision=1, output_map={"state.other": "other"}, merge=True, ) assert result["status"] == "valid", result["diagnostics"] fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True) assert fetched["draft"]["output"] == [ {"path": "state.echoed", "target": "echoed"}, {"value": "report", "target": "kind"}, {"path": "state.other", "target": "other"}, ] @pytest.mark.asyncio async def test_workflow_output_map_merge_rejects_requested_fan_out_source( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "workflow_merge_fan_out") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) draft = { **_echo_draft(), "output_schema": { "type": "object", "properties": { "first": {"type": "string"}, "second": {"type": "string"}, "kind": {"type": "string"}, }, }, "output": [ {"path": "state.echoed", "target": "first"}, {"path": "state.echoed", "target": "second"}, {"value": "markdown", "target": "kind"}, ], } await api.create_draft_workspace(workspace_id="report", draft=draft) before = await api.get_draft_workspace( workspace_id="report", include_draft=True, ) with pytest.raises(ValueError, match="multiple bindings"): await api.set_workflow_output_map( workspace_id="report", revision=1, output_map={"state.echoed": "renamed"}, merge=True, ) after = await api.get_draft_workspace( workspace_id="report", include_draft=True, ) assert after == before @pytest.mark.asyncio async def test_workflow_output_map_merge_checks_revision_before_ambiguity( tmp_path: Path, ) -> None: api, _service, _authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "workflow_merge_stale_fan_out"), register_echo=True, ) draft = { **_echo_draft(), "output": [ {"path": "state.echoed", "target": "first"}, {"path": "state.echoed", "target": "second"}, ], } await api.create_draft_workspace(workspace_id="report", draft=draft) await api.set_draft_name( workspace_id="report", revision=1, name="report_v2", ) before = await api.get_draft_workspace( workspace_id="report", include_draft=True, ) result = await api.set_workflow_output_map( workspace_id="report", revision=1, output_map={"state.echoed": "renamed"}, merge=True, ) after = await api.get_draft_workspace( workspace_id="report", include_draft=True, ) assert result["status"] == "conflict" assert result["diagnostics"][0]["code"] == "revision_conflict" assert _required_draft(result) == before["draft"] assert after == before @pytest.mark.asyncio async def test_workflow_output_map_merge_preserves_unrequested_fan_out_and_literals( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / "workflow_merge_unrequested_fan_out" ) api, _service, _authoring = _draft_api(artifact_store, register_echo=True) draft = { **_echo_draft(), "state_schema": { "fields": { "echoed": {"type": "string"}, "other": {"type": "string"}, } }, "output_schema": { "type": "object", "properties": { "first": {"type": "string"}, "second": {"type": "string"}, "kind": {"type": "string"}, "other": {"type": "string"}, }, }, "output": [ {"path": "state.echoed", "target": "first"}, {"path": "state.echoed", "target": "second"}, {"value": "markdown", "target": "kind"}, ], } await api.create_draft_workspace(workspace_id="report", draft=draft) result = await api.set_workflow_output_map( workspace_id="report", revision=1, output_map={"state.other": "other"}, merge=True, ) fetched = await api.get_draft_workspace( workspace_id="report", include_draft=True, ) assert result["revision"] == 2 assert fetched["draft"]["output"] == [ {"path": "state.echoed", "target": "first"}, {"path": "state.echoed", "target": "second"}, {"value": "markdown", "target": "kind"}, {"path": "state.other", "target": "other"}, ] def _composite_concat_draft() -> dict[str, Any]: return { "name": "composite_concat", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "concat", "steps": { "concat": { "use": "wf.std.concat", "input": [], "output": [], } }, "routes": {"concat": {"ok": "__end__"}}, } @pytest.mark.asyncio async def test_set_step_input_bindings_projects_composite_source_schema_and_order( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "composite_concat"), ) await draft_api.create_draft_workspace( workspace_id="concat", draft=_composite_concat_draft(), ) result = await authoring.set_step_input_bindings( workspace_id="concat", revision=1, step_id="concat", bindings=[ InputExpressionBinding.model_validate( { "target": "items", "expression": { "kind": "array", "items": [ {"kind": "path", "path": "state.foo"}, {"kind": "literal", "value": "wowcool"}, ], }, } ), InputValueBinding(target=LocalPath.of("separator"), value=" "), ], ) assert result["revision"] == 2 assert _required_draft(result)["steps"]["concat"]["input"] == [ { "target": "items", "expression": { "kind": "array", "items": [ {"kind": "path", "path": "state.foo"}, {"kind": "literal", "value": "wowcool"}, ], }, }, {"target": "separator", "value": " "}, ] assert _required_draft(result)["state_schema"]["properties"]["foo"] == { "type": "string" } @pytest.mark.asyncio async def test_set_step_input_bindings_rejects_incompatible_expression_atomically( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "composite_concat_incompatible"), ) await draft_api.create_draft_workspace( workspace_id="concat", draft={ **_composite_concat_draft(), "state_schema": { "type": "object", "properties": {"foo": {"type": "integer"}}, }, }, ) before = await draft_api.get_draft_workspace( workspace_id="concat", include_draft=True ) with pytest.raises(ValueError, match="state.foo"): await authoring.set_step_input_bindings( workspace_id="concat", revision=1, step_id="concat", bindings=[ InputExpressionBinding.model_validate( { "target": "items", "expression": { "kind": "array", "items": [{"kind": "path", "path": "state.foo"}], }, } ) ], ) after = await draft_api.get_draft_workspace( workspace_id="concat", include_draft=True ) assert after == before @pytest.mark.asyncio async def test_set_step_input_bindings_rejects_overlapping_expression_targets_atomically( tmp_path: Path, ) -> None: draft_api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "composite_concat_overlap"), ) await draft_api.create_draft_workspace( workspace_id="concat", draft=_composite_concat_draft(), ) before = await draft_api.get_draft_workspace( workspace_id="concat", include_draft=True ) with pytest.raises(ValueError, match="overlaps"): await authoring.set_step_input_bindings( workspace_id="concat", revision=1, step_id="concat", bindings=[ InputExpressionBinding.model_validate( { "target": "items", "expression": { "kind": "array", "items": [{"kind": "literal", "value": "hello"}], }, } ), InputValueBinding( target=LocalPath.of("items", "0"), value="shadowed", ), ], ) after = await draft_api.get_draft_workspace( workspace_id="concat", include_draft=True ) assert after == before @pytest.mark.asyncio async def test_set_step_input_bindings_uses_additional_property_source_schema( tmp_path: Path, ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "composite_concat_additional_source"), register_echo=True, ) service.register_specs( "demo.personal", replace( echo_tool, input_schema_contract={ "type": "object", "properties": {"items": {"type": "array", "items": {"type": "string"}}}, }, ), ) draft = _composite_concat_draft() draft["steps"]["concat"]["use"] = "demo.personal.echo_tool" draft["state_schema"] = { "type": "object", "additionalProperties": {"type": "integer"}, } await draft_api.create_draft_workspace(workspace_id="concat", draft=draft) with pytest.raises(ValueError, match="incompatible"): await authoring.set_step_input_bindings( workspace_id="concat", revision=1, step_id="concat", bindings=[ InputExpressionBinding.model_validate( { "target": "items", "expression": { "kind": "array", "items": [{"kind": "path", "path": "state.foo"}], }, } ) ], ) @pytest.mark.asyncio @pytest.mark.parametrize( "additional_schema", [ {"$ref": "https://example.com/schema.json"}, {"$ref": "#/$defs/Missing"}, {"$ref": "#/$defs/Node"}, ], ) async def test_set_step_input_bindings_rejects_unresolvable_additional_source_refs( tmp_path: Path, additional_schema: dict[str, object], ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "composite_concat_bad_additional_source"), register_echo=True, ) service.register_specs( "demo.personal", replace( echo_tool, input_schema_contract={ "type": "object", "properties": {"items": {"type": "array", "items": {"type": "string"}}}, }, ), ) draft = _composite_concat_draft() draft["steps"]["concat"]["use"] = "demo.personal.echo_tool" state_schema: dict[str, object] = { "type": "object", "additionalProperties": additional_schema, } if additional_schema == {"$ref": "#/$defs/Node"}: state_schema["$defs"] = { "Node": { "type": "object", "properties": {"next": {"$ref": "#/$defs/Node"}}, } } draft["state_schema"] = state_schema await draft_api.create_draft_workspace(workspace_id="concat", draft=draft) before = await draft_api.get_draft_workspace( workspace_id="concat", include_draft=True ) with pytest.raises(ValueError, match="reference|unsupported|cyclic|unresolved"): await authoring.set_step_input_bindings( workspace_id="concat", revision=1, step_id="concat", bindings=[ InputExpressionBinding.model_validate( { "target": "items", "expression": { "kind": "array", "items": [{"kind": "path", "path": "state.foo"}], }, } ) ], ) after = await draft_api.get_draft_workspace( workspace_id="concat", include_draft=True ) assert after == before @pytest.mark.asyncio @pytest.mark.parametrize( "additional_schema", [ {"$ref": "https://example.com/schema.json"}, {"$ref": "#/$defs/Missing"}, {"$ref": "#/$defs/Node"}, ], ) async def test_set_step_input_bindings_rejects_unresolvable_additional_path_refs( tmp_path: Path, additional_schema: dict[str, object], ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "composite_concat_bad_additional_path"), register_echo=True, ) service.register_specs( "demo.personal", replace( echo_tool, input_schema_contract={ "type": "object", "properties": {"items": {"type": "array", "items": {"type": "string"}}}, }, ), ) draft = _composite_concat_draft() draft["steps"]["concat"]["use"] = "demo.personal.echo_tool" state_schema: dict[str, object] = { "type": "object", "additionalProperties": additional_schema, } if additional_schema == {"$ref": "#/$defs/Node"}: state_schema["$defs"] = { "Node": { "type": "object", "properties": {"next": {"$ref": "#/$defs/Node"}}, } } draft["state_schema"] = state_schema await draft_api.create_draft_workspace(workspace_id="concat", draft=draft) with pytest.raises(ValueError, match="reference|unsupported|cyclic|unresolved"): await authoring.set_step_input_bindings( workspace_id="concat", revision=1, step_id="concat", bindings=[ InputPathBinding( path=GraphSourcePath.state("foo"), target=LocalPath.of("items"), ) ], ) @pytest.mark.asyncio async def test_set_step_input_bindings_accepts_simple_source_with_min_length_schema( tmp_path: Path, ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "composite_concat_min_length_source"), register_echo=True, ) service.register_specs( "demo.personal", replace( echo_tool, input_schema_contract={ "type": "object", "properties": {"separator": {"type": "string"}}, }, ), ) draft = _composite_concat_draft() draft["steps"]["concat"]["use"] = "demo.personal.echo_tool" draft["state_schema"] = { "type": "object", "properties": {"foo": {"type": "string", "minLength": 1}}, } await draft_api.create_draft_workspace(workspace_id="concat", draft=draft) result = await authoring.set_step_input_bindings( workspace_id="concat", revision=1, step_id="concat", bindings=[ InputPathBinding( path=GraphSourcePath.state("foo"), target=LocalPath.of("separator"), ) ], ) assert result["revision"] == 2 @pytest.mark.asyncio async def test_set_step_input_bindings_accepts_additional_property_target( tmp_path: Path, ) -> None: draft_api, service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "composite_concat_additional_target"), register_echo=True, ) service.register_specs( "demo.personal", replace( echo_tool, input_schema_contract={ "type": "object", "additionalProperties": {"type": "string"}, }, ), ) draft = _composite_concat_draft() draft["steps"]["concat"]["use"] = "demo.personal.echo_tool" await draft_api.create_draft_workspace(workspace_id="concat", draft=draft) result = await authoring.set_step_input_bindings( workspace_id="concat", revision=1, step_id="concat", bindings=[ InputExpressionBinding.model_validate( { "target": "dynamic", "expression": {"kind": "literal", "value": "hello"}, } ) ], ) assert result["revision"] == 2 assert _required_draft(result)["steps"]["concat"]["input"][0]["target"] == "dynamic" @pytest.mark.asyncio async def test_set_step_input_map_merge_rejects_existing_expression_without_mutation( tmp_path: Path, ) -> None: draft_api, _service, _authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "composite_concat_merge"), ) draft = _composite_concat_draft() await draft_api.create_draft_workspace(workspace_id="concat", draft=draft) draft["steps"]["concat"]["input"] = [ { "target": "items", "expression": { "kind": "array", "items": [{"kind": "literal", "value": "hello"}], }, } ] await draft_api.replace_validated_draft_document( workspace_id="concat", revision=1, draft=draft, ) before = await draft_api.get_draft_workspace( workspace_id="concat", include_draft=True ) with pytest.raises(ValueError, match="cannot be safely merged"): await draft_api.set_step_input_map( workspace_id="concat", revision=2, step_id="concat", input_map={"separator": "separator"}, merge=True, ) after = await draft_api.get_draft_workspace( workspace_id="concat", include_draft=True ) assert after == before @pytest.mark.asyncio async def test_set_workflow_output_map_projects_missing_output_schema( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_out_project") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) draft = { **_echo_draft(), "state_schema": { "type": "object", "properties": { "echoed": {"type": "string"}, "after": {"type": "object"}, }, }, "output_schema": {"type": "object", "properties": {}}, "output": [], } await api.create_draft_workspace(workspace_id="report", draft=draft) result = await api.set_workflow_output_map( workspace_id="report", revision=1, output_map={"state.after": "after"}, merge=True, ) assert result["status"] == "valid", result["diagnostics"] fetched = await api.get_draft_workspace(workspace_id="report", include_draft=True) assert fetched["draft"]["output"] == [{"path": "state.after", "target": "after"}] assert fetched["draft"]["output_schema"]["properties"]["after"] == { "type": "object" } # -- Browser-click test helpers for forward-route tests -- class _OpenClickPageInput(BaseModel): open_browser: bool = False class _OpenClickPageOutput(BaseModel): before: dict = {} session_id: str = "" class _WaitForClickInput(BaseModel): session_id: str simulate: dict timeout_seconds: int class _WaitForClickOutput(BaseModel): after: dict = {} class _CollectSnapshotsInput(BaseModel): session_id: str before: dict after: dict class _CollectSnapshotsOutput(BaseModel): before: dict = {} after: dict = {} @node(name="open_click_page", outcomes=("ok",)) def _open_click_page(payload: _OpenClickPageInput) -> _OpenClickPageOutput: return _OpenClickPageOutput(before={}, session_id="") @node(name="wait_for_click", outcomes=("ok",)) def _wait_for_click(payload: _WaitForClickInput) -> _WaitForClickOutput: return _WaitForClickOutput(after={}) @node(name="collect_snapshots", outcomes=("ok",)) def _collect_snapshots(payload: _CollectSnapshotsInput) -> _CollectSnapshotsOutput: return _CollectSnapshotsOutput(before={}, after={}) def _browser_click_api( artifact_store: FileWorkflowArtifactStore, ) -> tuple[WorkflowApi, WfMcpService]: mcp_root = artifact_store.root / "browser_mcp" / str(id(artifact_store)) service = WfMcpService( store=FileStore(mcp_root), artifact_store=artifact_store, draft_workspace_store=FileDraftWorkspaceStore(mcp_root), ) service.register_connection( ConnectionConfig( id="local.browser_click", server="local", account="browser_click" ) ) service.register_specs( "local.browser_click", _open_click_page, _wait_for_click, _collect_snapshots, ) context = context_from_service(service) return WorkflowApi(context), service @pytest.mark.asyncio async def test_create_draft_from_capability_does_not_bind_optional_inputs( tmp_path: Path, ) -> None: api, _service = _browser_click_api( FileWorkflowArtifactStore(tmp_path / "drafts_required_inputs") ) created = await api.create_draft_workspace_from_capability( workspace_id="browser", capability_name="local.browser_click.open_click_page", name="browser", ) workspace = await api.get_draft_workspace( workspace_id="browser", include_draft=True, ) assert created["wrapper_hints"]["input_map"] == {} assert workspace["draft"]["steps"]["call"]["input"] == [] assert any("open_browser" in note for note in created["wrapper_hints"]["notes"]) @pytest.mark.asyncio async def test_add_step_projects_explicit_optional_workflow_inputs( tmp_path: Path, ) -> None: api, _service = _browser_click_api( FileWorkflowArtifactStore(tmp_path / "drafts_explicit_optional_input") ) await api.create_draft_workspace_from_capability( workspace_id="browser", capability_name="local.browser_click.open_click_page", name="browser", ) result = await api.add_step_from_capability( workspace_id="browser", revision=1, step_id="wait", capability_name="local.browser_click.wait_for_click", route_from_step="call", routes={"ok": "__end__"}, input_map={ "state.session_id": "session_id", "input.simulate": "simulate", "input.timeout_seconds": "timeout_seconds", }, bind_outputs={"after": "state.after"}, ) assert result["status"] == "valid", result["diagnostics"] workspace = await api.get_draft_workspace( workspace_id="browser", include_draft=True ) properties = workspace["draft"]["input_schema"]["properties"] assert properties["simulate"]["type"] == "object" assert properties["timeout_seconds"]["type"] == "integer" @pytest.mark.asyncio async def test_add_step_persists_invalid_forward_route(tmp_path: Path) -> None: api, _service = _browser_click_api( FileWorkflowArtifactStore(tmp_path / "drafts_forward_route") ) await api.create_draft_workspace_from_capability( workspace_id="browser", capability_name="local.browser_click.open_click_page", name="browser", ) # Use only state.session_id — simulate/timeout_seconds are not declared # in the workflow input schema from open_click_page. result = await api.add_step_from_capability( workspace_id="browser", revision=1, step_id="wait", capability_name="local.browser_click.wait_for_click", route_from_step="call", routes={"ok": "collect"}, input_map={ "state.session_id": "session_id", }, bind_outputs={"after": "state.after"}, ) assert result["revision"] == 2 assert result["status"] == "invalid" assert any( item["code"] == "unknown_edge_destination" for item in result["diagnostics"] ) stored = await api.get_draft_workspace( workspace_id="browser", include_draft=True, ) assert ( stored["draft"]["steps"]["wait"]["use"] == "local.browser_click.wait_for_click" ) assert stored["draft"]["routes"]["wait"]["ok"] == "collect" @pytest.mark.asyncio async def test_invalid_forward_route_cannot_compile_or_save(tmp_path: Path) -> None: api, _service = _browser_click_api( FileWorkflowArtifactStore(tmp_path / "drafts_compile_boundary") ) await api.create_draft_workspace_from_capability( workspace_id="browser", capability_name="local.browser_click.open_click_page", name="browser", ) await api.add_step_from_capability( workspace_id="browser", revision=1, step_id="wait", capability_name="local.browser_click.wait_for_click", route_from_step="call", routes={"ok": "collect"}, input_map={"state.session_id": "session_id"}, bind_outputs={"after": "state.after"}, ) compiled = await api.compile_draft_workspace(workspace_id="browser") assert "status" in compiled assert compiled["status"] == "invalid" assert any( item["code"] == "unknown_edge_destination" for item in compiled["diagnostics"] ) saved = await api.create_artifact_from_workspace( workspace_id="browser", artifact_id="browser_workflow", version=1, title="Browser Workflow", outcomes=["ok"], ) assert "status" in saved assert saved["status"] == "invalid" assert saved["saved"] is False assert any( item["code"] == "unknown_edge_destination" for item in saved["diagnostics"] ) @pytest.mark.asyncio async def test_forward_route_becomes_valid_after_target_step_is_added( tmp_path: Path, ) -> None: api, _service = _browser_click_api( FileWorkflowArtifactStore(tmp_path / "drafts_repair_path") ) await api.create_draft_workspace_from_capability( workspace_id="browser", capability_name="local.browser_click.open_click_page", name="browser", ) await api.add_step_from_capability( workspace_id="browser", revision=1, step_id="wait", capability_name="local.browser_click.wait_for_click", route_from_step="call", routes={"ok": "collect"}, input_map={ "state.session_id": "session_id", }, bind_outputs={"after": "state.after"}, ) add_collect = await api.add_step_from_capability( workspace_id="browser", revision=2, step_id="collect", capability_name="local.browser_click.collect_snapshots", route_from_step="wait", input_map={ "state.session_id": "session_id", "state.before": "before", "state.after": "after", }, bind_outputs={"before": "state.final_before", "after": "state.final_after"}, ) assert add_collect["revision"] == 3 validated = await api.validate_draft_workspace(workspace_id="browser") assert validated["status"] == "valid" @pytest.mark.asyncio async def test_bind_draft_local_output_to_workflow_output_lowers_through_state( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / "drafts_bind_output_workflow_output" ) api, service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="report", draft={ "name": "report", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "render", "steps": { "render": { "use": "demo.personal.echo_tool", "input": [], "output": [], } }, "routes": {"render": {"ok": "__end__"}}, }, ) result = await authoring.bind_draft( workspace_id="report", revision=1, step_id="render", source_path="local.echoed", target_path="output.echoed", ) assert result["status"] == "valid" workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True) assert workspace["draft"]["steps"]["render"]["output"] == [ {"source": "echoed", "target": "state.echoed"} ] assert workspace["draft"]["output"] == [ {"path": "state.echoed", "target": "echoed"} ] assert ( workspace["draft"]["state_schema"]["properties"]["echoed"]["type"] == "string" ) assert ( workspace["draft"]["output_schema"]["properties"]["echoed"]["type"] == "string" ) @pytest.mark.asyncio async def test_bind_draft_local_output_to_workflow_output_reuses_compatible_state( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / "drafts_bind_existing_output_state" ) api, _service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="report", draft={ "name": "report", "input_schema": {"type": "object", "properties": {}}, "state_schema": { "type": "object", "properties": { "echoed": { "description": "Echoed text", "title": "Echoed", "type": "string", } }, }, "output_schema": {"type": "object", "properties": {}}, "start": "render", "steps": { "render": { "use": "demo.personal.echo_tool", "input": [], "output": [{"source": "echoed", "target": "state.echoed"}], } }, "routes": {"render": {"ok": "__end__"}}, }, ) result = await authoring.bind_draft( workspace_id="report", revision=1, step_id="render", source_path="local.echoed", target_path="output.echoed", ) assert result["status"] == "valid" workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True) assert workspace["draft"]["steps"]["render"]["output"] == [ {"source": "echoed", "target": "state.echoed"} ] assert workspace["draft"]["output"] == [ {"path": "state.echoed", "target": "echoed"} ] @pytest.mark.asyncio async def test_bind_draft_local_output_to_state_reuses_compatible_state( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_existing_state") api, _service, authoring = _draft_api(artifact_store, register_echo=True) draft = _echo_draft() draft["state_schema"] = { "type": "object", "properties": { "echoed": { "description": "Echoed text", "title": "Echoed", "type": "string", } }, } await api.create_draft_workspace(workspace_id="report", draft=draft) result = await authoring.bind_draft( workspace_id="report", revision=1, step_id="echo", source_path="local.echoed", target_path="state.echoed", ) assert result["status"] == "valid", result["diagnostics"] workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True) assert workspace["draft"]["steps"]["echo"]["output"] == [ {"source": "echoed", "target": "state.echoed"} ] @pytest.mark.asyncio async def test_bind_draft_local_output_to_quoted_workflow_output_field( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bind_quoted_output") api, _service, authoring = _draft_api(artifact_store, register_echo=True) draft = _echo_draft() draft["state_schema"] = {"type": "object", "properties": {}} draft["output_schema"] = {"type": "object", "properties": {}} draft["steps"]["echo"]["output"] = [] await api.create_draft_workspace(workspace_id="quoted", draft=draft) result = await authoring.bind_draft( workspace_id="quoted", revision=1, step_id="echo", source_path="local.echoed", target_path='output."public.echoed"', ) assert result["status"] == "valid" workspace = await api.get_draft_workspace(workspace_id="quoted", include_draft=True) assert workspace["draft"]["steps"]["echo"]["output"] == [ {"source": "echoed", "target": 'state."public.echoed"'} ] assert workspace["draft"]["output"] == [ {"path": 'state."public.echoed"', "target": '"public.echoed"'} ] @pytest.mark.asyncio async def test_bind_draft_replaces_previous_public_output_for_local_field( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_rebind_output") api, _service, authoring = _draft_api(artifact_store, register_echo=True) draft = _echo_draft() draft["state_schema"] = { "type": "object", "properties": { "old": {"type": "string"}, }, } draft["output_schema"] = { "type": "object", "properties": { "old": {"type": "string"}, }, } draft["steps"]["echo"]["output"] = [{"source": "echoed", "target": "state.old"}] draft["output"] = [{"path": "state.old", "target": "old"}] await api.create_draft_workspace(workspace_id="report", draft=draft) result = await authoring.bind_draft( workspace_id="report", revision=1, step_id="echo", source_path="local.echoed", target_path="output.echoed", ) assert result["status"] == "valid" workspace = await api.get_draft_workspace(workspace_id="report", include_draft=True) assert workspace["draft"]["output"] == [ {"path": "state.echoed", "target": "echoed"} ] @pytest.mark.asyncio async def test_bind_draft_rejects_ambiguous_previous_public_output_fan_out( tmp_path: Path, ) -> None: api, _service, authoring = _draft_api( FileWorkflowArtifactStore(tmp_path / "bind_ambiguous_public_output"), register_echo=True, ) draft = _echo_draft() draft["state_schema"] = { "type": "object", "properties": {"old": {"type": "string"}}, } draft["output_schema"] = { "type": "object", "properties": { "first": {"type": "string"}, "second": {"type": "string"}, }, } draft["steps"]["echo"]["output"] = [{"source": "echoed", "target": "state.old"}] draft["output"] = [ {"path": "state.old", "target": "first"}, {"path": "state.old", "target": "second"}, ] await api.create_draft_workspace(workspace_id="report", draft=draft) before = await api.get_draft_workspace(workspace_id="report", include_draft=True) with pytest.raises(ValueError, match="multiple public output bindings"): await authoring.bind_draft( workspace_id="report", revision=1, step_id="echo", source_path="local.echoed", target_path="output.echoed", ) after = await api.get_draft_workspace(workspace_id="report", include_draft=True) assert after == before @pytest.mark.asyncio async def test_remove_draft_binding_rejects_non_object_entries(tmp_path: Path) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_bad_bindings") api, _service, authoring = _draft_api(artifact_store, register_echo=True) draft = _echo_draft() draft["steps"]["echo"]["input"] = ["not-an-object"] await api.create_draft_workspace(workspace_id="bad", draft=draft) with pytest.raises(ValueError, match="input binding entries.*objects"): await authoring.remove_draft_binding( workspace_id="bad", revision=1, step_id="echo", inputs=["text"], ) @pytest.mark.asyncio async def test_validate_draft_workspace_omits_unusable_nested_input_repair_hint( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore( tmp_path / "drafts_nested_input_schema_hint" ) api, _service, _authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="nested_ws", draft={ "name": "nested", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "call", "steps": { "call": { "use": "demo.personal.echo_tool", "input": [{"target": "payload.text", "path": "input.undeclared"}], "output": [], } }, "routes": {"call": {"ok": "__end__"}}, }, ) payload = await api.validate_draft_workspace(workspace_id="nested_ws") diagnostic = next( item for item in payload["diagnostics"] if item["code"] == "invalid_source_path" ) assert "repair_hint" not in diagnostic @pytest.mark.asyncio async def test_validate_draft_workspace_details_invalid_input_source_path( tmp_path: Path, ) -> None: """invalid_source_path on a step input must carry step_id, source_path, target_field.""" artifact_store = FileWorkflowArtifactStore( tmp_path / "drafts_invalid_source_details" ) api, service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="wait_ws", draft={ "name": "wait", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "wait", "steps": { "wait": { "use": "demo.personal.echo_tool", "input": [ { "target": "text", "path": "input.undeclared", } ], "output": [], } }, "routes": {"wait": {"ok": "__end__"}}, }, ) payload = await api.validate_draft_workspace(workspace_id="wait_ws") diagnostic = payload["diagnostics"][0] assert diagnostic["code"] == "invalid_source_path" assert diagnostic.get("step_id") == "wait" details = diagnostic.get("details") assert isinstance(details, dict) assert details["source_path"] == "input.undeclared" assert details["target_field"] == "text" @pytest.mark.asyncio async def test_validate_draft_workspace_hints_input_schema_projection( tmp_path: Path, ) -> None: """invalid_source_path with input.* source must produce a repair_hint.""" artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_input_schema_hint") api, service, authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="wait_ws", draft={ "name": "wait", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "wait", "steps": { "wait": { "use": "demo.personal.echo_tool", "input": [ { "target": "text", "path": "input.undeclared", } ], "output": [], } }, "routes": {"wait": {"ok": "__end__"}}, }, ) payload = await api.validate_draft_workspace(workspace_id="wait_ws") diagnostic = payload["diagnostics"][0] assert diagnostic["code"] == "invalid_source_path" assert diagnostic.get("step_id") == "wait" assert diagnostic.get("repair_hint") == ( "wf draft bind wait_ws --revision 1 " "--step wait --from input.undeclared --to local.text" ) @pytest.mark.asyncio async def test_validate_draft_workspace_hints_state_schema_projection( tmp_path: Path, ) -> None: artifact_store = FileWorkflowArtifactStore(tmp_path / "drafts_state_schema_hint") api, _service, _authoring = _draft_api(artifact_store, register_echo=True) await api.create_draft_workspace( workspace_id="wait_ws", draft={ "name": "wait", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "start": "wait", "steps": { "wait": { "use": "demo.personal.echo_tool", "input": [{"target": "text", "path": "state.undeclared"}], "output": [], } }, "routes": {"wait": {"ok": "__end__"}}, }, ) payload = await api.validate_draft_workspace(workspace_id="wait_ws") diagnostic = next( item for item in payload["diagnostics"] if item["code"] == "invalid_source_path" ) assert diagnostic.get("repair_hint") == ( "wf draft bind wait_ws --revision 1 " "--step wait --from state.undeclared --to local.text" )