docs: publish structured runtime context
This commit is contained in:
+2
-2
@@ -117,8 +117,8 @@ docs as the active references:
|
|||||||
- [`superpowers/specs/2026-09-04-foreach-back-edge-design.md`](superpowers/specs/2026-09-04-foreach-back-edge-design.md):
|
- [`superpowers/specs/2026-09-04-foreach-back-edge-design.md`](superpowers/specs/2026-09-04-foreach-back-edge-design.md):
|
||||||
approved canonical foreach body-return and validation semantics.
|
approved canonical foreach body-return and validation semantics.
|
||||||
- [`superpowers/specs/2026-09-04-structured-runtime-context-design.md`](superpowers/specs/2026-09-04-structured-runtime-context-design.md):
|
- [`superpowers/specs/2026-09-04-structured-runtime-context-design.md`](superpowers/specs/2026-09-04-structured-runtime-context-design.md):
|
||||||
proposed same-scope nested foreach context, path, schema, and authoring-ref
|
implemented same-scope nested foreach context, path, schema, and
|
||||||
semantics.
|
authoring-ref semantics.
|
||||||
- [`superpowers/specs/2026-09-04-run-step-budget-design.md`](superpowers/specs/2026-09-04-run-step-budget-design.md):
|
- [`superpowers/specs/2026-09-04-run-step-budget-design.md`](superpowers/specs/2026-09-04-run-step-budget-design.md):
|
||||||
proposed persisted run-wide protection against unbounded graph execution.
|
proposed persisted run-wide protection against unbounded graph execution.
|
||||||
- [`superpowers/specs/2026-05-24-native-subgraphs-design.md`](superpowers/specs/2026-05-24-native-subgraphs-design.md):
|
- [`superpowers/specs/2026-05-24-native-subgraphs-design.md`](superpowers/specs/2026-05-24-native-subgraphs-design.md):
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ and barrier foundations in this order.
|
|||||||
The implementation plan is ready and its feature branch is under review:
|
The implementation plan is ready and its feature branch is under review:
|
||||||
|
|
||||||
- [`structured runtime context design`](superpowers/specs/2026-09-04-structured-runtime-context-design.md)
|
- [`structured runtime context design`](superpowers/specs/2026-09-04-structured-runtime-context-design.md)
|
||||||
- [`structured runtime context implementation plan`](superpowers/plans/2026-09-04-structured-runtime-context.md)
|
- [`structured runtime context implementation plan`](historical/superpowers/plans/2026-09-04-structured-runtime-context.md)
|
||||||
|
|
||||||
This slice gives runtime code, expressions, validation, and authoring references
|
This slice gives runtime code, expressions, validation, and authoring references
|
||||||
one model for run data and same-scope foreach activations. Subgraphs continue to
|
one model for run data and same-scope foreach activations. Subgraphs continue to
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Approved for implementation on 2026-09-04. This document specifies structured
|
Implemented on 2026-09-04. This document specifies structured
|
||||||
foreach context inside one runtime scope. It complements the foreach back-edge
|
foreach context inside one runtime scope. It complements the foreach back-edge
|
||||||
design without expanding that implementation slice.
|
design without expanding that implementation slice.
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,32 @@ See `examples/authoring_concurrent_foreach.py` for a runnable example covering:
|
|||||||
- the replace-conflict case when sibling item writes target a non-mergeable
|
- the replace-conflict case when sibling item writes target a non-mergeable
|
||||||
state path.
|
state path.
|
||||||
|
|
||||||
|
## Structured `foreach` context
|
||||||
|
|
||||||
|
Nested bodies read every active same-scope iteration through structured paths.
|
||||||
|
Prefer declared input bindings via the foreach reference:
|
||||||
|
|
||||||
|
```python
|
||||||
|
orders = graph.foreach(
|
||||||
|
id="orders",
|
||||||
|
over=state_path("orders"),
|
||||||
|
as_="order",
|
||||||
|
)
|
||||||
|
charge = graph.use(
|
||||||
|
charge_order,
|
||||||
|
input=[input_from(orders.item, "order")],
|
||||||
|
)
|
||||||
|
graph.set_route(orders, "loop", charge)
|
||||||
|
graph.set_route(charge, "ok", orders)
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiled binding is ordinary protocol data (`context.foreach.orders.item`).
|
||||||
|
Normal capabilities receive foreach values through declared inputs. Advanced
|
||||||
|
handlers may inspect `ctx.foreach["orders"].index` and stable runtime
|
||||||
|
identities (`activation_id`, `frame_id`, `scope_id`, `lineage_id`). Child
|
||||||
|
workflows do not inherit caller context and must receive input;
|
||||||
|
`loop_item`, `loop_index`, and aliases are migration conveniences.
|
||||||
|
|
||||||
## Deprecated `route`
|
## Deprecated `route`
|
||||||
|
|
||||||
`route()` is a compatibility shim:
|
`route()` is a compatibility shim:
|
||||||
|
|||||||
@@ -226,5 +226,28 @@ except WorkflowClientError as error:
|
|||||||
- Use the public objects before inspecting `wf_api`, RPC clients, codecs, or
|
- Use the public objects before inspecting `wf_api`, RPC clients, codecs, or
|
||||||
stores. Drop below the client boundary only when implementing the client.
|
stores. Drop below the client boundary only when implementing the client.
|
||||||
|
|
||||||
|
## Structured foreach context
|
||||||
|
|
||||||
|
Prefer declared input bindings via the foreach reference:
|
||||||
|
|
||||||
|
```python
|
||||||
|
orders = graph.foreach(
|
||||||
|
id="orders",
|
||||||
|
over=state_path("orders"),
|
||||||
|
as_="order",
|
||||||
|
)
|
||||||
|
charge = graph.use(
|
||||||
|
charge_order,
|
||||||
|
input=[input_from(orders.item, "order")],
|
||||||
|
)
|
||||||
|
graph.set_route(orders, "loop", charge)
|
||||||
|
graph.set_route(charge, "ok", orders)
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal capabilities receive foreach values through declared inputs. Advanced
|
||||||
|
handlers may inspect `ctx.foreach["orders"].index` and stable runtime
|
||||||
|
identities. Child workflows do not inherit caller context and must receive
|
||||||
|
input. `loop_item`, `loop_index`, and aliases are migration conveniences.
|
||||||
|
|
||||||
Read [references/python-lifecycle.md](references/python-lifecycle.md) when a
|
Read [references/python-lifecycle.md](references/python-lifecycle.md) when a
|
||||||
complete typed lifecycle or an editing/debugging recipe is needed.
|
complete typed lifecycle or an editing/debugging recipe is needed.
|
||||||
|
|||||||
@@ -175,3 +175,26 @@ for frame in trace.frames:
|
|||||||
Catch the specific public errors useful to the application and retain a final
|
Catch the specific public errors useful to the application and retain a final
|
||||||
`WorkflowClientError` fallback. Unknown server errors remain inspectable
|
`WorkflowClientError` fallback. Unknown server errors remain inspectable
|
||||||
`ProtocolError` values with `code`, `message`, and `data`.
|
`ProtocolError` values with `code`, `message`, and `data`.
|
||||||
|
|
||||||
|
## Structured foreach context
|
||||||
|
|
||||||
|
Prefer declared input bindings via the foreach reference:
|
||||||
|
|
||||||
|
```python
|
||||||
|
orders = graph.foreach(
|
||||||
|
id="orders",
|
||||||
|
over=state_path("orders"),
|
||||||
|
as_="order",
|
||||||
|
)
|
||||||
|
charge = graph.use(
|
||||||
|
charge_order,
|
||||||
|
input=[input_from(orders.item, "order")],
|
||||||
|
)
|
||||||
|
graph.set_route(orders, "loop", charge)
|
||||||
|
graph.set_route(charge, "ok", orders)
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal capabilities receive foreach values through declared inputs. Advanced
|
||||||
|
handlers may inspect `ctx.foreach["orders"].index` and stable runtime
|
||||||
|
identities. Child workflows do not inherit caller context and must receive
|
||||||
|
input. `loop_item`, `loop_index`, and aliases are migration conveniences.
|
||||||
|
|||||||
@@ -275,9 +275,7 @@ def _nested_foreach_path_options(
|
|||||||
# bounded object schema, mirroring input/state inventory behavior.
|
# bounded object schema, mirroring input/state inventory behavior.
|
||||||
if prop_name == "item":
|
if prop_name == "item":
|
||||||
options.extend(
|
options.extend(
|
||||||
_nested_item_subpaths(
|
_nested_item_subpaths(prop_schema, owner_id, availability, depth=0)
|
||||||
prop_schema, owner_id, availability, depth=0
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return options
|
return options
|
||||||
|
|
||||||
@@ -302,7 +300,10 @@ def _nested_item_subpaths(
|
|||||||
for name, sub_schema in properties.items():
|
for name, sub_schema in properties.items():
|
||||||
if not isinstance(name, str) or not isinstance(sub_schema, Mapping):
|
if not isinstance(name, str) or not isinstance(sub_schema, Mapping):
|
||||||
continue
|
continue
|
||||||
if isinstance(sub_schema.get("type"), str) and sub_schema.get("type") == "array":
|
if (
|
||||||
|
isinstance(sub_schema.get("type"), str)
|
||||||
|
and sub_schema.get("type") == "array"
|
||||||
|
):
|
||||||
# Arrays are whole values; item indexes need real runtime indexes.
|
# Arrays are whole values; item indexes need real runtime indexes.
|
||||||
path = str(
|
path = str(
|
||||||
GraphSourcePath(
|
GraphSourcePath(
|
||||||
@@ -339,7 +340,10 @@ def _nested_item_subpaths(
|
|||||||
)
|
)
|
||||||
options.extend(
|
options.extend(
|
||||||
_nested_item_subpaths(
|
_nested_item_subpaths(
|
||||||
sub_schema, owner_id, availability, depth=depth + 1,
|
sub_schema,
|
||||||
|
owner_id,
|
||||||
|
availability,
|
||||||
|
depth=depth + 1,
|
||||||
prefix_parts=(*prefix_parts, name),
|
prefix_parts=(*prefix_parts, name),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -296,9 +296,7 @@ def _context_schema_for_stack(
|
|||||||
continue
|
continue
|
||||||
entry_schemas[owner_id] = foreach_entry_schema(
|
entry_schemas[owner_id] = foreach_entry_schema(
|
||||||
owner_id,
|
owner_id,
|
||||||
_foreach_item_schema(
|
_foreach_item_schema(workflow, foreach, foreach_nodes, owner_stack_by_node),
|
||||||
workflow, foreach, foreach_nodes, owner_stack_by_node
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
properties[FOREACH_CONTEXT_KEY] = {
|
properties[FOREACH_CONTEXT_KEY] = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -306,9 +304,9 @@ def _context_schema_for_stack(
|
|||||||
"required": sorted(entry_schemas),
|
"required": sorted(entry_schemas),
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
}
|
}
|
||||||
required: list[str] = [
|
required: list[str] = [field.name for field in STANDARD_CONTEXT_FIELDS] + [
|
||||||
field.name for field in STANDARD_CONTEXT_FIELDS
|
FOREACH_CONTEXT_KEY
|
||||||
] + [FOREACH_CONTEXT_KEY]
|
]
|
||||||
if stack:
|
if stack:
|
||||||
innermost = foreach_nodes.get(stack[-1])
|
innermost = foreach_nodes.get(stack[-1])
|
||||||
if innermost is not None:
|
if innermost is not None:
|
||||||
@@ -445,10 +443,7 @@ def _schema_document(
|
|||||||
current: dict[str, object] = {
|
current: dict[str, object] = {
|
||||||
field.name: field.schema for field in STANDARD_CONTEXT_FIELDS
|
field.name: field.schema for field in STANDARD_CONTEXT_FIELDS
|
||||||
}
|
}
|
||||||
if (
|
if foreach_nodes is not None and owner_stack_by_node is not None:
|
||||||
foreach_nodes is not None
|
|
||||||
and owner_stack_by_node is not None
|
|
||||||
):
|
|
||||||
entry_schemas: dict[str, object] = {}
|
entry_schemas: dict[str, object] = {}
|
||||||
for owner_id in resolved_stack:
|
for owner_id in resolved_stack:
|
||||||
foreach = foreach_nodes.get(owner_id)
|
foreach = foreach_nodes.get(owner_id)
|
||||||
@@ -480,7 +475,9 @@ def _schema_document(
|
|||||||
for field in foreach_context_fields(foreach.as_, item_schema):
|
for field in foreach_context_fields(foreach.as_, item_schema):
|
||||||
# Innermost loop keys win; outer aliases accumulate.
|
# Innermost loop keys win; outer aliases accumulate.
|
||||||
# Validation owns collision diagnostics.
|
# Validation owns collision diagnostics.
|
||||||
is_innermost = bool(resolved_stack) and owner_id == resolved_stack[-1]
|
is_innermost = (
|
||||||
|
bool(resolved_stack) and owner_id == resolved_stack[-1]
|
||||||
|
)
|
||||||
if field.name not in current or is_innermost:
|
if field.name not in current or is_innermost:
|
||||||
current[field.name] = field.schema
|
current[field.name] = field.schema
|
||||||
return {"type": "object", "properties": current}
|
return {"type": "object", "properties": current}
|
||||||
|
|||||||
@@ -64,12 +64,8 @@ def validate_context_paths(
|
|||||||
node.input, f"nodes[{idx}].input", node.id, schema, report
|
node.input, f"nodes[{idx}].input", node.id, schema, report
|
||||||
)
|
)
|
||||||
elif isinstance(node, ConditionNode):
|
elif isinstance(node, ConditionNode):
|
||||||
for location, path in _condition_paths(
|
for location, path in _condition_paths(node.check, f"nodes[{idx}].check"):
|
||||||
node.check, f"nodes[{idx}].check"
|
_validate_one_context_path(path, location, node.id, schema, report)
|
||||||
):
|
|
||||||
_validate_one_context_path(
|
|
||||||
path, location, node.id, schema, report
|
|
||||||
)
|
|
||||||
elif isinstance(node, ForeachNode):
|
elif isinstance(node, ForeachNode):
|
||||||
# Context-rooted `over` paths reach this pass; the old
|
# Context-rooted `over` paths reach this pass; the old
|
||||||
# input/state-only check stays permissive for them.
|
# input/state-only check stays permissive for them.
|
||||||
@@ -110,9 +106,7 @@ def _validate_step_input_bindings(
|
|||||||
binding.expression, f"{binding_location}.expression"
|
binding.expression, f"{binding_location}.expression"
|
||||||
):
|
):
|
||||||
if path.root == "context":
|
if path.root == "context":
|
||||||
_validate_one_context_path(
|
_validate_one_context_path(path, location, node_id, schema, report)
|
||||||
path, location, node_id, schema, report
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _expression_paths(
|
def _expression_paths(
|
||||||
@@ -219,9 +213,10 @@ def _path_in_schema(schema: Mapping[str, Any], parts: tuple[str, ...]) -> bool:
|
|||||||
# allows subpaths; scalar or closed schemas do not.
|
# allows subpaths; scalar or closed schemas do not.
|
||||||
if current == {}:
|
if current == {}:
|
||||||
return True
|
return True
|
||||||
if current.get("type") == "object" and current.get(
|
if (
|
||||||
"additionalProperties", True
|
current.get("type") == "object"
|
||||||
) is not False:
|
and current.get("additionalProperties", True) is not False
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
if part not in properties:
|
if part not in properties:
|
||||||
@@ -280,9 +275,7 @@ def _validate_alias_ownership(
|
|||||||
continue
|
continue
|
||||||
alias = foreach.as_
|
alias = foreach.as_
|
||||||
idx = node_index_by_id.get(owner_id)
|
idx = node_index_by_id.get(owner_id)
|
||||||
location = (
|
location = f"nodes[{idx}].as" if idx is not None else f"nodes[{owner_id}]"
|
||||||
f"nodes[{idx}].as" if idx is not None else f"nodes[{owner_id}]"
|
|
||||||
)
|
|
||||||
if not alias or alias in RESERVED_CONTEXT_KEYS:
|
if not alias or alias in RESERVED_CONTEXT_KEYS:
|
||||||
if owner_id not in reported:
|
if owner_id not in reported:
|
||||||
reported.add(owner_id)
|
reported.add(owner_id)
|
||||||
|
|||||||
@@ -52,12 +52,8 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
|
|||||||
issue.path,
|
issue.path,
|
||||||
issue.message,
|
issue.message,
|
||||||
)
|
)
|
||||||
context_schemas = context_schemas_by_node(
|
context_schemas = context_schemas_by_node(workflow, control_regions=analysis)
|
||||||
workflow, control_regions=analysis
|
validate_context_paths(workflow, context_schemas=context_schemas, report=report)
|
||||||
)
|
|
||||||
validate_context_paths(
|
|
||||||
workflow, context_schemas=context_schemas, report=report
|
|
||||||
)
|
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|||||||
@@ -801,13 +801,9 @@ def test_foreach_reference_treats_dotted_id_as_one_literal_segment() -> None:
|
|||||||
state_schema={"type": "object"},
|
state_schema={"type": "object"},
|
||||||
output_schema={"type": "object"},
|
output_schema={"type": "object"},
|
||||||
)
|
)
|
||||||
each = builder.foreach(
|
each = builder.foreach(id="orders.v2", over=state_path("orders"), as_="order")
|
||||||
id="orders.v2", over=state_path("orders"), as_="order"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert each.item == GraphSourcePath(
|
assert each.item == GraphSourcePath("context", ("foreach", "orders.v2", "item"))
|
||||||
"context", ("foreach", "orders.v2", "item")
|
|
||||||
)
|
|
||||||
assert str(each.item) == 'context.foreach."orders.v2".item'
|
assert str(each.item) == 'context.foreach."orders.v2".item'
|
||||||
assert str(each.index) == 'context.foreach."orders.v2".index'
|
assert str(each.index) == 'context.foreach."orders.v2".index'
|
||||||
|
|
||||||
@@ -840,5 +836,5 @@ def test_foreach_ref_works_in_node_input_binding() -> None:
|
|||||||
input=[input_from(each.item, "order")],
|
input=[input_from(each.item, "order")],
|
||||||
)
|
)
|
||||||
binding = work.input[0]
|
binding = work.input[0]
|
||||||
assert isinstance(binding, object)
|
assert isinstance(binding, InputPathBinding)
|
||||||
assert str(binding.path) == "context.foreach.orders.item"
|
assert str(binding.path) == "context.foreach.orders.item"
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ def test_workflow_builder_subgraph_adds_native_subgraph_node() -> None:
|
|||||||
|
|
||||||
def test_foreach_ref_works_in_subgraph_input_binding() -> None:
|
def test_foreach_ref_works_in_subgraph_input_binding() -> None:
|
||||||
from wf_authoring import state_path
|
from wf_authoring import state_path
|
||||||
|
from wf_core.models.steps import InputPathBinding
|
||||||
|
|
||||||
child = build_demo_workflow()
|
child = build_demo_workflow()
|
||||||
parent = WorkflowBuilder(
|
parent = WorkflowBuilder(
|
||||||
@@ -291,4 +292,6 @@ def test_foreach_ref_works_in_subgraph_input_binding() -> None:
|
|||||||
id="run_child",
|
id="run_child",
|
||||||
input=[input_from(each.item, "order")],
|
input=[input_from(each.item, "order")],
|
||||||
)
|
)
|
||||||
assert str(step.input[0].path) == "context.foreach.orders.item"
|
binding = step.input[0]
|
||||||
|
assert isinstance(binding, InputPathBinding)
|
||||||
|
assert str(binding.path) == "context.foreach.orders.item"
|
||||||
|
|||||||
@@ -86,11 +86,14 @@ def test_active_structured_foreach_item_path_is_valid() -> None:
|
|||||||
|
|
||||||
workflow = _base_workflow(work_path="context.foreach.orders.item")
|
workflow = _base_workflow(work_path="context.foreach.orders.item")
|
||||||
report = validate_workflow(workflow)
|
report = validate_workflow(workflow)
|
||||||
assert _issue(
|
assert (
|
||||||
|
_issue(
|
||||||
report,
|
report,
|
||||||
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
||||||
"nodes[2].input[0].path",
|
"nodes[2].input[0].path",
|
||||||
) is None
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_nested_body_can_read_outer_and_inner_entries() -> None:
|
def test_nested_body_can_read_outer_and_inner_entries() -> None:
|
||||||
@@ -104,18 +107,21 @@ def test_nested_body_can_read_outer_and_inner_entries() -> None:
|
|||||||
):
|
):
|
||||||
workflow = _base_workflow(work_path=path)
|
workflow = _base_workflow(work_path=path)
|
||||||
report = validate_workflow(workflow)
|
report = validate_workflow(workflow)
|
||||||
assert _issue(
|
assert (
|
||||||
report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[2].input[0].path"
|
_issue(
|
||||||
) is None, path
|
report,
|
||||||
|
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
||||||
|
"nodes[2].input[0].path",
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
), path
|
||||||
|
|
||||||
|
|
||||||
def test_inactive_foreach_entry_is_rejected() -> None:
|
def test_inactive_foreach_entry_is_rejected() -> None:
|
||||||
from wf_core.validation import validate_workflow
|
from wf_core.validation import validate_workflow
|
||||||
|
|
||||||
workflow = _base_workflow()
|
workflow = _base_workflow()
|
||||||
workflow.nodes[3] = _node_use(
|
workflow.nodes[3] = _node_use("after_inner", path="context.foreach.orders.item")
|
||||||
"after_inner", path="context.foreach.orders.item"
|
|
||||||
)
|
|
||||||
report = validate_workflow(workflow)
|
report = validate_workflow(workflow)
|
||||||
issue = _issue(
|
issue = _issue(
|
||||||
report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[3].input[0].path"
|
report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[3].input[0].path"
|
||||||
@@ -172,9 +178,7 @@ def test_workflow_output_cannot_read_completed_foreach_entry() -> None:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
report = validate_workflow(workflow)
|
report = validate_workflow(workflow)
|
||||||
issue = _issue(
|
issue = _issue(report, ValidationIssueCode.INVALID_CONTEXT_PATH, "output[0].path")
|
||||||
report, ValidationIssueCode.INVALID_CONTEXT_PATH, "output[0].path"
|
|
||||||
)
|
|
||||||
assert issue is not None
|
assert issue is not None
|
||||||
|
|
||||||
|
|
||||||
@@ -197,9 +201,7 @@ def test_workflow_output_cannot_read_completed_foreach_entry() -> None:
|
|||||||
"work",
|
"work",
|
||||||
expression={
|
expression={
|
||||||
"kind": "array",
|
"kind": "array",
|
||||||
"items": [
|
"items": [{"kind": "path", "path": "context.foreach.missing.item"}],
|
||||||
{"kind": "path", "path": "context.foreach.missing.item"}
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
"nodes[2].input[0].expression.items[0].path",
|
"nodes[2].input[0].expression.items[0].path",
|
||||||
@@ -235,7 +237,9 @@ def test_all_model_surfaces_reject_missing_foreach_id() -> None:
|
|||||||
)
|
)
|
||||||
report = validate_workflow(workflow)
|
report = validate_workflow(workflow)
|
||||||
assert (
|
assert (
|
||||||
_issue(report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[2].input[0].path")
|
_issue(
|
||||||
|
report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[2].input[0].path"
|
||||||
|
)
|
||||||
is not None
|
is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -250,14 +254,14 @@ def test_all_model_surfaces_reject_missing_foreach_id() -> None:
|
|||||||
Edge.model_validate({"from": "work", "outcome": "true", "to": "orders"}),
|
Edge.model_validate({"from": "work", "outcome": "true", "to": "orders"}),
|
||||||
Edge.model_validate({"from": "work", "outcome": "false", "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": "orders", "outcome": "done", "to": "after_inner"}),
|
||||||
Edge.model_validate({"from": "after_inner", "outcome": "ok", "to": "customers"}),
|
Edge.model_validate(
|
||||||
|
{"from": "after_inner", "outcome": "ok", "to": "customers"}
|
||||||
|
),
|
||||||
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
||||||
]
|
]
|
||||||
report = validate_workflow(workflow)
|
report = validate_workflow(workflow)
|
||||||
assert (
|
assert (
|
||||||
_issue(
|
_issue(report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[2].check.path")
|
||||||
report, ValidationIssueCode.INVALID_CONTEXT_PATH, "nodes[2].check.path"
|
|
||||||
)
|
|
||||||
is not None
|
is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -289,7 +293,9 @@ def test_all_model_surfaces_reject_missing_foreach_id() -> None:
|
|||||||
Edge.model_validate({"from": "orders", "outcome": "loop", "to": "work"}),
|
Edge.model_validate({"from": "orders", "outcome": "loop", "to": "work"}),
|
||||||
Edge.model_validate({"from": "work", "outcome": "submitted", "to": "orders"}),
|
Edge.model_validate({"from": "work", "outcome": "submitted", "to": "orders"}),
|
||||||
Edge.model_validate({"from": "orders", "outcome": "done", "to": "after_inner"}),
|
Edge.model_validate({"from": "orders", "outcome": "done", "to": "after_inner"}),
|
||||||
Edge.model_validate({"from": "after_inner", "outcome": "ok", "to": "customers"}),
|
Edge.model_validate(
|
||||||
|
{"from": "after_inner", "outcome": "ok", "to": "customers"}
|
||||||
|
),
|
||||||
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
||||||
]
|
]
|
||||||
report = validate_workflow(workflow)
|
report = validate_workflow(workflow)
|
||||||
@@ -306,9 +312,7 @@ def test_all_model_surfaces_reject_missing_foreach_id() -> None:
|
|||||||
from wf_core.models.steps import InputPathBinding as _IPB
|
from wf_core.models.steps import InputPathBinding as _IPB
|
||||||
|
|
||||||
workflow = _base_workflow()
|
workflow = _base_workflow()
|
||||||
workflow.output = [
|
workflow.output = [_IPB.model_validate({"target": "result", "path": bad})]
|
||||||
_IPB.model_validate({"target": "result", "path": bad})
|
|
||||||
]
|
|
||||||
report = validate_workflow(workflow)
|
report = validate_workflow(workflow)
|
||||||
assert (
|
assert (
|
||||||
_issue(report, ValidationIssueCode.INVALID_CONTEXT_PATH, "output[0].path")
|
_issue(report, ValidationIssueCode.INVALID_CONTEXT_PATH, "output[0].path")
|
||||||
@@ -381,7 +385,9 @@ def test_sibling_foreach_aliases_may_match_when_never_active_together() -> None:
|
|||||||
Edge.model_validate({"from": "left", "outcome": "loop", "to": "left_body"}),
|
Edge.model_validate({"from": "left", "outcome": "loop", "to": "left_body"}),
|
||||||
Edge.model_validate({"from": "left_body", "outcome": "ok", "to": "left"}),
|
Edge.model_validate({"from": "left_body", "outcome": "ok", "to": "left"}),
|
||||||
Edge.model_validate({"from": "left", "outcome": "done", "to": "right"}),
|
Edge.model_validate({"from": "left", "outcome": "done", "to": "right"}),
|
||||||
Edge.model_validate({"from": "right", "outcome": "loop", "to": "right_body"}),
|
Edge.model_validate(
|
||||||
|
{"from": "right", "outcome": "loop", "to": "right_body"}
|
||||||
|
),
|
||||||
Edge.model_validate({"from": "right_body", "outcome": "ok", "to": "right"}),
|
Edge.model_validate({"from": "right_body", "outcome": "ok", "to": "right"}),
|
||||||
Edge.model_validate({"from": "right", "outcome": "done", "to": "join"}),
|
Edge.model_validate({"from": "right", "outcome": "done", "to": "join"}),
|
||||||
Edge.model_validate({"from": "join", "outcome": "ok", "to": END}),
|
Edge.model_validate({"from": "join", "outcome": "ok", "to": END}),
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ def test_root_frame_has_empty_structured_foreach_context() -> None:
|
|||||||
def test_nested_same_scope_frames_expose_outermost_to_innermost_context() -> None:
|
def test_nested_same_scope_frames_expose_outermost_to_innermost_context() -> None:
|
||||||
run = _run_with_frames(
|
run = _run_with_frames(
|
||||||
[
|
[
|
||||||
ExecutionFrame(id="root", kind="root", node_id="customers", scope_id="root"),
|
ExecutionFrame(
|
||||||
|
id="root", kind="root", node_id="customers", scope_id="root"
|
||||||
|
),
|
||||||
_item_frame(
|
_item_frame(
|
||||||
frame_id="outer-item",
|
frame_id="outer-item",
|
||||||
parent_id="root",
|
parent_id="root",
|
||||||
@@ -119,7 +121,9 @@ def test_nested_same_scope_frames_expose_outermost_to_innermost_context() -> Non
|
|||||||
def test_graph_context_values_keep_all_aliases_and_innermost_loop_keys() -> None:
|
def test_graph_context_values_keep_all_aliases_and_innermost_loop_keys() -> None:
|
||||||
run = _run_with_frames(
|
run = _run_with_frames(
|
||||||
[
|
[
|
||||||
ExecutionFrame(id="root", kind="root", node_id="customers", scope_id="root"),
|
ExecutionFrame(
|
||||||
|
id="root", kind="root", node_id="customers", scope_id="root"
|
||||||
|
),
|
||||||
_item_frame(
|
_item_frame(
|
||||||
frame_id="outer-item",
|
frame_id="outer-item",
|
||||||
parent_id="root",
|
parent_id="root",
|
||||||
@@ -158,7 +162,9 @@ def test_graph_context_values_keep_all_aliases_and_innermost_loop_keys() -> None
|
|||||||
def test_context_ancestry_stops_at_runtime_scope_boundary() -> None:
|
def test_context_ancestry_stops_at_runtime_scope_boundary() -> None:
|
||||||
run = _run_with_frames(
|
run = _run_with_frames(
|
||||||
[
|
[
|
||||||
ExecutionFrame(id="root", kind="root", node_id="customers", scope_id="root"),
|
ExecutionFrame(
|
||||||
|
id="root", kind="root", node_id="customers", scope_id="root"
|
||||||
|
),
|
||||||
_item_frame(
|
_item_frame(
|
||||||
frame_id="outer-item",
|
frame_id="outer-item",
|
||||||
parent_id="root",
|
parent_id="root",
|
||||||
@@ -290,7 +296,9 @@ def test_structured_context_rejects_duplicate_active_alias() -> None:
|
|||||||
def test_context_read_does_not_mutate_run_state() -> None:
|
def test_context_read_does_not_mutate_run_state() -> None:
|
||||||
run = _run_with_frames(
|
run = _run_with_frames(
|
||||||
[
|
[
|
||||||
ExecutionFrame(id="root", kind="root", node_id="customers", scope_id="root"),
|
ExecutionFrame(
|
||||||
|
id="root", kind="root", node_id="customers", scope_id="root"
|
||||||
|
),
|
||||||
_item_frame(
|
_item_frame(
|
||||||
frame_id="outer-item",
|
frame_id="outer-item",
|
||||||
parent_id="root",
|
parent_id="root",
|
||||||
@@ -369,7 +377,9 @@ def _nested_workflow(*, inner_over: str = "state.orders_list"):
|
|||||||
),
|
),
|
||||||
Edge.model_validate({"from": "orders", "outcome": "loop", "to": "work"}),
|
Edge.model_validate({"from": "orders", "outcome": "loop", "to": "work"}),
|
||||||
Edge.model_validate({"from": "work", "outcome": "ok", "to": "orders"}),
|
Edge.model_validate({"from": "work", "outcome": "ok", "to": "orders"}),
|
||||||
Edge.model_validate({"from": "orders", "outcome": "done", "to": "customers"}),
|
Edge.model_validate(
|
||||||
|
{"from": "orders", "outcome": "done", "to": "customers"}
|
||||||
|
),
|
||||||
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -398,9 +408,7 @@ def test_nested_handler_receives_outer_and_inner_typed_entries() -> None:
|
|||||||
{"record": record},
|
{"record": record},
|
||||||
)
|
)
|
||||||
assert run.status == RunStatus.COMPLETED
|
assert run.status == RunStatus.COMPLETED
|
||||||
assert seen == [
|
assert seen == [(("customers", "orders"), {"name": "Ada"}, {"sku": "A-17"}, 0)]
|
||||||
(("customers", "orders"), {"name": "Ada"}, {"sku": "A-17"}, 0)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_nested_graph_bindings_resolve_outer_and_inner_items() -> None:
|
def test_nested_graph_bindings_resolve_outer_and_inner_items() -> None:
|
||||||
@@ -672,9 +680,7 @@ def test_concurrent_items_receive_distinct_frame_and_lineage_context() -> None:
|
|||||||
contexts.append(ctx.foreach["each"])
|
contexts.append(ctx.foreach["each"])
|
||||||
return {"outcome": "ok", "output": {}}
|
return {"outcome": "ok", "output": {}}
|
||||||
|
|
||||||
run = execute_workflow(
|
run = execute_workflow(workflow, {"items": ["a", "b"]}, {"record": record})
|
||||||
workflow, {"items": ["a", "b"]}, {"record": record}
|
|
||||||
)
|
|
||||||
assert run.status == RunStatus.COMPLETED
|
assert run.status == RunStatus.COMPLETED
|
||||||
assert len(contexts) == 2
|
assert len(contexts) == 2
|
||||||
assert contexts[0].activation_id == contexts[1].activation_id
|
assert contexts[0].activation_id == contexts[1].activation_id
|
||||||
@@ -686,9 +692,7 @@ def test_concurrent_items_receive_distinct_frame_and_lineage_context() -> None:
|
|||||||
def test_nested_foreach_over_resolves_structured_outer_item_path() -> None:
|
def test_nested_foreach_over_resolves_structured_outer_item_path() -> None:
|
||||||
from wf_core import execute_workflow
|
from wf_core import execute_workflow
|
||||||
|
|
||||||
workflow = _nested_workflow(
|
workflow = _nested_workflow(inner_over="context.foreach.customers.item.orders")
|
||||||
inner_over="context.foreach.customers.item.orders"
|
|
||||||
)
|
|
||||||
captured: list[object] = []
|
captured: list[object] = []
|
||||||
|
|
||||||
def record(payload: dict[str, object], _ctx: RuntimeContext) -> dict[str, object]:
|
def record(payload: dict[str, object], _ctx: RuntimeContext) -> dict[str, object]:
|
||||||
@@ -705,3 +709,126 @@ def test_nested_foreach_over_resolves_structured_outer_item_path() -> None:
|
|||||||
)
|
)
|
||||||
assert run.status == RunStatus.COMPLETED
|
assert run.status == RunStatus.COMPLETED
|
||||||
assert captured == [{"sku": "A-17"}]
|
assert captured == [{"sku": "A-17"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupt_resume_recreates_structured_context_identities() -> None:
|
||||||
|
from wf_core import (
|
||||||
|
END,
|
||||||
|
Edge,
|
||||||
|
ForeachNode,
|
||||||
|
InterruptNode,
|
||||||
|
NodeDef,
|
||||||
|
NodeUse,
|
||||||
|
SchemaRef,
|
||||||
|
Workflow,
|
||||||
|
execute_workflow,
|
||||||
|
resume_workflow,
|
||||||
|
)
|
||||||
|
from wf_core.models.schemas import StateField, StateSchema
|
||||||
|
from wf_core.run_codec import dump_run_state, load_run_state
|
||||||
|
|
||||||
|
workflow = Workflow(
|
||||||
|
name="nested_interrupt_resume",
|
||||||
|
input_schema=SchemaRef(type="object", properties={}),
|
||||||
|
state_schema=StateSchema.from_field_map(
|
||||||
|
{
|
||||||
|
"customers": StateField(type="array"),
|
||||||
|
"orders_list": 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="outer",
|
||||||
|
nodes=[
|
||||||
|
ForeachNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "outer",
|
||||||
|
"type": "foreach",
|
||||||
|
"over": "state.customers",
|
||||||
|
"as": "customer",
|
||||||
|
"mode": "serial",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
ForeachNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "inner",
|
||||||
|
"type": "foreach",
|
||||||
|
"over": "state.orders_list",
|
||||||
|
"as": "order",
|
||||||
|
"mode": "serial",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
NodeUse.model_validate(
|
||||||
|
{
|
||||||
|
"id": "pre",
|
||||||
|
"type": "node",
|
||||||
|
"node": "record",
|
||||||
|
"input": [{"target": "value", "path": "context.order"}],
|
||||||
|
"output": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
InterruptNode.model_validate(
|
||||||
|
{"id": "ask", "type": "interrupt", "kind": "approval", "request": []}
|
||||||
|
),
|
||||||
|
NodeUse.model_validate(
|
||||||
|
{
|
||||||
|
"id": "post",
|
||||||
|
"type": "node",
|
||||||
|
"node": "record",
|
||||||
|
"input": [{"target": "value", "path": "context.order"}],
|
||||||
|
"output": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
Edge.model_validate({"from": "outer", "outcome": "loop", "to": "inner"}),
|
||||||
|
Edge.model_validate({"from": "inner", "outcome": "loop", "to": "pre"}),
|
||||||
|
Edge.model_validate({"from": "pre", "outcome": "ok", "to": "ask"}),
|
||||||
|
Edge.model_validate({"from": "ask", "outcome": "submitted", "to": "post"}),
|
||||||
|
Edge.model_validate({"from": "post", "outcome": "ok", "to": "inner"}),
|
||||||
|
Edge.model_validate({"from": "inner", "outcome": "done", "to": "outer"}),
|
||||||
|
Edge.model_validate({"from": "outer", "outcome": "done", "to": END}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
before: list[dict[str, ForeachContext]] = []
|
||||||
|
after: list[dict[str, ForeachContext]] = []
|
||||||
|
phase = {"value": "before"}
|
||||||
|
|
||||||
|
def record(_payload: dict[str, object], ctx: RuntimeContext) -> dict[str, object]:
|
||||||
|
snapshot = dict(ctx.foreach)
|
||||||
|
if phase["value"] == "before":
|
||||||
|
before.append(snapshot)
|
||||||
|
else:
|
||||||
|
after.append(snapshot)
|
||||||
|
return {"outcome": "ok", "output": {}}
|
||||||
|
|
||||||
|
run = execute_workflow(
|
||||||
|
workflow,
|
||||||
|
{"customers": [{"name": "Ada"}], "orders_list": [{"sku": "A-17"}]},
|
||||||
|
{"record": record},
|
||||||
|
)
|
||||||
|
assert run.status == RunStatus.INTERRUPTED
|
||||||
|
assert len(before) == 1
|
||||||
|
# Genuinely test reconstruction: dump, reload, resume from loaded state.
|
||||||
|
payload = dump_run_state(run)
|
||||||
|
reloaded = load_run_state(payload)
|
||||||
|
phase["value"] = "after"
|
||||||
|
resumed = resume_workflow(workflow, reloaded, {"record": record}, resume_payload={})
|
||||||
|
assert resumed.status == RunStatus.COMPLETED
|
||||||
|
assert len(after) == 1
|
||||||
|
before_outer = before[0]["outer"]
|
||||||
|
after_outer = after[0]["outer"]
|
||||||
|
before_inner = before[0]["inner"]
|
||||||
|
after_inner = after[0]["inner"]
|
||||||
|
assert after_outer.activation_id == before_outer.activation_id
|
||||||
|
assert after_inner.activation_id == before_inner.activation_id
|
||||||
|
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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from wf_core import (
|
|||||||
NodeUse,
|
NodeUse,
|
||||||
PreparedSubgraph,
|
PreparedSubgraph,
|
||||||
RunState,
|
RunState,
|
||||||
|
RunStatus,
|
||||||
SchemaRef,
|
SchemaRef,
|
||||||
StateField,
|
StateField,
|
||||||
StateSchema,
|
StateSchema,
|
||||||
@@ -425,3 +426,143 @@ def _interrupting_child_workflow() -> Workflow:
|
|||||||
|
|
||||||
def _schema(properties: dict[str, object]) -> SchemaRef:
|
def _schema(properties: dict[str, object]) -> SchemaRef:
|
||||||
return SchemaRef.model_validate({"type": "object", "properties": properties})
|
return SchemaRef.model_validate({"type": "object", "properties": properties})
|
||||||
|
|
||||||
|
|
||||||
|
def test_subgraph_does_not_inherit_caller_foreach_context() -> None:
|
||||||
|
from wf_core import ForeachNode, RuntimeContext
|
||||||
|
|
||||||
|
child_seen: dict[str, object] = {}
|
||||||
|
pre_seen: dict[str, object] = {}
|
||||||
|
|
||||||
|
def probe(payload: dict[str, object], ctx: RuntimeContext) -> dict[str, object]:
|
||||||
|
if ctx.current_node_id == "pre":
|
||||||
|
pre_seen["foreach"] = dict(ctx.foreach)
|
||||||
|
pre_seen["input_order"] = payload.get("order")
|
||||||
|
else:
|
||||||
|
child_seen["input_order"] = payload.get("order")
|
||||||
|
child_seen["context"] = ctx
|
||||||
|
return {"outcome": "ok", "output": {}}
|
||||||
|
|
||||||
|
child = Workflow(
|
||||||
|
name="child.workflow",
|
||||||
|
input_schema=_schema({"order": {"type": "object"}}),
|
||||||
|
state_schema=StateSchema.from_field_map(
|
||||||
|
{
|
||||||
|
"order": StateField(type="object"),
|
||||||
|
"child_items": StateField(type="array", default=["child-item"]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
output_schema=_schema({}),
|
||||||
|
node_defs=[
|
||||||
|
NodeDef(
|
||||||
|
name="probe",
|
||||||
|
input_schema=_schema({"order": {}}),
|
||||||
|
output_schema=_schema({}),
|
||||||
|
outcomes=["ok"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
start="pre",
|
||||||
|
nodes=[
|
||||||
|
NodeUse.model_validate(
|
||||||
|
{
|
||||||
|
"id": "pre",
|
||||||
|
"type": "node",
|
||||||
|
"node": "probe",
|
||||||
|
"input": [{"target": "order", "path": "input.order"}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
ForeachNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "orders",
|
||||||
|
"type": "foreach",
|
||||||
|
"over": "state.child_items",
|
||||||
|
"as": "c_order",
|
||||||
|
"mode": "serial",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
NodeUse.model_validate(
|
||||||
|
{
|
||||||
|
"id": "work",
|
||||||
|
"type": "node",
|
||||||
|
"node": "probe",
|
||||||
|
"input": [{"target": "order", "path": "input.order"}],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
Edge.model_validate({"from": "pre", "outcome": "ok", "to": "orders"}),
|
||||||
|
Edge.model_validate({"from": "orders", "outcome": "loop", "to": "work"}),
|
||||||
|
Edge.model_validate({"from": "work", "outcome": "ok", "to": "orders"}),
|
||||||
|
Edge.model_validate({"from": "orders", "outcome": "done", "to": END}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
parent = Workflow(
|
||||||
|
name="parent_orders",
|
||||||
|
input_schema=_schema({}),
|
||||||
|
state_schema=StateSchema.from_field_map(
|
||||||
|
{"parent_orders": StateField(type="array")}
|
||||||
|
),
|
||||||
|
output_schema=_schema({}),
|
||||||
|
node_defs=[
|
||||||
|
NodeDef(
|
||||||
|
name="noop",
|
||||||
|
input_schema=_schema({}),
|
||||||
|
output_schema=_schema({}),
|
||||||
|
outcomes=["ok"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
start="orders",
|
||||||
|
nodes=[
|
||||||
|
ForeachNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "orders",
|
||||||
|
"type": "foreach",
|
||||||
|
"over": "state.parent_orders",
|
||||||
|
"as": "p_order",
|
||||||
|
"mode": "serial",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
SubgraphNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "run_child",
|
||||||
|
"type": "subgraph",
|
||||||
|
"workflow": "child.workflow",
|
||||||
|
"input_schema": _schema({"order": {"type": "object"}}),
|
||||||
|
"output_schema": _schema({}),
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"target": "order",
|
||||||
|
"path": "context.foreach.orders.item",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"output": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
Edge.model_validate(
|
||||||
|
{"from": "orders", "outcome": "loop", "to": "run_child"}
|
||||||
|
),
|
||||||
|
Edge.model_validate({"from": "run_child", "outcome": "ok", "to": "orders"}),
|
||||||
|
Edge.model_validate({"from": "orders", "outcome": "done", "to": END}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
run = execute_workflow(
|
||||||
|
parent,
|
||||||
|
{"parent_orders": [{"sku": "A-17"}]},
|
||||||
|
{},
|
||||||
|
subgraphs={
|
||||||
|
"child.workflow": PreparedSubgraph(
|
||||||
|
workflow=child, registry={"probe": probe}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert run.status == RunStatus.COMPLETED
|
||||||
|
assert child_seen["input_order"] == {"sku": "A-17"}
|
||||||
|
ctx = child_seen["context"]
|
||||||
|
assert isinstance(ctx, RuntimeContext)
|
||||||
|
assert tuple(ctx.foreach) == ("orders",)
|
||||||
|
assert ctx.foreach["orders"].item == "child-item"
|
||||||
|
assert ctx.foreach["orders"].scope_id != "root"
|
||||||
|
assert pre_seen["foreach"] == {}
|
||||||
|
assert pre_seen["input_order"] == {"sku": "A-17"}
|
||||||
|
|||||||
@@ -491,8 +491,12 @@ def test_structured_foreach_paths_appear_in_authoring_inventory() -> None:
|
|||||||
Edge.model_validate(
|
Edge.model_validate(
|
||||||
{"from": "orders", "outcome": "loop", "to": "inner_body"}
|
{"from": "orders", "outcome": "loop", "to": "inner_body"}
|
||||||
),
|
),
|
||||||
Edge.model_validate({"from": "inner_body", "outcome": "ok", "to": "orders"}),
|
Edge.model_validate(
|
||||||
Edge.model_validate({"from": "orders", "outcome": "done", "to": "customers"}),
|
{"from": "inner_body", "outcome": "ok", "to": "orders"}
|
||||||
|
),
|
||||||
|
Edge.model_validate(
|
||||||
|
{"from": "orders", "outcome": "done", "to": "customers"}
|
||||||
|
),
|
||||||
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
Edge.model_validate({"from": "customers", "outcome": "done", "to": END}),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user