feat: describe structured foreach context

This commit is contained in:
lda
2026-09-05 00:16:25 +07:00 Verified
parent f6e5190696
commit 77537ff35a
6 changed files with 525 additions and 51 deletions
+123
View File
@@ -10,6 +10,7 @@ from wf_core.analysis.context_scopes import (
context_fields_by_node, context_fields_by_node,
) )
from wf_core.models.workflow import Workflow from wf_core.models.workflow import Workflow
from wf_core.paths import GraphSourcePath
from .models.authoring_contracts import ( from .models.authoring_contracts import (
AuthoringContractInventoryPayload, AuthoringContractInventoryPayload,
@@ -173,6 +174,8 @@ def context_path_options(
) -> list[AuthoringPathOptionPayload]: ) -> list[AuthoringPathOptionPayload]:
"""Project analyzed runtime context fields into Task 1 path payloads.""" """Project analyzed runtime context fields into Task 1 path payloads."""
options: list[AuthoringPathOptionPayload] = [] options: list[AuthoringPathOptionPayload] = []
foreach_schema: Mapping[str, Any] | None = None
foreach_availability: str = "available"
for field in fields: for field in fields:
if isinstance(field, ContextFieldAvailability): if isinstance(field, ContextFieldAvailability):
name = field.name name = field.name
@@ -212,6 +215,13 @@ def context_path_options(
if reason is not None: if reason is not None:
option["reason"] = reason option["reason"] = reason
options.append(option) options.append(option)
if name == "foreach" and isinstance(schema, Mapping):
foreach_schema = schema
foreach_availability = availability
if foreach_schema is not None:
options.extend(
_nested_foreach_path_options(foreach_schema, foreach_availability)
)
return options return options
@@ -223,6 +233,119 @@ def context_path_options_for_node(
return context_path_options(context_fields_by_node(workflow).get(node_id, ())) return context_path_options(context_fields_by_node(workflow).get(node_id, ()))
def _nested_foreach_path_options(
foreach_schema: Mapping[str, Any],
availability: str,
) -> list[AuthoringPathOptionPayload]:
"""Emit literal structured paths beneath the ``foreach`` map.
Foreach ids are literal TOML segments formatted through
``GraphSourcePath`` so dotted ids stay quoted as one segment. The walk is
bounded like other authoring traversals; arrays contribute no invented
child names.
"""
from .models.authoring_contracts import AuthoringPathOptionPayload as _Payload
options: list[_Payload] = []
properties = foreach_schema.get("properties")
if not isinstance(properties, Mapping):
return options
for owner_id, entry_schema in properties.items():
if not isinstance(owner_id, str) or not isinstance(entry_schema, Mapping):
continue
entry_properties = entry_schema.get("properties")
if not isinstance(entry_properties, Mapping):
continue
for prop_name, prop_schema in entry_properties.items():
if not isinstance(prop_name, str) or not isinstance(prop_schema, Mapping):
continue
# Construct dotted ids as literal segments, never by string concat.
path = str(GraphSourcePath("context", ("foreach", owner_id, prop_name)))
option: _Payload = {
"path": path,
"label": prop_name.replace("_", " ").replace("-", " ").title(),
"origin": "runtime_context",
"schema": deepcopy(dict(prop_schema)),
"required": False,
"availability": availability, # type: ignore[typeddict-item]
"uses": ["step_input"],
}
options.append(option)
# Emit nested object properties beneath `.item` when the item is a
# bounded object schema, mirroring input/state inventory behavior.
if prop_name == "item":
options.extend(
_nested_item_subpaths(
prop_schema, owner_id, availability, depth=0
)
)
return options
def _nested_item_subpaths(
item_schema: Mapping[str, Any],
owner_id: str,
availability: str,
*,
depth: int,
prefix_parts: tuple[str, ...] = (),
) -> list[AuthoringPathOptionPayload]:
"""Emit bounded object children beneath one foreach ``item`` schema."""
from .models.authoring_contracts import AuthoringPathOptionPayload as _Payload
if depth >= _MAX_LOCAL_SCHEMA_REFERENCE_DEPTH:
return []
properties = item_schema.get("properties")
if not isinstance(properties, Mapping):
return []
options: list[_Payload] = []
for name, sub_schema in properties.items():
if not isinstance(name, str) or not isinstance(sub_schema, Mapping):
continue
if isinstance(sub_schema.get("type"), str) and sub_schema.get("type") == "array":
# Arrays are whole values; item indexes need real runtime indexes.
path = str(
GraphSourcePath(
"context", ("foreach", owner_id, "item", *prefix_parts, name)
)
)
options.append(
{
"path": path,
"label": name.replace("_", " ").replace("-", " ").title(),
"origin": "runtime_context",
"schema": deepcopy(dict(sub_schema)),
"required": False,
"availability": availability, # type: ignore[typeddict-item]
"uses": ["step_input"],
}
)
continue
path = str(
GraphSourcePath(
"context", ("foreach", owner_id, "item", *prefix_parts, name)
)
)
options.append(
{
"path": path,
"label": name.replace("_", " ").replace("-", " ").title(),
"origin": "runtime_context",
"schema": deepcopy(dict(sub_schema)),
"required": False,
"availability": availability, # type: ignore[typeddict-item]
"uses": ["step_input"],
}
)
options.extend(
_nested_item_subpaths(
sub_schema, owner_id, availability, depth=depth + 1,
prefix_parts=(*prefix_parts, name),
)
)
return options
def _context_entries_for_inventory( def _context_entries_for_inventory(
entries: Sequence[AuthoringPathOptionPayload], entries: Sequence[AuthoringPathOptionPayload],
) -> list[AuthoringPathOptionPayload]: ) -> list[AuthoringPathOptionPayload]:
+6
View File
@@ -4,6 +4,9 @@ from .context_scopes import (
ContextFieldAvailability, ContextFieldAvailability,
context_analysis_warnings, context_analysis_warnings,
context_fields_by_node, context_fields_by_node,
context_schema_for_node,
context_schemas_by_node,
root_context_schema,
) )
from .control_regions import ( from .control_regions import (
ControlRegionAnalysis, ControlRegionAnalysis,
@@ -22,4 +25,7 @@ __all__ = [
"analyze_control_regions", "analyze_control_regions",
"context_analysis_warnings", "context_analysis_warnings",
"context_fields_by_node", "context_fields_by_node",
"context_schema_for_node",
"context_schemas_by_node",
"root_context_schema",
] ]
+239 -49
View File
@@ -6,14 +6,20 @@ from dataclasses import dataclass
from typing import Literal from typing import Literal
from wf_core.analysis.control_regions import ( from wf_core.analysis.control_regions import (
ControlRegionAnalysis,
ForeachOwnerStack, ForeachOwnerStack,
analyze_control_regions, analyze_control_regions,
) )
from wf_core.context_contracts import ( from wf_core.context_contracts import (
FOREACH_CONTEXT_KEY,
LOOP_INDEX_CONTEXT_KEY,
LOOP_ITEM_CONTEXT_KEY,
STANDARD_CONTEXT_FIELDS, STANDARD_CONTEXT_FIELDS,
ContextFieldContract, ContextFieldContract,
ContextSchema, ContextSchema,
foreach_context_fields, foreach_context_fields,
foreach_entry_schema,
structured_foreach_contract,
) )
from wf_core.models.steps import ForeachNode from wf_core.models.steps import ForeachNode
from wf_core.models.workflow import Edge, Workflow from wf_core.models.workflow import Edge, Workflow
@@ -65,17 +71,19 @@ class _Warnings:
def context_fields_by_node( def context_fields_by_node(
workflow: Workflow, workflow: Workflow,
*,
control_regions: ControlRegionAnalysis | None = None,
) -> dict[str, tuple[ContextFieldAvailability, ...]]: ) -> dict[str, tuple[ContextFieldAvailability, ...]]:
"""Return runtime context contracts for every reachable graph node. """Return runtime context contracts for every reachable graph node.
This is an abstract execution-frame analysis keyed by static control This is an abstract execution-frame analysis keyed by static control
region: each node use belongs to exactly one foreach-owner stack, and region: each node use belongs to exactly one foreach-owner stack, and
that stack decides which foreach aliases the node exposes. A node that stack decides which foreach entries the node exposes. A node
reachable under two stacks is a region conflict and receives no foreach reachable under two stacks is a region conflict and receives no foreach
fields. The traversal still memoizes node id and owner stack so cyclic fields. The traversal still memoizes node id and owner stack so cyclic
graphs terminate. graphs terminate.
""" """
return _analyze(workflow).fields_by_node return _analyze(workflow, control_regions=control_regions).fields_by_node
def context_analysis_warnings(workflow: Workflow) -> tuple[str, ...]: def context_analysis_warnings(workflow: Workflow) -> tuple[str, ...]:
@@ -83,11 +91,69 @@ def context_analysis_warnings(workflow: Workflow) -> tuple[str, ...]:
return _analyze(workflow).warnings return _analyze(workflow).warnings
def _analyze(workflow: Workflow) -> _ContextAnalysis: def context_schemas_by_node(
workflow: Workflow,
*,
control_regions: ControlRegionAnalysis | None = None,
) -> dict[str, ContextSchema]:
"""Return schemas for all unambiguous, reachable program locations.
A conflicted or unreachable node has no generated per-node schema;
callers treat that absence as invalid, not as root context. Schemas are
composed from the same owner-stack analysis as field contracts, not from
a second graph traversal.
"""
analysis = control_regions or analyze_control_regions(workflow)
foreach_nodes = {
node.id: node for node in workflow.nodes if isinstance(node, ForeachNode)
}
schemas: dict[str, ContextSchema] = {}
for node_id, stack in analysis.owner_stack_by_node.items():
# Conflicted nodes are already dropped from owner_stack_by_node by the
# control-region analysis, so every entry here is unambiguous.
schemas[node_id] = _context_schema_for_stack(
workflow, foreach_nodes, analysis.owner_stack_by_node, stack
)
return schemas
def context_schema_for_node(workflow: Workflow, node_id: str) -> ContextSchema:
"""Return the complete graph-visible context object schema at one node."""
schemas = context_schemas_by_node(workflow)
try:
return schemas[node_id]
except KeyError as exc:
raise KeyError(f"no context schema for node {node_id!r}") from exc
def root_context_schema() -> ContextSchema:
"""Return standard fields plus an empty structured foreach map."""
properties: dict[str, ContextSchema] = {
field.name: deepcopy(field.schema) for field in STANDARD_CONTEXT_FIELDS
}
properties[FOREACH_CONTEXT_KEY] = {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False,
}
return {
"type": "object",
"properties": properties,
"required": sorted(properties),
"additionalProperties": False,
}
def _analyze(
workflow: Workflow,
*,
control_regions: ControlRegionAnalysis | None = None,
) -> _ContextAnalysis:
"""Derive context contracts from static foreach control regions. """Derive context contracts from static foreach control regions.
Each unambiguous node use has exactly one owner stack; its active foreach Each unambiguous node use has exactly one owner stack; all active entries
is the final stack item. A canonical return edge pops the stack, so the in that stack are exposed. A canonical return edge pops the stack, so the
controller itself stays in the outer context. Conflicted nodes receive no controller itself stays in the outer context. Conflicted nodes receive no
foreach fields. foreach fields.
""" """
@@ -118,7 +184,7 @@ def _analyze(workflow: Workflow) -> _ContextAnalysis:
warnings.add(f"workflow start targets missing node {workflow.start!r}") warnings.add(f"workflow start targets missing node {workflow.start!r}")
return _ContextAnalysis({}, tuple(warnings.values)) return _ContextAnalysis({}, tuple(warnings.values))
analysis = analyze_control_regions(workflow) analysis = control_regions or analyze_control_regions(workflow)
for issue in analysis.issues: for issue in analysis.issues:
warnings.add( warnings.add(
f"control region {issue.kind.value} at {issue.path}: {issue.message}" f"control region {issue.kind.value} at {issue.path}: {issue.message}"
@@ -126,12 +192,11 @@ def _analyze(workflow: Workflow) -> _ContextAnalysis:
fields_by_node: dict[str, tuple[ContextFieldAvailability, ...]] = {} fields_by_node: dict[str, tuple[ContextFieldAvailability, ...]] = {}
for node_id, stack in analysis.owner_stack_by_node.items(): for node_id, stack in analysis.owner_stack_by_node.items():
active_foreach_id = stack[-1] if stack else None
fields_by_node[node_id] = _available_fields( fields_by_node[node_id] = _available_fields(
workflow, workflow,
foreach_nodes, foreach_nodes,
analysis.owner_stack_by_node, analysis.owner_stack_by_node,
active_foreach_id, stack,
) )
return _ContextAnalysis(fields_by_node, tuple(warnings.values)) return _ContextAnalysis(fields_by_node, tuple(warnings.values))
@@ -140,30 +205,67 @@ def _available_fields(
workflow: Workflow, workflow: Workflow,
foreach_nodes: Mapping[str, ForeachNode], foreach_nodes: Mapping[str, ForeachNode],
owner_stack_by_node: Mapping[str, ForeachOwnerStack], owner_stack_by_node: Mapping[str, ForeachOwnerStack],
active_scope: FrameScope, stack: ForeachOwnerStack,
) -> tuple[ContextFieldAvailability, ...]: ) -> tuple[ContextFieldAvailability, ...]:
"""Return contracts for one static owner stack; all are guaranteed. """Return contracts for one static owner stack; all are guaranteed.
A single node use has one control region, so foreach fields are either A single node use has one control region, so foreach fields are either
present (inside a body) or absent (outside). Conditional availability is present (inside bodies) or absent (outside). Every owner in the stack
not used to represent multiple owner stacks: a node reached under two contributes one required ``foreach.<id>`` entry, every active configured
stacks is a region conflict and receives no foreach fields at all. alias, and ``loop_item``/``loop_index`` from the final owner only.
Conditional availability is not used to represent multiple owner stacks:
a node reached under two stacks is a region conflict and receives no
foreach fields at all.
""" """
contracts = list(STANDARD_CONTEXT_FIELDS) contracts = list(STANDARD_CONTEXT_FIELDS)
if active_scope is not None: if not stack:
foreach = foreach_nodes.get(active_scope) return tuple(
if foreach is not None: ContextFieldAvailability(
contracts.extend( contract=ContextFieldContract(
foreach_context_fields( contract.name,
foreach.as_, deepcopy(contract.schema),
_foreach_item_schema( contract.description,
workflow, ),
foreach, availability="available",
foreach_nodes,
owner_stack_by_node,
),
)
) )
for contract in contracts
)
entry_schemas: dict[str, ContextSchema] = {}
for owner_id in stack:
foreach = foreach_nodes.get(owner_id)
if foreach is None:
continue
entry_schemas[owner_id] = foreach_entry_schema(
owner_id,
_foreach_item_schema(
workflow,
foreach,
foreach_nodes,
owner_stack_by_node,
),
)
if entry_schemas:
contracts.append(structured_foreach_contract(entry_schemas))
# Aliases for every active owner, plus innermost loop keys.
for owner_id in stack:
foreach = foreach_nodes.get(owner_id)
if foreach is None:
continue
item_schema = _foreach_item_schema(
workflow, foreach, foreach_nodes, owner_stack_by_node
)
# Only the innermost owner contributes loop_item/loop_index; every
# owner contributes its configured alias when unambiguous. Alias
# collision handling is a validation concern; here we expose what the
# stack declares.
if owner_id == stack[-1]:
contracts.extend(foreach_context_fields(foreach.as_, item_schema))
elif foreach.as_:
# Outer aliases are exposed without re-emitting loop keys.
outer_fields = foreach_context_fields(foreach.as_, item_schema)
for field in outer_fields:
if field.name not in (LOOP_ITEM_CONTEXT_KEY, LOOP_INDEX_CONTEXT_KEY):
contracts.append(field)
return tuple( return tuple(
ContextFieldAvailability( ContextFieldAvailability(
contract=ContextFieldContract( contract=ContextFieldContract(
@@ -177,6 +279,70 @@ def _available_fields(
) )
def _context_schema_for_stack(
workflow: Workflow,
foreach_nodes: Mapping[str, ForeachNode],
owner_stack_by_node: Mapping[str, ForeachOwnerStack],
stack: ForeachOwnerStack,
) -> ContextSchema:
"""Build the complete graph-visible context object schema for one stack."""
properties: dict[str, ContextSchema] = {
field.name: deepcopy(field.schema) for field in STANDARD_CONTEXT_FIELDS
}
entry_schemas: dict[str, ContextSchema] = {}
for owner_id in stack:
foreach = foreach_nodes.get(owner_id)
if foreach is None:
continue
entry_schemas[owner_id] = foreach_entry_schema(
owner_id,
_foreach_item_schema(
workflow, foreach, foreach_nodes, owner_stack_by_node
),
)
properties[FOREACH_CONTEXT_KEY] = {
"type": "object",
"properties": entry_schemas,
"required": sorted(entry_schemas),
"additionalProperties": False,
}
required: list[str] = [
field.name for field in STANDARD_CONTEXT_FIELDS
] + [FOREACH_CONTEXT_KEY]
if stack:
innermost = foreach_nodes.get(stack[-1])
if innermost is not None:
inner_item = _foreach_item_schema(
workflow, innermost, foreach_nodes, owner_stack_by_node
)
properties[LOOP_ITEM_CONTEXT_KEY] = deepcopy(inner_item)
properties[LOOP_INDEX_CONTEXT_KEY] = {"type": "integer"}
required.extend([LOOP_ITEM_CONTEXT_KEY, LOOP_INDEX_CONTEXT_KEY])
for owner_id in stack:
foreach = foreach_nodes.get(owner_id)
if foreach is not None and foreach.as_:
properties[foreach.as_] = deepcopy(
_foreach_item_schema(
workflow, foreach, foreach_nodes, owner_stack_by_node
)
)
required.append(foreach.as_)
# Deduplicate while preserving order; aliases could theoretically repeat
# a standard name, but validation rejects those collisions.
seen: set[str] = set()
deduped_required: list[str] = []
for name in required:
if name not in seen:
seen.add(name)
deduped_required.append(name)
return {
"type": "object",
"properties": properties,
"required": sorted(deduped_required),
"additionalProperties": False,
}
def _foreach_item_schema( def _foreach_item_schema(
workflow: Workflow, workflow: Workflow,
foreach: ForeachNode, foreach: ForeachNode,
@@ -185,18 +351,17 @@ def _foreach_item_schema(
) -> ContextSchema: ) -> ContextSchema:
"""Resolve one controller's item schema in its own static context. """Resolve one controller's item schema in its own static context.
An inner foreach may declare ``over="context.outer_item"``; the lookup An inner foreach may declare ``over="context.foreach.outer.item.children"``;
uses the controller's own owner stack, not the inner body stack. the lookup uses the controller's own owner stack, not the inner body stack.
""" """
controller_stack = owner_stack_by_node.get(foreach.id) controller_stack = owner_stack_by_node.get(foreach.id)
if controller_stack is None: if controller_stack is None:
return {} return {}
controller_scope: FrameScope = controller_stack[-1] if controller_stack else None
source_schema = _schema_at_path( source_schema = _schema_at_path(
workflow, workflow,
foreach.over.root, foreach.over.root,
foreach.over.parts, foreach.over.parts,
controller_scope, controller_stack,
foreach_nodes, foreach_nodes,
owner_stack_by_node, owner_stack_by_node,
) )
@@ -228,7 +393,7 @@ def _schema_at_path(
workflow: Workflow, workflow: Workflow,
root: str, root: str,
parts: tuple[str, ...], parts: tuple[str, ...],
active_scope: FrameScope, stack: ForeachOwnerStack,
foreach_nodes: Mapping[str, ForeachNode], foreach_nodes: Mapping[str, ForeachNode],
owner_stack_by_node: Mapping[str, ForeachOwnerStack], owner_stack_by_node: Mapping[str, ForeachOwnerStack],
) -> Mapping[str, object] | None: ) -> Mapping[str, object] | None:
@@ -236,7 +401,7 @@ def _schema_at_path(
schema_document = _schema_document( schema_document = _schema_document(
workflow, workflow,
root, root,
active_scope=active_scope, stack=stack,
foreach_nodes=foreach_nodes, foreach_nodes=foreach_nodes,
owner_stack_by_node=owner_stack_by_node, owner_stack_by_node=owner_stack_by_node,
) )
@@ -260,6 +425,7 @@ def _schema_document(
workflow: Workflow, workflow: Workflow,
root: str, root: str,
*, *,
stack: ForeachOwnerStack | None = None,
active_scope: FrameScope = None, active_scope: FrameScope = None,
foreach_nodes: Mapping[str, ForeachNode] | None = None, foreach_nodes: Mapping[str, ForeachNode] | None = None,
owner_stack_by_node: Mapping[str, ForeachOwnerStack] | None = None, owner_stack_by_node: Mapping[str, ForeachOwnerStack] | None = None,
@@ -269,30 +435,54 @@ def _schema_document(
if root == "state": if root == "state":
return workflow.state_schema.model_dump(mode="json", exclude_none=True) return workflow.state_schema.model_dump(mode="json", exclude_none=True)
if root == "context": if root == "context":
# Prefer the full owner stack when available; fall back to the legacy
# single active scope for callers that have not migrated yet.
resolved_stack: ForeachOwnerStack = ()
if stack is not None:
resolved_stack = stack
elif active_scope is not None:
resolved_stack = (active_scope,)
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 (
active_scope is not None foreach_nodes is not None
and foreach_nodes is not None
and owner_stack_by_node is not None and owner_stack_by_node is not None
): ):
foreach = foreach_nodes.get(active_scope) entry_schemas: dict[str, object] = {}
if foreach is not None: for owner_id in resolved_stack:
current.update( foreach = foreach_nodes.get(owner_id)
{ if foreach is not None:
field.name: field.schema entry_schemas[owner_id] = foreach_entry_schema(
for field in foreach_context_fields( owner_id,
foreach.as_, _foreach_item_schema(
_foreach_item_schema( workflow,
workflow, foreach,
foreach, foreach_nodes,
foreach_nodes, owner_stack_by_node,
owner_stack_by_node, ),
), )
) current[FOREACH_CONTEXT_KEY] = {
} "type": "object",
) "properties": entry_schemas,
"required": sorted(entry_schemas),
"additionalProperties": False,
}
for owner_id in resolved_stack:
foreach = foreach_nodes.get(owner_id)
if foreach is not None:
item_schema = _foreach_item_schema(
workflow,
foreach,
foreach_nodes,
owner_stack_by_node,
)
for field in foreach_context_fields(foreach.as_, item_schema):
# Innermost loop keys win; outer aliases accumulate.
# Validation owns collision diagnostics.
is_innermost = bool(resolved_stack) and owner_id == resolved_stack[-1]
if field.name not in current or is_innermost:
current[field.name] = field.schema
return {"type": "object", "properties": current} return {"type": "object", "properties": current}
return {} return {}
+48
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -87,3 +88,50 @@ def foreach_context_fields(
) )
) )
return tuple(fields) return tuple(fields)
def foreach_entry_schema(node_id: str, item_schema: ContextSchema) -> ContextSchema:
"""Return the JSON schema for one structured ``foreach.<id>`` entry.
Identity fields disclose the dynamic activation/frame/scope/lineage when
advanced runtime code needs them; ``item`` carries the inferred
collection item schema.
"""
return {
"type": "object",
"properties": {
"node_id": {"const": node_id},
"activation_id": {"type": "string"},
"frame_id": {"type": "string"},
"scope_id": {"type": "string"},
"lineage_id": {"type": "string"},
"index": {"type": "integer"},
"item": deepcopy(item_schema),
},
"required": [
"node_id",
"activation_id",
"frame_id",
"scope_id",
"lineage_id",
"index",
"item",
],
"additionalProperties": False,
}
def structured_foreach_contract(
entry_schemas: Mapping[str, ContextSchema],
) -> ContextFieldContract:
"""Return the ``foreach`` map contract for one static owner stack."""
return ContextFieldContract(
FOREACH_CONTEXT_KEY,
{
"type": "object",
"properties": dict(entry_schemas),
"required": sorted(entry_schemas),
"additionalProperties": False,
},
"Structured foreach context by static node id",
)
+44 -2
View File
@@ -260,12 +260,54 @@ def test_nested_foreach_replaces_inner_scope_and_restores_outer_scope() -> None:
inner = _field_map(workflow, "inner_body") inner = _field_map(workflow, "inner_body")
after_inner = _field_map(workflow, "after_inner") after_inner = _field_map(workflow, "after_inner")
assert "outer_item" not in inner outer_item_schema = {"type": "string"}
assert inner["inner_item"].availability == "available" inner_item_schema = {"type": "integer"}
assert inner["outer_item"].schema == outer_item_schema
assert inner["inner_item"].schema == inner_item_schema
assert inner["loop_item"].schema == inner_item_schema
foreach_schema = inner["foreach"].schema
assert set(foreach_schema["properties"]) == {"outer", "inner"}
assert foreach_schema["properties"]["outer"]["properties"]["item"] == (
outer_item_schema
)
assert foreach_schema["properties"]["inner"]["properties"]["index"] == {
"type": "integer"
}
assert after_inner["outer_item"].availability == "available" assert after_inner["outer_item"].availability == "available"
assert "inner_item" not in after_inner assert "inner_item" not in after_inner
def test_inner_completion_schema_restores_outer_structured_entry() -> None:
workflow = _workflow(
start="outer",
nodes=[
_foreach("outer", alias="outer_item"),
_foreach("inner", alias="inner_item", over="state.inner_items"),
_node("inner_body"),
_node("after_inner"),
],
edges=[
{"from": "outer", "outcome": "loop", "to": "inner"},
{"from": "inner", "outcome": "loop", "to": "inner_body"},
{"from": "inner", "outcome": "done", "to": "after_inner"},
{"from": "inner_body", "outcome": "ok", "to": "inner"},
{"from": "after_inner", "outcome": "ok", "to": "outer"},
{"from": "outer", "outcome": "done", "to": END},
],
state_schema={
"type": "object",
"properties": {
"items": {"type": "array", "items": {"type": "string"}},
"inner_items": {"type": "array", "items": {"type": "integer"}},
},
},
)
after_inner = _field_map(workflow, "after_inner")
foreach_schema = after_inner["foreach"].schema
assert set(foreach_schema["properties"]) == {"outer"}
def test_nested_foreach_preserves_context_backed_item_schema() -> None: def test_nested_foreach_preserves_context_backed_item_schema() -> None:
workflow = _workflow( workflow = _workflow(
start="outer", start="outer",
+65
View File
@@ -441,3 +441,68 @@ def test_project_inventory_does_not_offer_context_for_workflow_output() -> None:
assert inventory["readable_sources"][0]["path"] == "context.item" assert inventory["readable_sources"][0]["path"] == "context.item"
assert inventory["readable_sources"][0]["uses"] == ["step_input"] assert inventory["readable_sources"][0]["uses"] == ["step_input"]
def test_structured_foreach_paths_appear_in_authoring_inventory() -> None:
from wf_api.authoring_contracts import context_path_options_for_node
from wf_core import END, Edge, ForeachNode, NodeUse, SchemaRef, Workflow
from wf_core.models.schemas import StateSchema
def _foreach(node_id: str, *, over: str, alias: str) -> ForeachNode:
return ForeachNode.model_validate(
{"id": node_id, "type": "foreach", "over": over, "as": alias}
)
workflow = Workflow(
name="inventory_structured",
input_schema=SchemaRef(type="object"),
state_schema=StateSchema.model_validate(
{
"type": "object",
"properties": {
"customers": {
"type": "array",
"items": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
},
"orders": {
"type": "array",
"items": {
"type": "object",
"properties": {"sku": {"type": "string"}},
},
},
},
}
),
output_schema=SchemaRef(type="object"),
start="customers",
nodes=[
_foreach("customers", over="state.customers", alias="customer"),
_foreach("orders", over="state.orders", alias="order"),
NodeUse(id="inner_body", type="node", node="noop"),
],
edges=[
Edge.model_validate(
{"from": "customers", "outcome": "loop", "to": "orders"}
),
Edge.model_validate(
{"from": "orders", "outcome": "loop", "to": "inner_body"}
),
Edge.model_validate({"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}),
],
)
options = context_path_options_for_node(workflow, "inner_body")
paths = {option["path"] for option in options}
assert "context.foreach.customers.item" in paths
assert "context.foreach.customers.index" in paths
assert "context.foreach.orders.item" in paths
assert "context.foreach.orders.index" in paths
for option in options:
if option["path"].startswith("context.foreach."):
assert option["origin"] == "runtime_context"
assert option["uses"] == ["step_input"]