fix: address structured context final-review findings
This commit is contained in:
@@ -11,7 +11,12 @@ from wf_core.models.steps import (
|
||||
InterruptNode,
|
||||
NodeUse,
|
||||
)
|
||||
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
||||
from wf_core.paths import (
|
||||
GraphSourcePath,
|
||||
LocalPath,
|
||||
PathResolutionError,
|
||||
StatePath,
|
||||
)
|
||||
|
||||
|
||||
def test_node_use_accepts_canonical_input_and_output_bindings():
|
||||
@@ -465,9 +470,5 @@ def test_foreach_ref_item_index_are_literal_structured_paths() -> None:
|
||||
assert "item" not in node.model_dump(mode="json")
|
||||
assert "index" not in node.model_dump(mode="json")
|
||||
# GraphSourcePath still rejects an output root.
|
||||
try:
|
||||
with pytest.raises(PathResolutionError):
|
||||
GraphSourcePath.parse("output.result")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected output root to be rejected")
|
||||
|
||||
@@ -399,3 +399,137 @@ def test_sibling_foreach_aliases_may_match_when_never_active_together() -> None:
|
||||
for issue in report.errors
|
||||
if issue.code == ValidationIssueCode.FOREACH_CONTEXT_ALIAS_CONFLICT
|
||||
]
|
||||
|
||||
|
||||
def test_child_workflow_cannot_address_caller_foreach_context() -> None:
|
||||
"""A child scope must receive caller values through declared input.
|
||||
|
||||
The child is validated alone, so a path naming the caller's foreach id
|
||||
is a missing id in the child scope and fails closed.
|
||||
"""
|
||||
from wf_core.validation import validate_workflow
|
||||
|
||||
child = Workflow(
|
||||
name="child",
|
||||
input_schema=SchemaRef(type="object", properties={"order": {}}),
|
||||
state_schema=StateSchema.from_field_map({}),
|
||||
output_schema=SchemaRef(type="object", properties={}),
|
||||
node_defs=[_record_def()],
|
||||
start="work",
|
||||
nodes=[_node_use("work", path="context.foreach.orders.item")],
|
||||
edges=[Edge.model_validate({"from": "work", "outcome": "ok", "to": END})],
|
||||
)
|
||||
report = validate_workflow(child)
|
||||
issue = _issue(
|
||||
report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[0].input[0].path"
|
||||
)
|
||||
assert issue is not None
|
||||
assert "context.foreach.orders.item" in issue.message
|
||||
|
||||
|
||||
def test_object_expression_and_nested_conditions_report_exact_paths() -> None:
|
||||
from wf_core.models.steps import ConditionNode, InterruptNode
|
||||
from wf_core.validation import validate_workflow
|
||||
|
||||
bad = "context.foreach.missing.item"
|
||||
workflow = _base_workflow()
|
||||
workflow.nodes[2] = _node_use(
|
||||
"work",
|
||||
expression={
|
||||
"kind": "object",
|
||||
"fields": {"order": {"kind": "path", "path": bad}},
|
||||
},
|
||||
)
|
||||
report = validate_workflow(workflow)
|
||||
assert (
|
||||
_issue(
|
||||
report,
|
||||
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
||||
"nodes[2].input[0].expression.fields.order.path",
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
workflow = _base_workflow()
|
||||
workflow.nodes[2] = ConditionNode.model_validate(
|
||||
{
|
||||
"id": "work",
|
||||
"type": "condition",
|
||||
"check": {
|
||||
"op": "not",
|
||||
"arg": {
|
||||
"op": "and",
|
||||
"args": [
|
||||
{"op": "exists", "path": bad},
|
||||
{
|
||||
"op": "eq",
|
||||
"left": {"path": bad},
|
||||
"right": {"value": 1},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
workflow.edges = [
|
||||
Edge.model_validate({"from": "customers", "outcome": "loop", "to": "orders"}),
|
||||
Edge.model_validate({"from": "orders", "outcome": "loop", "to": "work"}),
|
||||
Edge.model_validate({"from": "work", "outcome": "true", "to": "orders"}),
|
||||
Edge.model_validate({"from": "work", "outcome": "false", "to": "orders"}),
|
||||
Edge.model_validate({"from": "orders", "outcome": "done", "to": "after_inner"}),
|
||||
Edge.model_validate(
|
||||
{"from": "after_inner", "outcome": "ok", "to": "customers"}
|
||||
),
|
||||
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
||||
]
|
||||
report = validate_workflow(workflow)
|
||||
assert (
|
||||
_issue(
|
||||
report,
|
||||
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
||||
"nodes[2].check.arg.args[0].path",
|
||||
)
|
||||
is not None
|
||||
)
|
||||
assert (
|
||||
_issue(
|
||||
report,
|
||||
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
||||
"nodes[2].check.arg.args[1].left.path",
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
workflow = _base_workflow()
|
||||
workflow.nodes[2] = InterruptNode.model_validate(
|
||||
{
|
||||
"id": "work",
|
||||
"type": "interrupt",
|
||||
"kind": "approval",
|
||||
"request": [
|
||||
{
|
||||
"target": "order",
|
||||
"expression": {"kind": "path", "path": bad},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
workflow.edges = [
|
||||
Edge.model_validate({"from": "customers", "outcome": "loop", "to": "orders"}),
|
||||
Edge.model_validate({"from": "orders", "outcome": "loop", "to": "work"}),
|
||||
Edge.model_validate({"from": "work", "outcome": "submitted", "to": "orders"}),
|
||||
Edge.model_validate({"from": "orders", "outcome": "done", "to": "after_inner"}),
|
||||
Edge.model_validate(
|
||||
{"from": "after_inner", "outcome": "ok", "to": "customers"}
|
||||
),
|
||||
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
||||
]
|
||||
report = validate_workflow(workflow)
|
||||
assert (
|
||||
_issue(
|
||||
report,
|
||||
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
||||
"nodes[2].request[0].expression.path",
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
@@ -683,6 +683,8 @@ def test_concurrent_items_receive_distinct_frame_and_lineage_context() -> None:
|
||||
run = execute_workflow(workflow, {"items": ["a", "b"]}, {"record": record})
|
||||
assert run.status == RunStatus.COMPLETED
|
||||
assert len(contexts) == 2
|
||||
# Items admitted in one foreach visit share that visit's activation but
|
||||
# own distinct item frames and lineages.
|
||||
assert contexts[0].activation_id == contexts[1].activation_id
|
||||
assert contexts[0].frame_id != contexts[1].frame_id
|
||||
assert contexts[0].lineage_id != contexts[1].lineage_id
|
||||
@@ -832,3 +834,92 @@ def test_interrupt_resume_recreates_structured_context_identities() -> None:
|
||||
assert after_inner.frame_id == before_inner.frame_id
|
||||
assert after_inner.lineage_id == before_inner.lineage_id
|
||||
assert after_inner.item == before_inner.item
|
||||
|
||||
|
||||
def test_single_foreach_exposes_one_structured_entry() -> None:
|
||||
from wf_core import (
|
||||
END,
|
||||
Edge,
|
||||
ForeachNode,
|
||||
NodeDef,
|
||||
NodeUse,
|
||||
SchemaRef,
|
||||
Workflow,
|
||||
execute_workflow,
|
||||
)
|
||||
from wf_core.models.schemas import StateField, StateSchema
|
||||
|
||||
workflow = Workflow(
|
||||
name="single_structured",
|
||||
input_schema=SchemaRef(type="object", properties={}),
|
||||
state_schema=StateSchema.from_field_map({"items": StateField(type="array")}),
|
||||
output_schema=SchemaRef(type="object", properties={}),
|
||||
node_defs=[
|
||||
NodeDef(
|
||||
name="record",
|
||||
input_schema=SchemaRef(type="object", properties={"value": {}}),
|
||||
output_schema=SchemaRef(type="object", properties={}),
|
||||
outcomes=["ok"],
|
||||
)
|
||||
],
|
||||
start="each",
|
||||
nodes=[
|
||||
ForeachNode.model_validate(
|
||||
{
|
||||
"id": "each",
|
||||
"type": "foreach",
|
||||
"over": "state.items",
|
||||
"as": "item",
|
||||
"mode": "serial",
|
||||
}
|
||||
),
|
||||
NodeUse.model_validate(
|
||||
{
|
||||
"id": "work",
|
||||
"type": "node",
|
||||
"node": "record",
|
||||
"input": [{"target": "value", "path": "context.item"}],
|
||||
"output": [],
|
||||
}
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
Edge.model_validate({"from": "each", "outcome": "loop", "to": "work"}),
|
||||
Edge.model_validate({"from": "work", "outcome": "ok", "to": "each"}),
|
||||
Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
|
||||
],
|
||||
)
|
||||
seen: list[RuntimeContext] = []
|
||||
|
||||
def record(_payload: dict[str, object], ctx: RuntimeContext) -> dict[str, object]:
|
||||
seen.append(ctx)
|
||||
return {"outcome": "ok", "output": {}}
|
||||
|
||||
run = execute_workflow(workflow, {"items": ["a"]}, {"record": record})
|
||||
assert run.status == RunStatus.COMPLETED
|
||||
assert len(seen) == 1
|
||||
assert tuple(seen[0].foreach) == ("each",)
|
||||
assert seen[0].foreach["each"].item == "a"
|
||||
assert seen[0].foreach["each"].index == 0
|
||||
|
||||
|
||||
def test_bool_loop_index_metadata_fails_closed() -> None:
|
||||
run = _run_with_frames(
|
||||
[
|
||||
ExecutionFrame(
|
||||
id="bad",
|
||||
kind="foreach_iteration",
|
||||
node_id="body",
|
||||
scope_id="root",
|
||||
metadata={
|
||||
"foreach_node_id": "each",
|
||||
"activation_id": "act-1",
|
||||
"loop_index": True,
|
||||
"loop_item": "a",
|
||||
"loop_alias": "item",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
with pytest.raises(WorkflowExecutionError, match="malformed foreach loop index"):
|
||||
frame_context_view(run, run.frames["bad"])
|
||||
|
||||
@@ -563,6 +563,7 @@ def test_subgraph_does_not_inherit_caller_foreach_context() -> None:
|
||||
assert isinstance(ctx, RuntimeContext)
|
||||
assert tuple(ctx.foreach) == ("orders",)
|
||||
assert ctx.foreach["orders"].item == "child-item"
|
||||
assert ctx.foreach["orders"].scope_id != "root"
|
||||
parent_scope_id = run.scopes["root"].id
|
||||
assert ctx.foreach["orders"].scope_id != parent_scope_id
|
||||
assert pre_seen["foreach"] == {}
|
||||
assert pre_seen["input_order"] == {"sku": "A-17"}
|
||||
|
||||
Reference in New Issue
Block a user