Files
lda-wf/tests/artifacts/test_draft_adapter.py
T

675 lines
22 KiB
Python

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",
},
}
}
},
"routes": {
"each_item": {
"loop": "__end__",
"done": "__end__",
"completed_with_errors": "__end__",
}
},
}
)
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