subgraph model & validation
This commit is contained in:
@@ -45,11 +45,12 @@ implementation state.
|
|||||||
[ADR 0002](./adr/0002-concurrent-foreach-policy-and-barrier-commits.md).
|
[ADR 0002](./adr/0002-concurrent-foreach-policy-and-barrier-commits.md).
|
||||||
- Native subgraph design spec:
|
- Native subgraph design spec:
|
||||||
[2026-05-24 native subgraphs](./superpowers/specs/2026-05-24-native-subgraphs-design.md).
|
[2026-05-24 native subgraphs](./superpowers/specs/2026-05-24-native-subgraphs-design.md).
|
||||||
- **Native subgraphs / graph-as-node**: next major runtime feature. Add a
|
- **Native subgraphs / graph-as-node**: next major runtime feature. A
|
||||||
first-class subgraph step with child run/frame identity, child trace
|
first-class `SubgraphNode` placeholder exists and validates parent-side
|
||||||
preservation, interrupt bubbling, and resume back into the child workflow.
|
bindings/outcomes, but runtime execution still needs child run/frame identity,
|
||||||
Wrapper helpers currently run child workflows as ordinary nodes; true
|
child trace preservation, interrupt bubbling, and resume back into the child
|
||||||
graph-as-node behavior belongs here.
|
workflow. Wrapper helpers currently run child workflows as ordinary nodes;
|
||||||
|
true graph-as-node behavior belongs here.
|
||||||
- **Concurrent foreach**: implemented in core with explicit scheduling,
|
- **Concurrent foreach**: implemented in core with explicit scheduling,
|
||||||
reducer/merge semantics, item error policy, async handler batching, and
|
reducer/merge semantics, item error policy, async handler batching, and
|
||||||
quiescent interrupt behavior. Remaining work is polish and future reuse of
|
quiescent interrupt behavior. Remaining work is polish and future reuse of
|
||||||
|
|||||||
@@ -56,11 +56,19 @@ class SubgraphNode(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
type: Literal["subgraph"]
|
type: Literal["subgraph"]
|
||||||
workflow: WorkflowRef
|
workflow: WorkflowRef
|
||||||
|
input_schema: SchemaRef
|
||||||
|
output_schema: SchemaRef
|
||||||
input: list[InputBinding] = Field(default_factory=list)
|
input: list[InputBinding] = Field(default_factory=list)
|
||||||
output: list[OutputBinding] = Field(default_factory=list)
|
output: list[OutputBinding] = Field(default_factory=list)
|
||||||
outcomes: list[str] = Field(default_factory=lambda: ["ok"])
|
outcomes: list[str] = Field(default_factory=lambda: ["ok"])
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Current implementation status: `wf_core` has a first placeholder
|
||||||
|
`SubgraphNode`, but `workflow` is still a plain string reference. The placeholder
|
||||||
|
also carries `input_schema` and `output_schema` so validation can check parent
|
||||||
|
bindings before native execution exists. Runtime execution intentionally raises
|
||||||
|
until a later slice adds child scope/frame execution.
|
||||||
|
|
||||||
`WorkflowRef` should be structural, not a dotted string parser:
|
`WorkflowRef` should be structural, not a dotted string parser:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -132,9 +132,11 @@ limits and intended adapter seam.
|
|||||||
external subscriptions or notification streams need a separate lifecycle
|
external subscriptions or notification streams need a separate lifecycle
|
||||||
design. Interrupt `request` and `resume` are canonical binding lists; nested
|
design. Interrupt `request` and `resume` are canonical binding lists; nested
|
||||||
child-workflow resume is still future work.
|
child-workflow resume is still future work.
|
||||||
- Native subgraphs are not part of `wf_core` yet. The core `Step` model only
|
- Native subgraphs have a core model placeholder, `SubgraphNode`, but runtime
|
||||||
includes node, condition, foreach, join, and interrupt steps; `Workflow` does
|
execution is not implemented yet. The placeholder carries a child workflow
|
||||||
not contain nested workflow/subgraph steps.
|
reference, declared input/output schemas, binding lists, and declared
|
||||||
|
outcomes so parent graph structure can validate before execution support
|
||||||
|
lands.
|
||||||
- Nested subgraph interruption is not first-class yet. The current
|
- Nested subgraph interruption is not first-class yet. The current
|
||||||
`wf_authoring` subgraph helpers wrap a child workflow as an ordinary sync or
|
`wf_authoring` subgraph helpers wrap a child workflow as an ordinary sync or
|
||||||
async node and validate the child output; they do not preserve a child run
|
async node and validate the child output; they do not preserve a child run
|
||||||
@@ -145,10 +147,11 @@ limits and intended adapter seam.
|
|||||||
upgrade: nested run state, child-frame trace preservation, interrupt bubbling
|
upgrade: nested run state, child-frame trace preservation, interrupt bubbling
|
||||||
with path metadata, and resume back into the child workflow.
|
with path metadata, and resume back into the child workflow.
|
||||||
- Frames are no longer only a serial execution stack: the runtime has a ready
|
- Frames are no longer only a serial execution stack: the runtime has a ready
|
||||||
queue and `BLOCKED` frame state. Concurrent foreach and native subgraphs still
|
queue, `BLOCKED` frame state, lineage isolation, barrier merge semantics, and
|
||||||
need more work: lineage isolation, barrier merge semantics, pending child
|
pending child results for concurrent foreach. Native subgraphs still need
|
||||||
results, and explicit child workflow/deployment identity. Concurrent foreach
|
explicit child workflow/deployment identity and child-scope execution.
|
||||||
is the primary current use case for async concurrent node handler execution.
|
Concurrent foreach is the primary current use case for async concurrent node
|
||||||
|
handler execution.
|
||||||
- Runtime errors are still ordinary exceptions plus failed run status. A richer
|
- Runtime errors are still ordinary exceptions plus failed run status. A richer
|
||||||
error payload can be added later, but should be designed as part of trace/run
|
error payload can be added later, but should be designed as part of trace/run
|
||||||
state rather than scattered exceptions.
|
state rather than scattered exceptions.
|
||||||
|
|||||||
@@ -635,10 +635,12 @@ must not be parsed as a generic `CapabilityRef`.
|
|||||||
The first implementation should prefer artifact validation and dependency
|
The first implementation should prefer artifact validation and dependency
|
||||||
diagnostics before attempting persistent nested resume.
|
diagnostics before attempting persistent nested resume.
|
||||||
|
|
||||||
Native subgraphs are not in `wf_core` yet. The current core `Step` model has
|
Native subgraphs now have a core `SubgraphNode` placeholder. It validates the
|
||||||
node, condition, foreach, join, and interrupt steps, but no subgraph step. The
|
parent-side contract: child workflow reference, declared child input/output
|
||||||
current `wf_authoring.subgraph_node` and `async_subgraph_node` helpers execute
|
schemas, binding lists, and declared outcomes. Runtime execution is still not
|
||||||
a child workflow as a plain node and validate the child output. The async helper
|
implemented; reaching a subgraph step raises a clear runtime error. The current
|
||||||
|
`wf_authoring.subgraph_node` and `async_subgraph_node` helpers still execute a
|
||||||
|
child workflow as a plain node and validate the child output. The async helper
|
||||||
is explicit because hiding `asyncio.run()` inside the sync wrapper would break
|
is explicit because hiding `asyncio.run()` inside the sync wrapper would break
|
||||||
inside already-running event loops. Future saved-workflow-as-node execution
|
inside already-running event loops. Future saved-workflow-as-node execution
|
||||||
needs a real child run state if child interrupts should pause the parent and
|
needs a real child run state if child interrupts should pause the parent and
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .models import (
|
|||||||
SiblingWritePolicy,
|
SiblingWritePolicy,
|
||||||
StateField,
|
StateField,
|
||||||
StateSchema,
|
StateSchema,
|
||||||
|
SubgraphNode,
|
||||||
Workflow,
|
Workflow,
|
||||||
)
|
)
|
||||||
from .runtime import (
|
from .runtime import (
|
||||||
@@ -64,6 +65,7 @@ __all__ = [
|
|||||||
"SiblingWritePolicy",
|
"SiblingWritePolicy",
|
||||||
"StateField",
|
"StateField",
|
||||||
"StateSchema",
|
"StateSchema",
|
||||||
|
"SubgraphNode",
|
||||||
"AsyncNodeHandler",
|
"AsyncNodeHandler",
|
||||||
"NodeHandler",
|
"NodeHandler",
|
||||||
"ExecutionFrame",
|
"ExecutionFrame",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from wf_core.models.steps import (
|
|||||||
JoinNode,
|
JoinNode,
|
||||||
NodeUse,
|
NodeUse,
|
||||||
Step,
|
Step,
|
||||||
|
SubgraphNode,
|
||||||
)
|
)
|
||||||
from wf_core.models.workflow import Edge, Workflow
|
from wf_core.models.workflow import Edge, Workflow
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ __all__ = [
|
|||||||
"StateField",
|
"StateField",
|
||||||
"StateSchema",
|
"StateSchema",
|
||||||
"Step",
|
"Step",
|
||||||
|
"SubgraphNode",
|
||||||
"VariadicCondition",
|
"VariadicCondition",
|
||||||
"Workflow",
|
"Workflow",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Annotated, Literal, Self
|
|||||||
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.schemas import SchemaRef
|
||||||
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +156,48 @@ class NodeUse(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class SubgraphNode(BaseModel):
|
||||||
|
"""Workflow boundary step reserved for native subgraph execution.
|
||||||
|
|
||||||
|
This is a contract-bearing placeholder, not the implementation of nested
|
||||||
|
workflow execution yet. The core can validate the parent graph's bindings
|
||||||
|
and declared outcomes now; a later runtime slice will resolve ``workflow``
|
||||||
|
into a child graph, create a child scope/lineage, and commit its result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
type: Literal["subgraph"]
|
||||||
|
workflow: str = Field(
|
||||||
|
description=(
|
||||||
|
"Reference to the child workflow artifact or registry key. The core "
|
||||||
|
"does not resolve this reference until native subgraph runtime "
|
||||||
|
"execution is implemented."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
desc: str | None = None
|
||||||
|
input_schema: SchemaRef = Field(
|
||||||
|
default_factory=lambda: SchemaRef(type="object"),
|
||||||
|
description="Declared child workflow input contract used to validate input bindings.",
|
||||||
|
)
|
||||||
|
output_schema: SchemaRef = Field(
|
||||||
|
default_factory=lambda: SchemaRef(type="object"),
|
||||||
|
description="Declared child workflow output contract used to validate output bindings.",
|
||||||
|
)
|
||||||
|
input: list[InputBinding] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Bindings that build the child workflow input payload.",
|
||||||
|
)
|
||||||
|
output: list[OutputBinding] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Bindings that commit child workflow output into parent state.",
|
||||||
|
)
|
||||||
|
outcomes: list[str] = Field(
|
||||||
|
default_factory=lambda: ["ok"],
|
||||||
|
min_length=1,
|
||||||
|
description="Outcomes the parent graph may wire from this subgraph boundary.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConditionNode(BaseModel):
|
class ConditionNode(BaseModel):
|
||||||
"""Control-flow step that routes through `true` or `false` outcomes."""
|
"""Control-flow step that routes through `true` or `false` outcomes."""
|
||||||
|
|
||||||
@@ -329,7 +372,7 @@ class InterruptNode(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
Step = Annotated[
|
Step = Annotated[
|
||||||
NodeUse | ConditionNode | ForeachNode | JoinNode | InterruptNode,
|
NodeUse | SubgraphNode | ConditionNode | ForeachNode | JoinNode | InterruptNode,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
"""Discriminated union of all executable workflow graph steps."""
|
"""Discriminated union of all executable workflow graph steps."""
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ def lineage_writes_for_frame(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Compatibility fallback: concurrent foreach used barrier-local patches
|
||||||
|
# before `RunState.lineages` became the primary write store. Keep reading
|
||||||
|
# those patches so old serialized runs and direct barrier tests still work.
|
||||||
owner = item_frame_owner(frame)
|
owner = item_frame_owner(frame)
|
||||||
if owner is None:
|
if owner is None:
|
||||||
return ()
|
return ()
|
||||||
@@ -127,7 +130,11 @@ def lineage_patch(
|
|||||||
scope_id: str,
|
scope_id: str,
|
||||||
lineage_id: str,
|
lineage_id: str,
|
||||||
) -> StatePatch:
|
) -> StatePatch:
|
||||||
"""Return a replayable patch for one lineage's pending writes."""
|
"""Return a replayable patch for one lineage's pending writes.
|
||||||
|
|
||||||
|
Barrier/gather code should consume this instead of reconstructing a patch
|
||||||
|
from visible state. Incoming values are the replay source of truth.
|
||||||
|
"""
|
||||||
lineage = _lineage(run, scope_id=scope_id, lineage_id=lineage_id)
|
lineage = _lineage(run, scope_id=scope_id, lineage_id=lineage_id)
|
||||||
return StatePatch(writes=list(lineage.writes))
|
return StatePatch(writes=list(lineage.writes))
|
||||||
|
|
||||||
|
|||||||
@@ -340,6 +340,8 @@ def _finish_concurrent_foreach(
|
|||||||
raise WorkflowExecutionError(
|
raise WorkflowExecutionError(
|
||||||
"collect item error policy requires collect_to"
|
"collect item error policy requires collect_to"
|
||||||
)
|
)
|
||||||
|
# Collect-error records are generated by the barrier itself, not by an
|
||||||
|
# item lineage, so they still enter as a compatibility `changes` patch.
|
||||||
item_patches.append(StatePatch(changes={str(collect_to): error_records}))
|
item_patches.append(StatePatch(changes={str(collect_to): error_records}))
|
||||||
combined = build_barrier_patch(
|
combined = build_barrier_patch(
|
||||||
workflow,
|
workflow,
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ def _finalize_node_execution(
|
|||||||
if is_root_lineage_frame(frame):
|
if is_root_lineage_frame(frame):
|
||||||
state_changes = commit_state_patch(run.state, patch)
|
state_changes = commit_state_patch(run.state, patch)
|
||||||
else:
|
else:
|
||||||
|
# Non-root frames are future subgraph/fork branch execution: writes
|
||||||
|
# become lineage-local until an explicit boundary/barrier commits.
|
||||||
append_lineage_writes(
|
append_lineage_writes(
|
||||||
run,
|
run,
|
||||||
scope_id=frame.scope_id,
|
scope_id=frame.scope_id,
|
||||||
@@ -136,6 +138,8 @@ def _finalize_node_execution(
|
|||||||
parent_frame = run.frames[parent_frame_id]
|
parent_frame = run.frames[parent_frame_id]
|
||||||
barrier = ForeachBarrierState.from_frame(parent_frame, foreach_node_id)
|
barrier = ForeachBarrierState.from_frame(parent_frame, foreach_node_id)
|
||||||
if barrier is not None and barrier.mode == "concurrent":
|
if barrier is not None and barrier.mode == "concurrent":
|
||||||
|
# New concurrent foreach stores writes in the child lineage; the
|
||||||
|
# barrier keeps only result metadata plus old patch fallback.
|
||||||
append_lineage_writes(
|
append_lineage_writes(
|
||||||
run,
|
run,
|
||||||
scope_id=frame.scope_id,
|
scope_id=frame.scope_id,
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ class StatePatch:
|
|||||||
_staged_state: dict[str, Any] = dataclass_field(default_factory=dict, repr=False)
|
_staged_state: dict[str, Any] = dataclass_field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Keep legacy `StatePatch(changes=...)` usable during migration."""
|
"""Keep legacy `StatePatch(changes=...)` usable during migration.
|
||||||
|
|
||||||
|
New runtime code should prefer ordered `writes`. `changes` stays as the
|
||||||
|
public trace-facing view and as parse compatibility for old barrier
|
||||||
|
metadata/tests that predate `StateWrite`.
|
||||||
|
"""
|
||||||
if not self.changes and self.writes:
|
if not self.changes and self.writes:
|
||||||
self.changes = {
|
self.changes = {
|
||||||
str(write.path): write.incoming_value for write in self.writes
|
str(write.path): write.incoming_value for write in self.writes
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from wf_core.models.steps import (
|
|||||||
InterruptNode,
|
InterruptNode,
|
||||||
JoinNode,
|
JoinNode,
|
||||||
NodeUse,
|
NodeUse,
|
||||||
|
SubgraphNode,
|
||||||
)
|
)
|
||||||
from wf_core.models.workflow import Workflow
|
from wf_core.models.workflow import Workflow
|
||||||
from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace
|
from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace
|
||||||
@@ -115,6 +116,11 @@ def step_workflow(
|
|||||||
return handle_interrupt_step(run, step)
|
return handle_interrupt_step(run, step)
|
||||||
elif isinstance(step, ForeachNode):
|
elif isinstance(step, ForeachNode):
|
||||||
return step_foreach(workflow, run, step, index, reducers=reducers)
|
return step_foreach(workflow, run, step, index, reducers=reducers)
|
||||||
|
elif isinstance(step, SubgraphNode):
|
||||||
|
raise WorkflowExecutionError(
|
||||||
|
f"subgraph step {step.id!r} references {step.workflow!r}, "
|
||||||
|
"but native subgraph execution is not implemented yet"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise WorkflowExecutionError(
|
raise WorkflowExecutionError(
|
||||||
f"unsupported step type {getattr(step, 'type', type(step).__name__)!r}"
|
f"unsupported step type {getattr(step, 'type', type(step).__name__)!r}"
|
||||||
@@ -209,6 +215,11 @@ async def step_workflow_async(
|
|||||||
return handle_interrupt_step(run, step)
|
return handle_interrupt_step(run, step)
|
||||||
elif isinstance(step, ForeachNode):
|
elif isinstance(step, ForeachNode):
|
||||||
return step_foreach(workflow, run, step, index, reducers=reducers)
|
return step_foreach(workflow, run, step, index, reducers=reducers)
|
||||||
|
elif isinstance(step, SubgraphNode):
|
||||||
|
raise WorkflowExecutionError(
|
||||||
|
f"subgraph step {step.id!r} references {step.workflow!r}, "
|
||||||
|
"but native subgraph execution is not implemented yet"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise WorkflowExecutionError(
|
raise WorkflowExecutionError(
|
||||||
f"unsupported step type {getattr(step, 'type', type(step).__name__)!r}"
|
f"unsupported step type {getattr(step, 'type', type(step).__name__)!r}"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from wf_core.models.steps import (
|
|||||||
InterruptNode,
|
InterruptNode,
|
||||||
NodeUse,
|
NodeUse,
|
||||||
Step,
|
Step,
|
||||||
|
SubgraphNode,
|
||||||
)
|
)
|
||||||
from wf_core.models.schemas import NodeDef
|
from wf_core.models.schemas import NodeDef
|
||||||
from wf_core.models.workflow import Edge, Workflow
|
from wf_core.models.workflow import Edge, Workflow
|
||||||
@@ -17,6 +18,7 @@ from wf_core.validation.steps import (
|
|||||||
validate_foreach_node,
|
validate_foreach_node,
|
||||||
validate_interrupt_node,
|
validate_interrupt_node,
|
||||||
validate_node_use,
|
validate_node_use,
|
||||||
|
validate_subgraph_node,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +71,8 @@ def _validate_nodes(
|
|||||||
|
|
||||||
if isinstance(node, NodeUse):
|
if isinstance(node, NodeUse):
|
||||||
validate_node_use(node, index, node_defs, workflow, report)
|
validate_node_use(node, index, node_defs, workflow, report)
|
||||||
|
elif isinstance(node, SubgraphNode):
|
||||||
|
validate_subgraph_node(node, index, workflow, report)
|
||||||
elif isinstance(node, ConditionNode):
|
elif isinstance(node, ConditionNode):
|
||||||
validate_condition_node(
|
validate_condition_node(
|
||||||
node, index, report, state_root_fields, input_root_fields
|
node, index, report, state_root_fields, input_root_fields
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from wf_core.models.schemas import NodeDef
|
from wf_core.models.schemas import NodeDef
|
||||||
from wf_core.models.steps import InterruptNode, NodeUse, Step
|
from wf_core.models.steps import InterruptNode, NodeUse, Step, SubgraphNode
|
||||||
from wf_core.models.workflow import Edge
|
from wf_core.models.workflow import Edge
|
||||||
from wf_core.tokens import END
|
from wf_core.tokens import END
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ def declared_outcomes_for_step(step: Step, node_defs: dict[str, NodeDef]) -> set
|
|||||||
if isinstance(step, NodeUse):
|
if isinstance(step, NodeUse):
|
||||||
node_def = node_defs.get(step.node)
|
node_def = node_defs.get(step.node)
|
||||||
return set(node_def.outcomes) if node_def else set()
|
return set(node_def.outcomes) if node_def else set()
|
||||||
|
if isinstance(step, SubgraphNode):
|
||||||
|
return set(step.outcomes)
|
||||||
if step.type == "condition":
|
if step.type == "condition":
|
||||||
return {"true", "false"}
|
return {"true", "false"}
|
||||||
if step.type == "foreach":
|
if step.type == "foreach":
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ from wf_core.models.schemas import NodeDef
|
|||||||
from wf_core.models.steps import (
|
from wf_core.models.steps import (
|
||||||
ConditionNode,
|
ConditionNode,
|
||||||
ForeachNode,
|
ForeachNode,
|
||||||
|
InputBinding,
|
||||||
InputPathBinding,
|
InputPathBinding,
|
||||||
InterruptNode,
|
InterruptNode,
|
||||||
NodeUse,
|
NodeUse,
|
||||||
|
OutputBinding,
|
||||||
|
SubgraphNode,
|
||||||
)
|
)
|
||||||
from wf_core.models.workflow import Workflow
|
from wf_core.models.workflow import Workflow
|
||||||
from wf_core.paths import (
|
from wf_core.paths import (
|
||||||
@@ -45,21 +48,65 @@ def validate_node_use(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
input_fields = set(node_def.input_schema.properties)
|
_validate_boundary_bindings(
|
||||||
output_fields = set(node_def.output_schema.properties)
|
input_bindings=node.input,
|
||||||
state_root_fields = workflow.state_schema.root_fields()
|
output_bindings=node.output,
|
||||||
input_root_fields = set(workflow.input_schema.properties)
|
input_fields=set(node_def.input_schema.properties),
|
||||||
|
output_fields=set(node_def.output_schema.properties),
|
||||||
|
state_root_fields=workflow.state_schema.root_fields(),
|
||||||
|
input_root_fields=set(workflow.input_schema.properties),
|
||||||
|
report=report,
|
||||||
|
path_prefix=f"nodes[{index}]",
|
||||||
|
input_error_code=ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||||
|
output_error_code=ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_subgraph_node(
|
||||||
|
node: SubgraphNode,
|
||||||
|
index: int,
|
||||||
|
workflow: Workflow,
|
||||||
|
report: ValidationReport,
|
||||||
|
) -> None:
|
||||||
|
"""Validate a subgraph boundary contract before runtime support exists."""
|
||||||
|
_validate_boundary_bindings(
|
||||||
|
input_bindings=node.input,
|
||||||
|
output_bindings=node.output,
|
||||||
|
input_fields=set(node.input_schema.properties),
|
||||||
|
output_fields=set(node.output_schema.properties),
|
||||||
|
state_root_fields=workflow.state_schema.root_fields(),
|
||||||
|
input_root_fields=set(workflow.input_schema.properties),
|
||||||
|
report=report,
|
||||||
|
path_prefix=f"nodes[{index}]",
|
||||||
|
input_error_code=ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||||
|
output_error_code=ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_boundary_bindings(
|
||||||
|
*,
|
||||||
|
input_bindings: list[InputBinding],
|
||||||
|
output_bindings: list[OutputBinding],
|
||||||
|
input_fields: set[str],
|
||||||
|
output_fields: set[str],
|
||||||
|
state_root_fields: set[str],
|
||||||
|
input_root_fields: set[str],
|
||||||
|
report: ValidationReport,
|
||||||
|
path_prefix: str,
|
||||||
|
input_error_code: ValidationIssueCode,
|
||||||
|
output_error_code: ValidationIssueCode,
|
||||||
|
) -> None:
|
||||||
|
"""Validate bindings for node-like boundaries with declared I/O schemas."""
|
||||||
input_targets = []
|
input_targets = []
|
||||||
for input_index, binding in enumerate(node.input):
|
for input_index, binding in enumerate(input_bindings):
|
||||||
input_targets.append(binding.target)
|
input_targets.append(binding.target)
|
||||||
destination_root = _local_root(binding.target)
|
destination_root = _local_root(binding.target)
|
||||||
if destination_root is None or (
|
if destination_root is None or (
|
||||||
destination_root != "." and destination_root not in input_fields
|
destination_root != "." and destination_root not in input_fields
|
||||||
):
|
):
|
||||||
report.add(
|
report.add(
|
||||||
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
input_error_code,
|
||||||
f"nodes[{index}].input[{input_index}].target",
|
f"{path_prefix}.input[{input_index}].target",
|
||||||
f"destination field {str(binding.target)!r} is not declared in node input schema",
|
f"destination field {str(binding.target)!r} is not declared in node input schema",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,40 +115,40 @@ def validate_node_use(
|
|||||||
):
|
):
|
||||||
report.add(
|
report.add(
|
||||||
ValidationIssueCode.INVALID_SOURCE_PATH,
|
ValidationIssueCode.INVALID_SOURCE_PATH,
|
||||||
f"nodes[{index}].input[{input_index}].path",
|
f"{path_prefix}.input[{input_index}].path",
|
||||||
"source path must start with input., state., or context. and reference a declared root field when applicable",
|
"source path must start with input., state., or context. and reference a declared root field when applicable",
|
||||||
)
|
)
|
||||||
|
|
||||||
if has_overlapping_paths(input_targets):
|
if has_overlapping_paths(input_targets):
|
||||||
report.add(
|
report.add(
|
||||||
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
input_error_code,
|
||||||
f"nodes[{index}].input",
|
f"{path_prefix}.input",
|
||||||
"input has overlapping node-local input paths",
|
"input has overlapping node-local input paths",
|
||||||
)
|
)
|
||||||
|
|
||||||
output_targets = []
|
output_targets = []
|
||||||
for output_index, binding in enumerate(node.output):
|
for output_index, binding in enumerate(output_bindings):
|
||||||
output_targets.append(str(binding.target))
|
output_targets.append(str(binding.target))
|
||||||
source_root = _local_root(binding.source)
|
source_root = _local_root(binding.source)
|
||||||
if source_root is None or (
|
if source_root is None or (
|
||||||
source_root != "." and source_root not in output_fields
|
source_root != "." and source_root not in output_fields
|
||||||
):
|
):
|
||||||
report.add(
|
report.add(
|
||||||
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
output_error_code,
|
||||||
f"nodes[{index}].output[{output_index}].source",
|
f"{path_prefix}.output[{output_index}].source",
|
||||||
f"source field {str(binding.source)!r} is not declared in node output schema",
|
f"source field {str(binding.source)!r} is not declared in node output schema",
|
||||||
)
|
)
|
||||||
destination_root = _state_destination_root(binding.target)
|
destination_root = _state_destination_root(binding.target)
|
||||||
if destination_root is None or destination_root not in state_root_fields:
|
if destination_root is None or destination_root not in state_root_fields:
|
||||||
report.add(
|
report.add(
|
||||||
ValidationIssueCode.INVALID_DESTINATION_PATH,
|
ValidationIssueCode.INVALID_DESTINATION_PATH,
|
||||||
f"nodes[{index}].output[{output_index}].target",
|
f"{path_prefix}.output[{output_index}].target",
|
||||||
"destination path must start with state. and reference a declared root field",
|
"destination path must start with state. and reference a declared root field",
|
||||||
)
|
)
|
||||||
if has_overlapping_paths(output_targets):
|
if has_overlapping_paths(output_targets):
|
||||||
report.add(
|
report.add(
|
||||||
ValidationIssueCode.INVALID_DESTINATION_PATH,
|
ValidationIssueCode.INVALID_DESTINATION_PATH,
|
||||||
f"nodes[{index}].output",
|
f"{path_prefix}.output",
|
||||||
"output has overlapping state destination paths",
|
"output has overlapping state destination paths",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wf_core import (
|
||||||
|
END,
|
||||||
|
Edge,
|
||||||
|
SchemaRef,
|
||||||
|
StateField,
|
||||||
|
StateSchema,
|
||||||
|
SubgraphNode,
|
||||||
|
Workflow,
|
||||||
|
WorkflowExecutionError,
|
||||||
|
execute_workflow,
|
||||||
|
)
|
||||||
|
from wf_core.validation.issues import ValidationIssueCode
|
||||||
|
|
||||||
|
|
||||||
|
def test_subgraph_step_validates_boundary_bindings_and_outcomes() -> None:
|
||||||
|
workflow = _workflow()
|
||||||
|
|
||||||
|
report = workflow.validate_structure()
|
||||||
|
|
||||||
|
assert report.errors == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_subgraph_step_rejects_undeclared_input_target() -> None:
|
||||||
|
workflow = _workflow(
|
||||||
|
node=SubgraphNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "child",
|
||||||
|
"type": "subgraph",
|
||||||
|
"workflow": "child.workflow",
|
||||||
|
"input_schema": _schema({"text": {"type": "string"}}),
|
||||||
|
"output_schema": _schema({"answer": {"type": "string"}}),
|
||||||
|
"input": [{"target": "missing", "path": "input.text"}],
|
||||||
|
"output": [{"source": "answer", "target": "state.answer"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
report = workflow.validate_structure()
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
issue.code == ValidationIssueCode.INVALID_NODE_INPUT_FIELD
|
||||||
|
and issue.path == "nodes[0].input[0].target"
|
||||||
|
for issue in report.errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_subgraph_step_rejects_unwired_declared_outcome() -> None:
|
||||||
|
workflow = _workflow(
|
||||||
|
node=SubgraphNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "child",
|
||||||
|
"type": "subgraph",
|
||||||
|
"workflow": "child.workflow",
|
||||||
|
"input_schema": _schema({"text": {"type": "string"}}),
|
||||||
|
"output_schema": _schema({"answer": {"type": "string"}}),
|
||||||
|
"outcomes": ["ok", "failed"],
|
||||||
|
"input": [{"target": "text", "path": "input.text"}],
|
||||||
|
"output": [{"source": "answer", "target": "state.answer"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
report = workflow.validate_structure()
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
issue.code == ValidationIssueCode.MISSING_OUTCOME_EDGE
|
||||||
|
and "failed" in issue.message
|
||||||
|
for issue in report.errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_subgraph_step_runtime_fails_explicitly_until_native_execution_exists() -> None:
|
||||||
|
workflow = _workflow()
|
||||||
|
|
||||||
|
with pytest.raises(WorkflowExecutionError, match="native subgraph execution"):
|
||||||
|
execute_workflow(workflow, {"text": "hello"}, {})
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow(*, node: SubgraphNode | None = None) -> Workflow:
|
||||||
|
subgraph = node or SubgraphNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "child",
|
||||||
|
"type": "subgraph",
|
||||||
|
"workflow": "child.workflow",
|
||||||
|
"input_schema": _schema({"text": {"type": "string"}}),
|
||||||
|
"output_schema": _schema({"answer": {"type": "string"}}),
|
||||||
|
"input": [{"target": "text", "path": "input.text"}],
|
||||||
|
"output": [{"source": "answer", "target": "state.answer"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Workflow(
|
||||||
|
name="subgraph_parent",
|
||||||
|
input_schema=_schema({"text": {"type": "string"}}),
|
||||||
|
state_schema=StateSchema.from_field_map({"answer": StateField(type="string")}),
|
||||||
|
output_schema=_schema({}),
|
||||||
|
start="child",
|
||||||
|
nodes=[subgraph],
|
||||||
|
edges=[Edge.model_validate({"from": "child", "outcome": "ok", "to": END})],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schema(properties: dict[str, object]) -> SchemaRef:
|
||||||
|
return SchemaRef.model_validate({"type": "object", "properties": properties})
|
||||||
Reference in New Issue
Block a user