from __future__ import annotations from pydantic import ValidationError from wf_artifacts.drafts import WorkflowDraft from wf_artifacts.drafts.adapter import build_workflow_from_draft from wf_artifacts.drafts.api import compile_workflow_draft, validate_workflow_draft from wf_core import ( ConditionNode, EndNode, ForeachNode, InterruptNode, NodeDef, NodeUse, SchemaRef, SubgraphNode, execute_workflow, ) from wf_core.models.steps import InputValueBinding def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None: draft = WorkflowDraft.model_validate( { "name": "echo", "input_schema": {}, "state_schema": {"fields": {}}, "output_schema": {}, "start": "echo", "steps": {"echo": {"use": "demo.echo"}}, "routes": {"echo": {"ok": "__end__"}}, } ) workflow = build_workflow_from_draft(draft) node = workflow.nodes[0] assert isinstance(node, NodeUse) assert node.id == "echo" assert node.node == "demo.echo" assert workflow.edges[0].from_ == "echo" assert workflow.edges[0].outcome == "ok" assert workflow.edges[0].to == "__end__" def test_adapter_lowers_use_steps_to_canonical_bindings() -> None: draft = WorkflowDraft.model_validate( { "name": "echo", "input_schema": {}, "state_schema": {"fields": {"echoed": {"type": "string"}}}, "output_schema": {}, "start": "echo", "steps": { "echo": { "use": "demo.echo", "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__"}}, } ) workflow = build_workflow_from_draft(draft) node = workflow.nodes[0] assert isinstance(node, NodeUse) dumped = node.model_dump(mode="json") assert "in_map" not in dumped assert "out_map" not in dumped assert dumped["input"][0]["target"] == "text" assert dumped["input"][0]["path"] == "input.text" assert dumped["output"][0]["source"] == "echoed" assert dumped["output"][0]["target"] == "state.echoed" def test_adapter_preserves_composite_node_input_bindings() -> None: draft = WorkflowDraft.model_validate( { "name": "composite", "input_schema": {}, "state_schema": {"type": "object"}, "output_schema": {}, "start": "echo", "steps": { "echo": { "use": "demo.echo", "input": [ { "target": "items", "expression": { "kind": "array", "items": [ {"kind": "path", "path": "state.value"}, {"kind": "literal", "value": "!"}, ], }, } ], } }, "routes": {"echo": {"ok": "__end__"}}, } ) workflow = build_workflow_from_draft(draft) node = workflow.nodes[0] assert isinstance(node, NodeUse) assert node.model_dump(mode="json")["input"] == [ { "target": "items", "expression": { "kind": "array", "items": [ {"kind": "path", "path": "state.value"}, {"kind": "literal", "value": "!"}, ], }, } ] def test_adapter_lowers_root_workflow_output_bindings() -> None: draft = WorkflowDraft.model_validate( { "name": "echo", "input_schema": {}, "state_schema": { "type": "object", "properties": { "raw": { "type": "object", "properties": {"echoed": {"type": "string"}}, } }, }, "output_schema": { "type": "object", "properties": {"message": {"type": "string"}}, }, "output": [{"target": "message", "path": "state.raw.echoed"}], "start": "echo", "steps": {"echo": {"use": "demo.echo"}}, "routes": {"echo": {"ok": "__end__"}}, } ) workflow = build_workflow_from_draft(draft) dumped = workflow.model_dump(mode="json") assert dumped["output"][0]["target"] == "message" assert dumped["output"][0]["path"] == "state.raw.echoed" def test_adapter_golden_draft_executes_ok_and_error_outcomes() -> None: draft = WorkflowDraft.model_validate( { "name": "golden_echo", "input_schema": { "type": "object", "properties": { "text": {"type": "string"}, "fail": {"type": "boolean"}, }, "required": ["text"], }, "state_schema": { "type": "object", "properties": {"raw": {"type": "object"}}, }, "output_schema": { "type": "object", "properties": {"message": {"type": "string"}}, }, "outcomes": ["ok", "error"], "output": [{"target": "message", "path": "state.raw.echoed"}], "start": "call", "steps": { "call": { "use": "demo.echo", "input": [ {"target": "text", "path": "input.text"}, {"target": "fail", "path": "input.fail"}, ], "output": [{"source": "echoed", "target": "state.raw.echoed"}], }, "end_error": {"end": {"outcome": "error"}}, }, "routes": { "call": {"ok": "__end__", "error": "end_error"}, }, } ) workflow = build_workflow_from_draft(draft) workflow = workflow.model_copy( update={ "node_defs": [ NodeDef( name="demo.echo", input_schema=SchemaRef.model_validate(draft.input_schema), output_schema=SchemaRef.model_validate( { "type": "object", "properties": {"echoed": {"type": "string"}}, } ), outcomes=["ok", "error"], ) ] } ) def echo(payload: dict[str, object], _ctx: object) -> dict[str, object]: if payload.get("fail") is True: return {"outcome": "error", "output": {"echoed": "failed"}} return {"outcome": "ok", "output": {"echoed": str(payload["text"])}} ok = execute_workflow( workflow, {"text": "hello", "fail": False}, {"demo.echo": echo}, ) error = execute_workflow( workflow, {"text": "hello", "fail": True}, {"demo.echo": echo}, ) assert ok.status == "completed" assert ok.outcome == "ok" assert ok.output["message"] == "hello" assert error.status == "completed" assert error.outcome == "error" assert error.output["message"] == "failed" def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None: draft = WorkflowDraft.model_validate( { "name": "constant", "input_schema": {}, "state_schema": {"fields": {"message": {"type": "string"}}}, "output_schema": {}, "start": "constant", "steps": { "constant": { "use": "wf.std.constant", "input": [ { "target": {"root": "local", "parts": ["value"]}, "value": "CLICKED", } ], "output": [ { "source": {"root": "local", "parts": ["value"]}, "target": {"root": "state", "parts": ["message"]}, } ], } }, "routes": {"constant": {"ok": "__end__"}}, } ) workflow = build_workflow_from_draft(draft) node = workflow.nodes[0] assert isinstance(node, NodeUse) assert node.node == "wf.std.constant" assert len(node.input) == 1 assert isinstance(node.input[0], InputValueBinding) assert str(node.input[0].target) == "value" assert node.input[0].value == "CLICKED" def test_invalid_literal_input_map_does_not_fall_through_to_join() -> None: draft = { "name": "bad_constant", "input_schema": {}, "state_schema": {"fields": {}}, "output_schema": {}, "start": "constant", "steps": { "constant": { "use": "wf.std.constant", "in": {"value": {"value": "CLICKED"}}, } }, "routes": {"constant": {"ok": "__end__"}}, } result = validate_workflow_draft(draft) assert result["status"] == "invalid" assert "steps.constant" in result["diagnostics"][0]["path"] try: compile_workflow_draft(draft) except ValidationError: pass else: # pragma: no cover - kept explicit because silent join fallback was the bug. raise AssertionError("invalid use step compiled instead of failing") def test_adapter_lowers_when_step_through_builder() -> None: draft = WorkflowDraft.model_validate( { "name": "when_example", "input_schema": {}, "state_schema": {"fields": {}}, "output_schema": {}, "start": "decide", "steps": { "decide": { "when": { "if": { "op": "ge", "left": {"path": "state.count"}, "right": {"value": 1}, }, "then": "echo", "otherwise": "__end__", } }, "echo": {"use": "demo.echo"}, }, "routes": {"echo": {"ok": "__end__"}}, } ) workflow = build_workflow_from_draft(draft) condition = workflow.nodes[0] assert isinstance(condition, ConditionNode) assert condition.id == "decide" assert workflow.start == "decide" assert [(edge.from_, edge.outcome, edge.to) for edge in workflow.edges[:2]] == [ ("decide", "true", "echo"), ("decide", "false", "__end__"), ] def test_adapter_lowers_explicit_end_step() -> None: draft = WorkflowDraft.model_validate( { "name": "end_example", "input_schema": {}, "state_schema": {"fields": {}}, "output_schema": {}, "outcomes": ["ok", "error"], "start": "echo", "steps": { "echo": {"use": "demo.echo"}, "end_error": {"end": {"outcome": "error"}}, }, "routes": {"echo": {"error": "end_error"}}, } ) workflow = build_workflow_from_draft(draft) terminal = workflow.nodes[1] assert isinstance(terminal, EndNode) assert terminal.id == "end_error" assert terminal.outcome == "error" assert workflow.outcomes == ["ok", "error"] assert workflow.edges[0].to == "end_error" def test_adapter_lowers_choose_step_through_builder() -> None: draft = WorkflowDraft.model_validate( { "name": "choose_example", "input_schema": {}, "state_schema": {"fields": {}}, "output_schema": {}, "start": "pick", "steps": { "pick": { "choose": { "clauses": [ { "if": { "op": "gt", "left": {"path": "state.score"}, "right": {"value": 80}, }, "then": "high", }, { "if": { "op": "exists", "path": "state.fallback", }, "then": "fallback", }, ], "default": "__end__", } }, "high": {"use": "demo.high"}, "fallback": {"use": "demo.fallback"}, }, "routes": { "high": {"ok": "__end__"}, "fallback": {"ok": "__end__"}, }, } ) workflow = build_workflow_from_draft(draft) condition_ids = [ node.id for node in workflow.nodes if isinstance(node, ConditionNode) ] assert condition_ids == ["pick", "pick_2"] assert workflow.start == "pick" assert [(edge.from_, edge.outcome, edge.to) for edge in workflow.edges[:4]] == [ ("pick", "true", "high"), ("pick", "false", "pick_2"), ("pick_2", "true", "fallback"), ("pick_2", "false", "__end__"), ] def test_adapter_lowers_match_step_through_builder() -> None: draft = WorkflowDraft.model_validate( { "name": "match_example", "input_schema": {}, "state_schema": {"fields": {}}, "output_schema": {}, "start": "match_status", "steps": { "match_status": { "match": { "value": "state.status", "cases": [ {"equals": "ready", "then": "ready"}, {"equals": "waiting", "then": "waiting"}, ], "default": "__end__", } }, "ready": {"use": "demo.ready"}, "waiting": {"use": "demo.waiting"}, }, "routes": { "ready": {"ok": "__end__"}, "waiting": {"ok": "__end__"}, }, } ) workflow = build_workflow_from_draft(draft) condition_ids = [ node.id for node in workflow.nodes if isinstance(node, ConditionNode) ] assert condition_ids == ["match_status", "match_status_2"] assert workflow.start == "match_status" assert [(edge.from_, edge.outcome, edge.to) for edge in workflow.edges[:4]] == [ ("match_status", "true", "ready"), ("match_status", "false", "match_status_2"), ("match_status_2", "true", "waiting"), ("match_status_2", "false", "__end__"), ] def test_adapter_lowers_foreach_policy_through_builder() -> None: draft = WorkflowDraft.model_validate( { "name": "foreach_policy", "input_schema": {}, "state_schema": { "type": "object", "properties": { "items": {"type": "array"}, "item_errors": {"type": "array"}, }, }, "output_schema": {}, "start": "each_item", "steps": { "each_item": { "foreach": { "over": "state.items", "as": "item", "mode": "concurrent", "concurrent": {"max_active": 2, "max_outstanding": 4}, "item_error": { "action": "collect", "collect_to": "state.item_errors", }, } }, "echo": {"use": "demo.echo"}, }, "routes": { "each_item": { "loop": "echo", "done": "__end__", "completed_with_errors": "__end__", }, "echo": {"ok": "each_item"}, }, } ) workflow = build_workflow_from_draft(draft) foreach = workflow.nodes[0] assert isinstance(foreach, ForeachNode) assert foreach.mode == "concurrent" assert foreach.concurrent is not None assert foreach.concurrent.max_active == 2 assert foreach.concurrent.max_outstanding == 4 assert foreach.item_error.action == "collect" assert str(foreach.item_error.collect_to) == "state.item_errors" def test_adapter_lowers_typed_and_untyped_interrupt_steps() -> None: request_schema = { "type": "object", "properties": {"issues": {"type": "array"}}, "required": ["issues"], } resume_schema = { "type": "object", "properties": {"selected": {"type": "array"}}, "required": ["selected"], } draft = WorkflowDraft.model_validate( { "name": "review", "input_schema": {}, "state_schema": {"type": "object"}, "output_schema": {}, "start": "review", "steps": { "review": { "interrupt": { "kind": "issue_review", "request_schema": request_schema, "resume_schema": resume_schema, "outcomes": ["submitted", "cancelled"], } }, "legacy": {"interrupt": {"kind": "legacy"}}, }, "routes": { "review": {"submitted": "__end__", "cancelled": "__end__"}, "legacy": {"submitted": "__end__"}, }, } ) workflow = build_workflow_from_draft(draft) review = next(node for node in workflow.nodes if node.id == "review") legacy = next(node for node in workflow.nodes if node.id == "legacy") assert isinstance(review, InterruptNode) assert review.request_schema == request_schema assert review.resume_schema == resume_schema assert review.has_explicit_contract is True assert isinstance(legacy, InterruptNode) assert legacy.has_explicit_contract is False def test_adapter_lowers_subgraph_step_without_resolving_artifact() -> None: input_schema = { "type": "object", "properties": {"topic": {"type": "string"}}, } output_schema = { "type": "object", "properties": {"report": {"type": "string"}}, } draft = WorkflowDraft.model_validate( { "name": "parent", "input_schema": {}, "state_schema": {"type": "object"}, "output_schema": {}, "start": "child", "steps": { "child": { "subgraph": { "workflow": {"artifact_id": "child_report", "version": 2}, "input_schema": input_schema, "output_schema": output_schema, "input": [{"target": "topic", "path": "state.topic"}], "output": [{"source": "report", "target": "state.report"}], "outcomes": ["ok", "error"], } } }, "routes": {"child": {"ok": "__end__", "error": "__end__"}}, } ) workflow = build_workflow_from_draft(draft) child = workflow.nodes[0] assert isinstance(child, SubgraphNode) assert child.workflow.artifact_id == "child_report" assert child.workflow.version == 2 assert child.input_schema == SchemaRef.model_validate(input_schema) assert child.output_schema == SchemaRef.model_validate(output_schema) assert child.model_dump(mode="json", by_alias=True)["input"] == [ {"path": "state.topic", "target": "topic"} ] assert child.model_dump(mode="json", by_alias=True)["output"] == [ {"source": "report", "target": "state.report"} ] assert child.outcomes == ["ok", "error"] def test_validate_workflow_draft_reports_structured_output_destination_issue() -> None: draft = { "name": "missing_state_field", "input_schema": {}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {}, "start": "snap", "steps": { "snap": { "use": "demo.snapshot", "output": [ { "source": {"root": "local", "parts": ["after"]}, "target": {"root": "state", "parts": ["after"]}, } ], } }, "routes": {"snap": {"ok": "__end__"}}, } node_defs = [ NodeDef( name="demo.snapshot", input_schema=SchemaRef.model_validate({"type": "object", "properties": {}}), output_schema=SchemaRef.model_validate( {"type": "object", "properties": {"after": {"type": "string"}}} ), outcomes=["ok"], ) ] result = validate_workflow_draft(draft, node_defs=node_defs) assert result["status"] == "invalid" destination_diagnostics = [ d for d in result["diagnostics"] if d["code"] == "invalid_destination_path" ] assert len(destination_diagnostics) == 1 diagnostic = destination_diagnostics[0] assert diagnostic["path"] == "nodes[0].output[0].target" assert diagnostic["step_id"] == "snap" assert diagnostic["details"] == { "output_field": "after", "state_path": "state.after", } assert "repair_hint" not in diagnostic