end node is real
This commit is contained in:
@@ -47,10 +47,12 @@ implementation state.
|
|||||||
[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. A
|
- **Native subgraphs / graph-as-node**: next major runtime feature. A
|
||||||
first-class `SubgraphNode` placeholder exists and validates parent-side
|
first-class `SubgraphNode` placeholder exists and validates parent-side
|
||||||
bindings/outcomes, but runtime execution still needs child run/frame identity,
|
bindings/outcomes. Core workflows now also declare workflow-level outcomes
|
||||||
child trace preservation, interrupt bubbling, and resume back into the child
|
and can terminate through explicit `EndNode` steps. Runtime subgraph execution
|
||||||
workflow. Wrapper helpers currently run child workflows as ordinary nodes;
|
still needs child run/frame identity, child trace preservation, interrupt
|
||||||
true graph-as-node behavior belongs here.
|
bubbling, and resume back into the child 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
|
||||||
|
|||||||
@@ -261,13 +261,13 @@ When the child workflow completes:
|
|||||||
2. Apply the subgraph step `output` bindings from child output into parent
|
2. Apply the subgraph step `output` bindings from child output into parent
|
||||||
state.
|
state.
|
||||||
3. Record a parent `subgraph` trace entry with committed parent state changes.
|
3. Record a parent `subgraph` trace entry with committed parent state changes.
|
||||||
4. Advance the parent subgraph frame through outcome `ok`.
|
4. Advance the parent subgraph frame through the child workflow outcome.
|
||||||
|
|
||||||
For v1, a child workflow completion maps to one parent outcome: `ok`.
|
Core workflows now declare `Workflow.outcomes`, and explicit `EndNode` steps
|
||||||
Later, saved workflow artifacts may declare multiple outcomes, but core
|
set `RunState.outcome`. The legacy `__end__` token remains compatibility
|
||||||
`Workflow.output_schema` is currently one output shape. Outcome-per-child-graph
|
shorthand for workflow outcome `ok`. Native subgraph execution should use that
|
||||||
needs a separate design if we want a subgraph to behave exactly like a
|
workflow-level outcome as the parent-visible subgraph outcome, instead of
|
||||||
multi-outcome node.
|
guessing from the child node that happened to route to a terminal.
|
||||||
|
|
||||||
## Failure Semantics
|
## Failure Semantics
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ See `examples/raw_concurrent_foreach.py` for the canonical raw workflow shape an
|
|||||||
- validate start node existence
|
- validate start node existence
|
||||||
- validate edge sources, destinations, duplicate outcomes, and declared outcomes
|
- validate edge sources, destinations, duplicate outcomes, and declared outcomes
|
||||||
- validate reachable nodes have all required outcome edges
|
- validate reachable nodes have all required outcome edges
|
||||||
|
- validate explicit `EndNode` outcomes against `Workflow.outcomes`
|
||||||
|
|
||||||
Validation reports multiple issues through `ValidationReport` instead of
|
Validation reports multiple issues through `ValidationReport` instead of
|
||||||
raising at the first failure.
|
raising at the first failure.
|
||||||
|
|||||||
@@ -207,7 +207,10 @@ Edge
|
|||||||
|
|
||||||
- source nodes declare which outcomes are possible
|
- source nodes declare which outcomes are possible
|
||||||
- edges map those outcomes to next nodes
|
- edges map those outcomes to next nodes
|
||||||
- terminal routing can go to builtin `__end__`
|
- terminal routing can go to builtin `__end__`, which is compatibility
|
||||||
|
shorthand for workflow outcome `ok`
|
||||||
|
- explicit `EndNode` steps set non-`ok` workflow outcomes such as `error` or
|
||||||
|
`needs_input`
|
||||||
|
|
||||||
Reaching an undeclared or unwired outcome is runtime failure.
|
Reaching an undeclared or unwired outcome is runtime failure.
|
||||||
|
|
||||||
@@ -302,7 +305,7 @@ Executor steps:
|
|||||||
7. Validate typed node output
|
7. Validate typed node output
|
||||||
8. Commit mapped output into state
|
8. Commit mapped output into state
|
||||||
9. Route by returned outcome
|
9. Route by returned outcome
|
||||||
10. Stop when routing reaches `__end__`
|
10. Stop when routing reaches `__end__` or an explicit `EndNode`
|
||||||
11. Derive and validate final output from state
|
11. Derive and validate final output from state
|
||||||
|
|
||||||
Commit rules:
|
Commit rules:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from .models import (
|
from .models import (
|
||||||
ConditionNode,
|
ConditionNode,
|
||||||
|
EndNode,
|
||||||
Edge,
|
Edge,
|
||||||
ForeachConcurrentPolicy,
|
ForeachConcurrentPolicy,
|
||||||
ForeachItemErrorPolicy,
|
ForeachItemErrorPolicy,
|
||||||
@@ -51,6 +52,7 @@ from .validation import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"ConditionNode",
|
"ConditionNode",
|
||||||
"Edge",
|
"Edge",
|
||||||
|
"EndNode",
|
||||||
"ForeachConcurrentPolicy",
|
"ForeachConcurrentPolicy",
|
||||||
"ForeachItemErrorPolicy",
|
"ForeachItemErrorPolicy",
|
||||||
"ForeachNode",
|
"ForeachNode",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from wf_core.models.reducers import ReducerRef, ReducerSpec, SiblingWritePolicy
|
|||||||
from wf_core.models.schemas import NodeDef, SchemaRef, StateField, StateSchema
|
from wf_core.models.schemas import NodeDef, SchemaRef, StateField, StateSchema
|
||||||
from wf_core.models.steps import (
|
from wf_core.models.steps import (
|
||||||
ConditionNode,
|
ConditionNode,
|
||||||
|
EndNode,
|
||||||
ForeachConcurrentPolicy,
|
ForeachConcurrentPolicy,
|
||||||
ForeachItemErrorPolicy,
|
ForeachItemErrorPolicy,
|
||||||
ForeachNode,
|
ForeachNode,
|
||||||
@@ -29,6 +30,7 @@ __all__ = [
|
|||||||
"Condition",
|
"Condition",
|
||||||
"ConditionNode",
|
"ConditionNode",
|
||||||
"Edge",
|
"Edge",
|
||||||
|
"EndNode",
|
||||||
"ExistsCondition",
|
"ExistsCondition",
|
||||||
"ForeachConcurrentPolicy",
|
"ForeachConcurrentPolicy",
|
||||||
"ForeachItemErrorPolicy",
|
"ForeachItemErrorPolicy",
|
||||||
|
|||||||
@@ -312,6 +312,20 @@ class JoinNode(BaseModel):
|
|||||||
type: Literal["join"]
|
type: Literal["join"]
|
||||||
|
|
||||||
|
|
||||||
|
class EndNode(BaseModel):
|
||||||
|
"""Explicit workflow terminal that sets the workflow-level outcome.
|
||||||
|
|
||||||
|
`__end__` remains the compatibility shorthand for outcome ``ok``. New
|
||||||
|
workflows that need business outcomes such as ``error`` or ``needs_input``
|
||||||
|
should route to explicit end nodes so the terminal contract is visible in
|
||||||
|
the graph.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
type: Literal["end"]
|
||||||
|
outcome: str = "ok"
|
||||||
|
|
||||||
|
|
||||||
class InterruptNode(BaseModel):
|
class InterruptNode(BaseModel):
|
||||||
"""Control-flow step that pauses a run and waits for resume input."""
|
"""Control-flow step that pauses a run and waits for resume input."""
|
||||||
|
|
||||||
@@ -372,7 +386,13 @@ class InterruptNode(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
Step = Annotated[
|
Step = Annotated[
|
||||||
NodeUse | SubgraphNode | ConditionNode | ForeachNode | JoinNode | InterruptNode,
|
NodeUse
|
||||||
|
| SubgraphNode
|
||||||
|
| ConditionNode
|
||||||
|
| ForeachNode
|
||||||
|
| JoinNode
|
||||||
|
| EndNode
|
||||||
|
| InterruptNode,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
"""Discriminated union of all executable workflow graph steps."""
|
"""Discriminated union of all executable workflow graph steps."""
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class Workflow(BaseModel):
|
|||||||
state_schema: StateSchema
|
state_schema: StateSchema
|
||||||
output_schema: SchemaRef
|
output_schema: SchemaRef
|
||||||
node_defs: list[NodeDef] = Field(default_factory=list)
|
node_defs: list[NodeDef] = Field(default_factory=list)
|
||||||
|
outcomes: list[str] = Field(default_factory=lambda: ["ok"], min_length=1)
|
||||||
start: str
|
start: str
|
||||||
nodes: list[Step]
|
nodes: list[Step]
|
||||||
edges: list[Edge]
|
edges: list[Edge]
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ class RunState:
|
|||||||
status: RunStatus
|
status: RunStatus
|
||||||
workflow_input: dict[str, Any]
|
workflow_input: dict[str, Any]
|
||||||
state: dict[str, Any]
|
state: dict[str, Any]
|
||||||
|
outcome: str | None = None
|
||||||
output: dict[str, Any] = field(default_factory=dict)
|
output: dict[str, Any] = field(default_factory=dict)
|
||||||
trace: list[TraceEntry] = field(default_factory=list)
|
trace: list[TraceEntry] = field(default_factory=list)
|
||||||
frames: dict[str, ExecutionFrame] = field(default_factory=dict)
|
frames: dict[str, ExecutionFrame] = field(default_factory=dict)
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ def advance_frame(
|
|||||||
|
|
||||||
|
|
||||||
def finalize_run(workflow: Workflow, run: RunState) -> RunState:
|
def finalize_run(workflow: Workflow, run: RunState) -> RunState:
|
||||||
|
if run.outcome is None:
|
||||||
|
run.outcome = "ok"
|
||||||
run.output = project_output(workflow, run.state)
|
run.output = project_output(workflow, run.state)
|
||||||
validate_payload_against_schema(
|
validate_payload_against_schema(
|
||||||
workflow.output_schema, run.output, "workflow output"
|
workflow.output_schema, run.output, "workflow output"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
from wf_core.errors import WorkflowExecutionError
|
from wf_core.errors import WorkflowExecutionError
|
||||||
from wf_core.models.steps import (
|
from wf_core.models.steps import (
|
||||||
ConditionNode,
|
ConditionNode,
|
||||||
|
EndNode,
|
||||||
ForeachNode,
|
ForeachNode,
|
||||||
InterruptNode,
|
InterruptNode,
|
||||||
JoinNode,
|
JoinNode,
|
||||||
@@ -37,7 +38,8 @@ from wf_core.runtime.scheduler import (
|
|||||||
select_next_frame,
|
select_next_frame,
|
||||||
wake_parent_for_child_progress,
|
wake_parent_for_child_progress,
|
||||||
)
|
)
|
||||||
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState
|
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult
|
||||||
|
from wf_core.tokens import END
|
||||||
|
|
||||||
from .preparation import prepare_step
|
from .preparation import prepare_step
|
||||||
|
|
||||||
@@ -74,6 +76,33 @@ def complete_step(
|
|||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def complete_end_step(
|
||||||
|
*,
|
||||||
|
run: RunState,
|
||||||
|
frame_id: str,
|
||||||
|
node_id: str,
|
||||||
|
outcome: str,
|
||||||
|
) -> RunState:
|
||||||
|
"""Record an explicit workflow terminal and complete the active frame."""
|
||||||
|
result = StepExecutionResult(outcome=outcome)
|
||||||
|
run.outcome = outcome
|
||||||
|
append_step_result_trace(
|
||||||
|
run,
|
||||||
|
frame_id=frame_id,
|
||||||
|
node_id=node_id,
|
||||||
|
step_type="end",
|
||||||
|
next_node_id=END,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
advance_frame(
|
||||||
|
run,
|
||||||
|
run.frames[frame_id],
|
||||||
|
outcome=outcome,
|
||||||
|
next_node_id=END,
|
||||||
|
)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
def step_workflow(
|
def step_workflow(
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
run: RunState,
|
run: RunState,
|
||||||
@@ -112,6 +141,13 @@ def step_workflow(
|
|||||||
step_result = handle_condition_step(run, step)
|
step_result = handle_condition_step(run, step)
|
||||||
elif isinstance(step, JoinNode):
|
elif isinstance(step, JoinNode):
|
||||||
step_result = handle_join_step()
|
step_result = handle_join_step()
|
||||||
|
elif isinstance(step, EndNode):
|
||||||
|
return complete_end_step(
|
||||||
|
run=run,
|
||||||
|
frame_id=frame.id,
|
||||||
|
node_id=frame.node_id,
|
||||||
|
outcome=step.outcome,
|
||||||
|
)
|
||||||
elif isinstance(step, InterruptNode):
|
elif isinstance(step, InterruptNode):
|
||||||
return handle_interrupt_step(run, step)
|
return handle_interrupt_step(run, step)
|
||||||
elif isinstance(step, ForeachNode):
|
elif isinstance(step, ForeachNode):
|
||||||
@@ -211,6 +247,13 @@ async def step_workflow_async(
|
|||||||
step_result = handle_condition_step(run, step)
|
step_result = handle_condition_step(run, step)
|
||||||
elif isinstance(step, JoinNode):
|
elif isinstance(step, JoinNode):
|
||||||
step_result = handle_join_step()
|
step_result = handle_join_step()
|
||||||
|
elif isinstance(step, EndNode):
|
||||||
|
return complete_end_step(
|
||||||
|
run=run,
|
||||||
|
frame_id=frame.id,
|
||||||
|
node_id=frame.node_id,
|
||||||
|
outcome=step.outcome,
|
||||||
|
)
|
||||||
elif isinstance(step, InterruptNode):
|
elif isinstance(step, InterruptNode):
|
||||||
return handle_interrupt_step(run, step)
|
return handle_interrupt_step(run, step)
|
||||||
elif isinstance(step, ForeachNode):
|
elif isinstance(step, ForeachNode):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from wf_core.models.steps import (
|
from wf_core.models.steps import (
|
||||||
ConditionNode,
|
ConditionNode,
|
||||||
|
EndNode,
|
||||||
ForeachNode,
|
ForeachNode,
|
||||||
InterruptNode,
|
InterruptNode,
|
||||||
NodeUse,
|
NodeUse,
|
||||||
@@ -9,7 +10,7 @@ from wf_core.models.steps import (
|
|||||||
SubgraphNode,
|
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 Workflow
|
||||||
from wf_core.tokens import END
|
from wf_core.tokens import END
|
||||||
from wf_core.validation.issues import ValidationIssueCode, ValidationReport
|
from wf_core.validation.issues import ValidationIssueCode, ValidationReport
|
||||||
from wf_core.validation.outcomes import declared_outcomes_for_step, reachable_node_ids
|
from wf_core.validation.outcomes import declared_outcomes_for_step, reachable_node_ids
|
||||||
@@ -28,7 +29,7 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
|
|||||||
node_defs = _collect_node_defs(workflow, report)
|
node_defs = _collect_node_defs(workflow, report)
|
||||||
nodes_by_id = _validate_nodes(workflow, node_defs, report)
|
nodes_by_id = _validate_nodes(workflow, node_defs, report)
|
||||||
_validate_start(workflow, nodes_by_id, report)
|
_validate_start(workflow, nodes_by_id, report)
|
||||||
outgoing = _validate_edges(workflow.edges, nodes_by_id, node_defs, report)
|
outgoing = _validate_edges(workflow, nodes_by_id, node_defs, report)
|
||||||
_validate_reachable_outcomes(workflow, nodes_by_id, node_defs, outgoing, report)
|
_validate_reachable_outcomes(workflow, nodes_by_id, node_defs, outgoing, report)
|
||||||
|
|
||||||
return report
|
return report
|
||||||
@@ -73,6 +74,8 @@ def _validate_nodes(
|
|||||||
validate_node_use(node, index, node_defs, workflow, report)
|
validate_node_use(node, index, node_defs, workflow, report)
|
||||||
elif isinstance(node, SubgraphNode):
|
elif isinstance(node, SubgraphNode):
|
||||||
validate_subgraph_node(node, index, workflow, report)
|
validate_subgraph_node(node, index, workflow, report)
|
||||||
|
elif isinstance(node, EndNode):
|
||||||
|
_validate_end_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
|
||||||
@@ -94,6 +97,21 @@ def _validate_nodes(
|
|||||||
return nodes_by_id
|
return nodes_by_id
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_end_node(
|
||||||
|
node: EndNode,
|
||||||
|
index: int,
|
||||||
|
workflow: Workflow,
|
||||||
|
report: ValidationReport,
|
||||||
|
) -> None:
|
||||||
|
"""Validate explicit workflow terminal outcomes."""
|
||||||
|
if node.outcome not in workflow.outcomes:
|
||||||
|
report.add(
|
||||||
|
ValidationIssueCode.UNDECLARED_WORKFLOW_OUTCOME,
|
||||||
|
f"nodes[{index}].outcome",
|
||||||
|
f"workflow outcome {node.outcome!r} is not declared",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_start(
|
def _validate_start(
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
nodes_by_id: dict[str, Step],
|
nodes_by_id: dict[str, Step],
|
||||||
@@ -108,7 +126,7 @@ def _validate_start(
|
|||||||
|
|
||||||
|
|
||||||
def _validate_edges(
|
def _validate_edges(
|
||||||
edges: list[Edge],
|
workflow: Workflow,
|
||||||
nodes_by_id: dict[str, Step],
|
nodes_by_id: dict[str, Step],
|
||||||
node_defs: dict[str, NodeDef],
|
node_defs: dict[str, NodeDef],
|
||||||
report: ValidationReport,
|
report: ValidationReport,
|
||||||
@@ -116,7 +134,7 @@ def _validate_edges(
|
|||||||
outgoing: dict[str, set[str]] = {}
|
outgoing: dict[str, set[str]] = {}
|
||||||
edge_keys: set[tuple[str, str]] = set()
|
edge_keys: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
for index, edge in enumerate(edges):
|
for index, edge in enumerate(workflow.edges):
|
||||||
edge_key = (edge.from_, edge.outcome)
|
edge_key = (edge.from_, edge.outcome)
|
||||||
if edge_key in edge_keys:
|
if edge_key in edge_keys:
|
||||||
report.add(
|
report.add(
|
||||||
@@ -150,6 +168,14 @@ def _validate_edges(
|
|||||||
f"edges[{index}].to",
|
f"edges[{index}].to",
|
||||||
f"unknown destination node {edge.to!r}",
|
f"unknown destination node {edge.to!r}",
|
||||||
)
|
)
|
||||||
|
if edge.to == END and "ok" not in workflow.outcomes:
|
||||||
|
# `__end__` is the legacy implicit end node for workflow outcome
|
||||||
|
# "ok". Explicit non-ok outcomes should use EndNode instead.
|
||||||
|
report.add(
|
||||||
|
ValidationIssueCode.UNDECLARED_WORKFLOW_OUTCOME,
|
||||||
|
f"edges[{index}].to",
|
||||||
|
"legacy __end__ requires workflow outcome 'ok' to be declared",
|
||||||
|
)
|
||||||
|
|
||||||
return outgoing
|
return outgoing
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ValidationIssueCode(StrEnum):
|
|||||||
UNKNOWN_EDGE_DESTINATION = "unknown_edge_destination"
|
UNKNOWN_EDGE_DESTINATION = "unknown_edge_destination"
|
||||||
UNDECLARED_EDGE_OUTCOME = "undeclared_edge_outcome"
|
UNDECLARED_EDGE_OUTCOME = "undeclared_edge_outcome"
|
||||||
MISSING_OUTCOME_EDGE = "missing_outcome_edge"
|
MISSING_OUTCOME_EDGE = "missing_outcome_edge"
|
||||||
|
UNDECLARED_WORKFLOW_OUTCOME = "undeclared_workflow_outcome"
|
||||||
UNKNOWN_NODE_DEF = "unknown_node_def"
|
UNKNOWN_NODE_DEF = "unknown_node_def"
|
||||||
INVALID_NODE_INPUT_FIELD = "invalid_node_input_field"
|
INVALID_NODE_INPUT_FIELD = "invalid_node_input_field"
|
||||||
INVALID_SOURCE_PATH = "invalid_source_path"
|
INVALID_SOURCE_PATH = "invalid_source_path"
|
||||||
|
|||||||
@@ -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, SubgraphNode
|
from wf_core.models.steps import EndNode, 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
|
||||||
|
|
||||||
@@ -21,6 +21,8 @@ def declared_outcomes_for_step(step: Step, node_defs: dict[str, NodeDef]) -> set
|
|||||||
return outcomes
|
return outcomes
|
||||||
if step.type == "join":
|
if step.type == "join":
|
||||||
return {"done"}
|
return {"done"}
|
||||||
|
if isinstance(step, EndNode):
|
||||||
|
return set()
|
||||||
if isinstance(step, InterruptNode):
|
if isinstance(step, InterruptNode):
|
||||||
return set(step.outcomes)
|
return set(step.outcomes)
|
||||||
return set()
|
return set()
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wf_core import END, Workflow
|
||||||
|
from wf_core.runtime import execute_workflow
|
||||||
|
from wf_core.validation.issues import ValidationIssueCode
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_end_token_completes_with_ok_workflow_outcome() -> None:
|
||||||
|
workflow = _workflow(edges=[{"from": "finish", "outcome": "done", "to": END}])
|
||||||
|
|
||||||
|
run = execute_workflow(workflow, {"text": "hello"}, {"finish": _finish})
|
||||||
|
|
||||||
|
assert run.status == "completed"
|
||||||
|
assert run.outcome == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_end_node_sets_workflow_outcome() -> None:
|
||||||
|
workflow = _workflow(
|
||||||
|
outcomes=["ok", "error"],
|
||||||
|
nodes=[
|
||||||
|
_finish_node_data(),
|
||||||
|
{"id": "end_error", "type": "end", "outcome": "error"},
|
||||||
|
],
|
||||||
|
edges=[{"from": "finish", "outcome": "done", "to": "end_error"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
run = execute_workflow(workflow, {"text": "hello"}, {"finish": _finish})
|
||||||
|
|
||||||
|
assert run.status == "completed"
|
||||||
|
assert run.outcome == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_rejects_end_node_outcome_not_declared_by_workflow() -> None:
|
||||||
|
workflow = _workflow(
|
||||||
|
nodes=[
|
||||||
|
_finish_node_data(),
|
||||||
|
{"id": "end_error", "type": "end", "outcome": "error"},
|
||||||
|
],
|
||||||
|
edges=[{"from": "finish", "outcome": "done", "to": "end_error"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
report = workflow.validate_structure()
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
issue.code == ValidationIssueCode.UNDECLARED_WORKFLOW_OUTCOME
|
||||||
|
and issue.path == "nodes[1].outcome"
|
||||||
|
for issue in report.errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_rejects_legacy_end_without_ok_workflow_outcome() -> None:
|
||||||
|
workflow = _workflow(
|
||||||
|
outcomes=["error"],
|
||||||
|
edges=[{"from": "finish", "outcome": "done", "to": END}],
|
||||||
|
)
|
||||||
|
|
||||||
|
report = workflow.validate_structure()
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
issue.code == ValidationIssueCode.UNDECLARED_WORKFLOW_OUTCOME
|
||||||
|
and issue.path == "edges[0].to"
|
||||||
|
for issue in report.errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(payload: dict[str, object], _ctx: object) -> dict[str, object]:
|
||||||
|
return {"outcome": "done", "output": {"echoed": payload["text"]}}
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow(
|
||||||
|
*,
|
||||||
|
outcomes: list[str] | None = None,
|
||||||
|
nodes: list[dict[str, object]] | None = None,
|
||||||
|
edges: list[dict[str, object]],
|
||||||
|
) -> Workflow:
|
||||||
|
return Workflow.model_validate(
|
||||||
|
{
|
||||||
|
"name": "workflow_outcomes",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
"state_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"echoed": {"type": "string"}},
|
||||||
|
},
|
||||||
|
"output_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"echoed": {"type": "string"}},
|
||||||
|
"required": ["echoed"],
|
||||||
|
},
|
||||||
|
"node_defs": [
|
||||||
|
{
|
||||||
|
"name": "finish",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
"output_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"echoed": {"type": "string"}},
|
||||||
|
"required": ["echoed"],
|
||||||
|
},
|
||||||
|
"outcomes": ["done"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outcomes": outcomes or ["ok"],
|
||||||
|
"start": "finish",
|
||||||
|
"nodes": [_finish_node_data()] if nodes is None else nodes,
|
||||||
|
"edges": edges,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_node_data() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": "finish",
|
||||||
|
"type": "node",
|
||||||
|
"node": "finish",
|
||||||
|
"input": [{"target": "text", "path": "input.text"}],
|
||||||
|
"output": [{"source": "echoed", "target": "state.echoed"}],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user