drafts system to get ts too

This commit is contained in:
lda
2026-05-23 22:25:47 +07:00 Verified
parent 10350e87f8
commit 998b1943ba
5 changed files with 175 additions and 10 deletions
+27 -4
View File
@@ -286,7 +286,8 @@ the generated `error` outcome dangling.
### `foreach` ### `foreach`
Runs a child body over items. Runs a child body over items. Draft foreach mirrors the core foreach policy
model: use `item_error` and `concurrent`, not draft-only field names.
```json ```json
{ {
@@ -294,13 +295,35 @@ Runs a child body over items.
"over": {"root": "state", "parts": ["items"]}, "over": {"root": "state", "parts": ["items"]},
"as": "item", "as": "item",
"mode": "serial", "mode": "serial",
"on_item_error": "fail" "item_error": "fail"
} }
} }
``` ```
Use `serial` unless the runtime explicitly supports a parallel async path for Concurrent foreach uses the same canonical policy shape as core:
the target workflow.
```json
{
"foreach": {
"over": {"root": "state", "parts": ["items"]},
"as": "item",
"mode": "concurrent",
"concurrent": {
"max_active": 2,
"max_outstanding": 4
},
"item_error": {
"action": "collect",
"collect_to": {"root": "state", "parts": ["item_errors"]}
}
}
}
```
`item_error` accepts `"fail"` and `"skip"` as shorthand. `collect` needs an
explicit destination, so `item_error: "collect"` is invalid; use the object
shape and provide `collect_to`. Deprecated `on_item_error` and `parallel` are
accepted only as parse-only compatibility and dump back to canonical fields.
### `interrupt` ### `interrupt`
+1 -1
View File
@@ -52,7 +52,7 @@ def _add_step(builder: WorkflowBuilder, step_id: str, step: DraftStep):
over=step.foreach.over, over=step.foreach.over,
as_=step.foreach.as_, as_=step.foreach.as_,
mode=step.foreach.mode, mode=step.foreach.mode,
on_item_error=step.foreach.on_item_error, item_error=step.foreach.item_error,
concurrent=step.foreach.concurrent, concurrent=step.foreach.concurrent,
) )
if isinstance(step, DraftInterruptStep): if isinstance(step, DraftInterruptStep):
+19 -4
View File
@@ -5,7 +5,12 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
from wf_core.models.conditions import Condition from wf_core.models.conditions import Condition
from wf_core.models.steps import InputBinding, OutputBinding from wf_core.models.steps import (
ForeachConcurrentPolicy,
ForeachItemErrorPolicy,
InputBinding,
OutputBinding,
)
from wf_core.paths import GraphSourcePath from wf_core.paths import GraphSourcePath
JsonObject = dict[str, Any] JsonObject = dict[str, Any]
@@ -98,13 +103,18 @@ class DraftForeachPayload(BaseModel):
over: GraphSourcePath over: GraphSourcePath
as_: str = Field(alias="as") as_: str = Field(alias="as")
mode: Literal["serial", "concurrent"] = "serial" mode: Literal["serial", "concurrent"] = "serial"
on_item_error: Literal["fail", "collect", "skip"] = "fail" item_error: ForeachItemErrorPolicy = Field(default_factory=ForeachItemErrorPolicy)
concurrent: JsonObject | None = None concurrent: ForeachConcurrentPolicy | None = None
on_item_error: Literal["fail", "collect", "skip"] | None = Field(
default=None,
exclude=True,
description="Deprecated parse-only shorthand; use item_error.",
)
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
def _coerce_legacy_parallel_policy(cls, data: object) -> object: def _coerce_legacy_parallel_policy(cls, data: object) -> object:
"""Accept old draft foreach parallel names as parse-only compatibility.""" """Accept old foreach names while saving canonical policy fields."""
if not isinstance(data, dict): if not isinstance(data, dict):
return data return data
normalized = dict(data) normalized = dict(data)
@@ -114,6 +124,11 @@ class DraftForeachPayload(BaseModel):
if "concurrent" in normalized: if "concurrent" in normalized:
raise ValueError("cannot mix deprecated parallel with concurrent") raise ValueError("cannot mix deprecated parallel with concurrent")
normalized["concurrent"] = normalized.pop("parallel") normalized["concurrent"] = normalized.pop("parallel")
old_item_error = normalized.pop("on_item_error", None)
if old_item_error is not None:
if "item_error" in normalized:
raise ValueError("cannot mix deprecated on_item_error with item_error")
normalized["item_error"] = old_item_error
return normalized return normalized
+51 -1
View File
@@ -5,7 +5,7 @@ from pydantic import ValidationError
from wf_artifacts.drafts import WorkflowDraft from wf_artifacts.drafts import WorkflowDraft
from wf_artifacts.drafts.api import compile_workflow_draft, validate_workflow_draft from wf_artifacts.drafts.api import compile_workflow_draft, validate_workflow_draft
from wf_artifacts.drafts.adapter import build_workflow_from_draft from wf_artifacts.drafts.adapter import build_workflow_from_draft
from wf_core import ConditionNode, NodeUse from wf_core import ConditionNode, ForeachNode, NodeUse
from wf_core.models.steps import InputValueBinding from wf_core.models.steps import InputValueBinding
@@ -279,3 +279,53 @@ def test_adapter_lowers_match_step_through_builder() -> None:
("match_status_2", "true", "waiting"), ("match_status_2", "true", "waiting"),
("match_status_2", "false", "__end__"), ("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"
+77
View File
@@ -7,6 +7,7 @@ from pydantic import ValidationError
from wf_artifacts.drafts import ( from wf_artifacts.drafts import (
DraftChooseStep, DraftChooseStep,
DraftForeachStep,
DraftMatchStep, DraftMatchStep,
DraftUseStep, DraftUseStep,
DraftWhenStep, DraftWhenStep,
@@ -191,6 +192,82 @@ def test_workflow_draft_foreach_over_dumps_structural_path() -> None:
} }
def test_workflow_draft_foreach_accepts_canonical_item_error_policy() -> None:
draft = WorkflowDraft.model_validate(
{
**_keyed_echo_draft(),
"start": "each_item",
"steps": {
"each_item": {
"foreach": {
"over": "state.items",
"as": "item",
"mode": "concurrent",
"concurrent": {"max_active": 2, "max_outstanding": 3},
"item_error": {
"action": "collect",
"collect_to": "state.item_errors",
},
}
}
},
"routes": {"each_item": {"loop": "__end__", "done": "__end__"}},
}
)
step = draft.steps["each_item"]
dumped = draft.model_dump(mode="json")
assert isinstance(step, DraftForeachStep)
assert dumped["steps"]["each_item"]["foreach"]["item_error"] == {
"action": "collect",
"collect_to": {"root": "state", "parts": ["item_errors"]},
}
assert "on_item_error" not in dumped["steps"]["each_item"]["foreach"]
def test_workflow_draft_foreach_accepts_item_error_action_string() -> None:
draft = WorkflowDraft.model_validate(
{
**_keyed_echo_draft(),
"start": "each_item",
"steps": {
"each_item": {
"foreach": {
"over": "state.items",
"as": "item",
"item_error": "skip",
}
}
},
"routes": {"each_item": {"loop": "__end__", "done": "__end__"}},
}
)
dumped = draft.model_dump(mode="json")
assert dumped["steps"]["each_item"]["foreach"]["item_error"]["action"] == "skip"
def test_workflow_draft_foreach_collect_string_requires_destination() -> None:
with pytest.raises(ValidationError, match="collect_to"):
WorkflowDraft.model_validate(
{
**_keyed_echo_draft(),
"start": "each_item",
"steps": {
"each_item": {
"foreach": {
"over": "state.items",
"as": "item",
"item_error": "collect",
}
}
},
}
)
def test_workflow_draft_accepts_match_step() -> None: def test_workflow_draft_accepts_match_step() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate(
{ {