general improvements to modularity

This commit is contained in:
lda
2026-04-28 08:08:42 +07:00 Verified
parent 89d23a0ca0
commit 01fdfab725
6 changed files with 287 additions and 134 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import json import json
import sys import sys
from wf_core import RuntimeContext, Workflow, execute_workflow from wf_core import END, RuntimeContext, Workflow, execute_workflow
workflow = Workflow.model_validate( workflow = Workflow.model_validate(
@@ -137,8 +137,8 @@ workflow = Workflow.model_validate(
{"from": "summarize", "outcome": "ok", "to": "should_email"}, {"from": "summarize", "outcome": "ok", "to": "should_email"},
{"from": "should_email", "outcome": "true", "to": "send_email"}, {"from": "should_email", "outcome": "true", "to": "send_email"},
{"from": "should_email", "outcome": "false", "to": "skip_email"}, {"from": "should_email", "outcome": "false", "to": "skip_email"},
{"from": "send_email", "outcome": "sent", "to": "__end__"}, {"from": "send_email", "outcome": "sent", "to": END},
{"from": "skip_email", "outcome": "ok", "to": "__end__"}, {"from": "skip_email", "outcome": "ok", "to": END},
], ],
} }
) )
+10 -1
View File
@@ -16,7 +16,13 @@ from .runtime import (
WorkflowExecutionError, WorkflowExecutionError,
execute_workflow, execute_workflow,
) )
from .validate import ValidationIssue, ValidationReport, validate_workflow from .tokens import END, START
from .validate import (
ValidationIssue,
ValidationIssueCode,
ValidationReport,
validate_workflow,
)
__all__ = [ __all__ = [
"ConditionNode", "ConditionNode",
@@ -30,7 +36,10 @@ __all__ = [
"StateSchema", "StateSchema",
"RuntimeContext", "RuntimeContext",
"TraceEntry", "TraceEntry",
"START",
"END",
"ValidationIssue", "ValidationIssue",
"ValidationIssueCode",
"ValidationReport", "ValidationReport",
"Workflow", "Workflow",
"WorkflowExecutionError", "WorkflowExecutionError",
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from collections.abc import Mapping, MutableMapping
from typing import Any
class PathResolutionError(ValueError):
pass
def split_graph_path(path: str) -> tuple[str, list[str]]:
root, *parts = path.split(".")
if not root or not parts:
raise PathResolutionError(f"invalid path {path!r}")
return root, parts
def is_valid_source_path(
path: str,
state_root_fields: set[str],
input_root_fields: set[str],
*,
allow_context: bool = False,
) -> bool:
try:
root, parts = split_graph_path(path)
except PathResolutionError:
return False
field_name = parts[0]
if allow_context and root == "context":
return True
if root == "state":
return field_name in state_root_fields
if root == "input":
return field_name in input_root_fields
return False
def is_valid_destination_path(path: str) -> bool:
try:
root, parts = split_graph_path(path)
except PathResolutionError:
return False
return root == "state" and bool(parts)
def resolve_graph_path(
path: str,
*,
state: Mapping[str, Any],
workflow_input: Mapping[str, Any],
context: Mapping[str, Any],
) -> Any:
root, parts = split_graph_path(path)
if root == "state":
source: Mapping[str, Any] = state
elif root == "input":
source = workflow_input
elif root == "context":
source = context
else:
raise PathResolutionError(f"unknown path root {root!r}")
current: Any = source
for part in parts:
if not isinstance(current, Mapping) or part not in current:
raise PathResolutionError(f"path {path!r} could not be resolved")
current = current[part]
return current
def path_exists(
path: str,
*,
state: Mapping[str, Any],
workflow_input: Mapping[str, Any],
context: Mapping[str, Any],
) -> bool:
try:
resolve_graph_path(
path, state=state, workflow_input=workflow_input, context=context
)
except PathResolutionError:
return False
return True
def get_nested_value(state: Mapping[str, Any], path_parts: list[str]) -> Any:
current: Any = state
for part in path_parts:
if not isinstance(current, Mapping) or part not in current:
return None
current = current[part]
return current
def set_nested_value(
state: MutableMapping[str, Any], path_parts: list[str], value: Any
) -> None:
current: MutableMapping[str, Any] = state
for part in path_parts[:-1]:
next_value = current.get(part)
if next_value is None:
next_value = {}
current[part] = next_value
if not isinstance(next_value, MutableMapping):
raise PathResolutionError(
f"cannot descend into non-object state field {part!r}"
)
current = next_value
current[path_parts[-1]] = value
+60 -72
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from typing import Any from typing import Any
@@ -20,6 +20,15 @@ from .model import (
VariadicCondition, VariadicCondition,
Workflow, Workflow,
) )
from .paths import (
PathResolutionError,
get_nested_value,
path_exists,
resolve_graph_path,
set_nested_value,
split_graph_path,
)
from .tokens import END
class WorkflowExecutionError(RuntimeError): class WorkflowExecutionError(RuntimeError):
@@ -70,7 +79,7 @@ def execute_workflow(
prior_outcome: str | None = None prior_outcome: str | None = None
activated_incoming_edge: str | None = None activated_incoming_edge: str | None = None
while current_node_id != "__end__": while current_node_id != END:
step = nodes_by_id[current_node_id] step = nodes_by_id[current_node_id]
if isinstance(step, NodeUse): if isinstance(step, NodeUse):
@@ -158,7 +167,12 @@ def _execute_node_use(
) )
resolved_input = { resolved_input = {
destination_field: _resolve_path(source_path, state, workflow_input, {}) destination_field: _safe_resolve_path(
source_path,
state=state,
workflow_input=workflow_input,
context={},
)
for source_path, destination_field in node.in_map.items() for source_path, destination_field in node.in_map.items()
} }
_validate_payload_against_schema( _validate_payload_against_schema(
@@ -219,23 +233,29 @@ def _apply_output_map(
def _write_state_value( def _write_state_value(
workflow: Workflow, state: dict[str, Any], destination_path: str, value: Any workflow: Workflow, state: dict[str, Any], destination_path: str, value: Any
) -> None: ) -> None:
root, field_name, *rest = destination_path.split(".") try:
root, parts = split_graph_path(destination_path)
except PathResolutionError as exc:
raise WorkflowExecutionError(str(exc)) from exc
if root != "state": if root != "state":
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"executor only supports writes into state.*, got {destination_path!r}" f"executor only supports writes into state.*, got {destination_path!r}"
) )
field_name = parts[0]
declared_field = workflow.state_schema.fields.get(field_name) declared_field = workflow.state_schema.fields.get(field_name)
merge_strategy = declared_field.merge_strategy if declared_field else "replace" merge_strategy = declared_field.merge_strategy if declared_field else "replace"
key_path = [field_name, *rest] key_path = parts
if merge_strategy == "replace": if merge_strategy == "replace":
_set_nested_value(state, key_path, value) _safe_set_nested_value(state, key_path, value)
return return
current_value = _get_nested_value(state, key_path) current_value = get_nested_value(state, key_path)
if merge_strategy == "append": if merge_strategy == "append":
if current_value is None: if current_value is None:
_set_nested_value( _safe_set_nested_value(
state, key_path, [value] if not isinstance(value, list) else value state, key_path, [value] if not isinstance(value, list) else value
) )
return return
@@ -255,7 +275,7 @@ def _write_state_value(
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"cannot merge non-object value into {destination_path!r}" f"cannot merge non-object value into {destination_path!r}"
) )
_set_nested_value(state, key_path, dict(value)) _safe_set_nested_value(state, key_path, dict(value))
return return
if not isinstance(current_value, dict) or not isinstance(value, dict): if not isinstance(current_value, dict) or not isinstance(value, dict):
raise WorkflowExecutionError( raise WorkflowExecutionError(
@@ -291,7 +311,12 @@ def _eval_condition(
context_data: str | None, context_data: str | None,
) -> bool: ) -> bool:
if isinstance(condition, ExistsCondition): if isinstance(condition, ExistsCondition):
return _path_exists(condition.path, state, workflow_input, context_data) return path_exists(
condition.path,
state=state,
workflow_input=workflow_input,
context={"prior_outcome": context_data},
)
if isinstance(condition, NotCondition): if isinstance(condition, NotCondition):
return not _eval_condition(condition.arg, state, workflow_input, context_data) return not _eval_condition(condition.arg, state, workflow_input, context_data)
if isinstance(condition, VariadicCondition): if isinstance(condition, VariadicCondition):
@@ -322,73 +347,36 @@ def _resolve_operand(
) -> Any: ) -> Any:
if isinstance(operand, LiteralOperand): if isinstance(operand, LiteralOperand):
return operand.value return operand.value
return _resolve_path( return _safe_resolve_path(
operand.path, operand.path,
state, state=state,
workflow_input, workflow_input=workflow_input,
{"prior_outcome": context_data}, context={"prior_outcome": context_data},
) )
def _path_exists( def _safe_resolve_path(
path: str, path: str,
state: dict[str, Any], *,
workflow_input: dict[str, Any], state: Mapping[str, Any],
context_data: str | None, workflow_input: Mapping[str, Any],
) -> bool: context: Mapping[str, Any],
try:
_resolve_path(path, state, workflow_input, {"prior_outcome": context_data})
except WorkflowExecutionError:
return False
return True
def _resolve_path(
path: str,
state: dict[str, Any],
workflow_input: dict[str, Any],
context: dict[str, Any],
) -> Any: ) -> Any:
root, *parts = path.split(".") try:
if not parts: return resolve_graph_path(
raise WorkflowExecutionError(f"invalid path {path!r}") path,
state=state,
if root == "state": workflow_input=workflow_input,
source = state context=context,
elif root == "input": )
source = workflow_input except PathResolutionError as exc:
elif root == "context": raise WorkflowExecutionError(str(exc)) from exc
source = context
else:
raise WorkflowExecutionError(f"unknown path root {root!r}")
current: Any = source
for part in parts:
if not isinstance(current, dict) or part not in current:
raise WorkflowExecutionError(f"path {path!r} could not be resolved")
current = current[part]
return current
def _get_nested_value(state: dict[str, Any], path_parts: list[str]) -> Any: def _safe_set_nested_value(
current: Any = state state: dict[str, Any], path_parts: list[str], value: Any
for part in path_parts: ) -> None:
if not isinstance(current, dict) or part not in current: try:
return None set_nested_value(state, path_parts, value)
current = current[part] except PathResolutionError as exc:
return current raise WorkflowExecutionError(str(exc)) from exc
def _set_nested_value(state: dict[str, Any], path_parts: list[str], value: Any) -> None:
current = state
for part in path_parts[:-1]:
next_value = current.get(part)
if next_value is None:
next_value = {}
current[part] = next_value
if not isinstance(next_value, dict):
raise WorkflowExecutionError(
f"cannot descend into non-object state field {part!r}"
)
current = next_value
current[path_parts[-1]] = value
+4
View File
@@ -0,0 +1,4 @@
START = "__start__"
END = "__end__"
__all__ = ["START", "END"]
+97 -58
View File
@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
from .model import ( from .model import (
BinaryCondition, BinaryCondition,
Condition, Condition,
ConditionNode, ConditionNode,
Edge,
ExistsCondition, ExistsCondition,
ForeachNode, ForeachNode,
LiteralOperand, LiteralOperand,
@@ -17,10 +20,32 @@ from .model import (
VariadicCondition, VariadicCondition,
Workflow, Workflow,
) )
from .paths import is_valid_destination_path, is_valid_source_path
from .tokens import END
class ValidationIssueCode(StrEnum):
DUPLICATE_NODE_DEF = "duplicate_node_def"
DUPLICATE_NODE_ID = "duplicate_node_id"
UNKNOWN_START = "unknown_start"
DUPLICATE_EDGE = "duplicate_edge"
UNKNOWN_EDGE_SOURCE = "unknown_edge_source"
UNKNOWN_EDGE_DESTINATION = "unknown_edge_destination"
UNDECLARED_EDGE_OUTCOME = "undeclared_edge_outcome"
MISSING_OUTCOME_EDGE = "missing_outcome_edge"
UNKNOWN_NODE_DEF = "unknown_node_def"
INVALID_NODE_INPUT_FIELD = "invalid_node_input_field"
INVALID_SOURCE_PATH = "invalid_source_path"
INVALID_NODE_OUTPUT_FIELD = "invalid_node_output_field"
INVALID_DESTINATION_PATH = "invalid_destination_path"
EMPTY_CONDITION_ARGS = "empty_condition_args"
INVALID_CONDITION_PATH = "invalid_condition_path"
INVALID_FOREACH_SOURCE = "invalid_foreach_source"
@dataclass(slots=True) @dataclass(slots=True)
class ValidationIssue: class ValidationIssue:
code: ValidationIssueCode
path: str path: str
message: str message: str
@@ -33,14 +58,14 @@ class ValidationReport:
def ok(self) -> bool: def ok(self) -> bool:
return not self.errors return not self.errors
def add(self, path: str, message: str) -> None: def add(self, code: ValidationIssueCode, path: str, message: str) -> None:
self.errors.append(ValidationIssue(path=path, message=message)) self.errors.append(ValidationIssue(code=code, path=path, message=message))
def raise_for_errors(self) -> None: def raise_for_errors(self) -> None:
if not self.errors: if not self.errors:
return return
rendered = "\n".join( rendered = "\n".join(
f"- {issue.path}: {issue.message}" for issue in self.errors f"- [{issue.code}] {issue.path}: {issue.message}" for issue in self.errors
) )
raise ValueError(f"Workflow validation failed:\n{rendered}") raise ValueError(f"Workflow validation failed:\n{rendered}")
@@ -52,18 +77,24 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
for index, node_def in enumerate(workflow.node_defs): for index, node_def in enumerate(workflow.node_defs):
if node_def.name in node_defs: if node_def.name in node_defs:
report.add( report.add(
f"node_defs[{index}].name", f"duplicate node def name {node_def.name!r}" ValidationIssueCode.DUPLICATE_NODE_DEF,
f"node_defs[{index}].name",
f"duplicate node def name {node_def.name!r}",
) )
else: else:
node_defs[node_def.name] = node_def node_defs[node_def.name] = node_def
nodes_by_id = {} nodes_by_id: dict[str, Step] = {}
state_root_fields = set(workflow.state_schema.fields) state_root_fields = set(workflow.state_schema.fields)
input_root_fields = set(workflow.input_schema.properties) input_root_fields = set(workflow.input_schema.properties)
for index, node in enumerate(workflow.nodes): for index, node in enumerate(workflow.nodes):
if node.id in nodes_by_id: if node.id in nodes_by_id:
report.add(f"nodes[{index}].id", f"duplicate node id {node.id!r}") report.add(
ValidationIssueCode.DUPLICATE_NODE_ID,
f"nodes[{index}].id",
f"duplicate node id {node.id!r}",
)
else: else:
nodes_by_id[node.id] = node nodes_by_id[node.id] = node
@@ -79,7 +110,11 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
) )
if workflow.start not in nodes_by_id: if workflow.start not in nodes_by_id:
report.add("start", f"unknown start node {workflow.start!r}") report.add(
ValidationIssueCode.UNKNOWN_START,
"start",
f"unknown start node {workflow.start!r}",
)
outgoing: dict[str, set[str]] = {} outgoing: dict[str, set[str]] = {}
edge_keys: set[tuple[str, str]] = set() edge_keys: set[tuple[str, str]] = set()
@@ -88,6 +123,7 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
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(
ValidationIssueCode.DUPLICATE_EDGE,
f"edges[{index}]", f"edges[{index}]",
f"duplicate edge for source {edge.from_!r} and outcome {edge.outcome!r}", f"duplicate edge for source {edge.from_!r} and outcome {edge.outcome!r}",
) )
@@ -96,21 +132,27 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
source = nodes_by_id.get(edge.from_) source = nodes_by_id.get(edge.from_)
if source is None: if source is None:
report.add(f"edges[{index}].from", f"unknown source node {edge.from_!r}") report.add(
ValidationIssueCode.UNKNOWN_EDGE_SOURCE,
f"edges[{index}].from",
f"unknown source node {edge.from_!r}",
)
else: else:
allowed = _declared_outcomes_for_step(source, node_defs) allowed = _declared_outcomes_for_step(source, node_defs)
if edge.outcome not in allowed: if edge.outcome not in allowed:
report.add( report.add(
ValidationIssueCode.UNDECLARED_EDGE_OUTCOME,
f"edges[{index}].outcome", f"edges[{index}].outcome",
f"outcome {edge.outcome!r} is not declared by node {edge.from_!r}", f"outcome {edge.outcome!r} is not declared by node {edge.from_!r}",
) )
outgoing.setdefault(edge.from_, set()).add(edge.outcome) outgoing.setdefault(edge.from_, set()).add(edge.outcome)
if edge.to != "__end__": if edge.to != END and edge.to not in nodes_by_id:
if edge.to not in nodes_by_id: report.add(
report.add( ValidationIssueCode.UNKNOWN_EDGE_DESTINATION,
f"edges[{index}].to", f"unknown destination node {edge.to!r}" f"edges[{index}].to",
) f"unknown destination node {edge.to!r}",
)
reachable = _reachable_node_ids(workflow.start, workflow.edges, nodes_by_id) reachable = _reachable_node_ids(workflow.start, workflow.edges, nodes_by_id)
@@ -121,6 +163,7 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
missing = declared_outcomes - wired missing = declared_outcomes - wired
if missing: if missing:
report.add( report.add(
ValidationIssueCode.MISSING_OUTCOME_EDGE,
f"nodes[{node_id}]", f"nodes[{node_id}]",
f"reachable node is missing edges for outcomes {sorted(missing)!r}", f"reachable node is missing edges for outcomes {sorted(missing)!r}",
) )
@@ -137,7 +180,11 @@ def _validate_node_use(
) -> None: ) -> None:
node_def = node_defs.get(node.node) node_def = node_defs.get(node.node)
if node_def is None: if node_def is None:
report.add(f"nodes[{index}].node", f"unknown node def {node.node!r}") report.add(
ValidationIssueCode.UNKNOWN_NODE_DEF,
f"nodes[{index}].node",
f"unknown node def {node.node!r}",
)
return return
input_fields = set(node_def.input_schema.properties) input_fields = set(node_def.input_schema.properties)
@@ -148,11 +195,13 @@ def _validate_node_use(
for source_path, destination_field in node.in_map.items(): for source_path, destination_field in node.in_map.items():
if destination_field not in input_fields: if destination_field not in input_fields:
report.add( report.add(
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
f"nodes[{index}].in_map[{source_path!r}]", f"nodes[{index}].in_map[{source_path!r}]",
f"destination field {destination_field!r} is not declared in node input schema", f"destination field {destination_field!r} is not declared in node input schema",
) )
if not _is_valid_source_path(source_path, state_fields, input_root_fields): if not is_valid_source_path(source_path, state_fields, input_root_fields):
report.add( report.add(
ValidationIssueCode.INVALID_SOURCE_PATH,
f"nodes[{index}].in_map[{source_path!r}]", f"nodes[{index}].in_map[{source_path!r}]",
"source path must start with input. or state. and reference a declared root field", "source path must start with input. or state. and reference a declared root field",
) )
@@ -160,11 +209,13 @@ def _validate_node_use(
for source_field, destination_path in node.out_map.items(): for source_field, destination_path in node.out_map.items():
if source_field not in output_fields: if source_field not in output_fields:
report.add( report.add(
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
f"nodes[{index}].out_map[{source_field!r}]", f"nodes[{index}].out_map[{source_field!r}]",
f"source field {source_field!r} is not declared in node output schema", f"source field {source_field!r} is not declared in node output schema",
) )
if not _is_valid_destination_path(destination_path): if not is_valid_destination_path(destination_path):
report.add( report.add(
ValidationIssueCode.INVALID_DESTINATION_PATH,
f"nodes[{index}].out_map[{source_field!r}]", f"nodes[{index}].out_map[{source_field!r}]",
"destination path must start with state.", "destination path must start with state.",
) )
@@ -178,7 +229,11 @@ def _validate_condition_node(
input_root_fields: set[str], input_root_fields: set[str],
) -> None: ) -> None:
if isinstance(node.check, VariadicCondition) and not node.check.args: if isinstance(node.check, VariadicCondition) and not node.check.args:
report.add(f"nodes[{index}].check.args", "condition args must not be empty") report.add(
ValidationIssueCode.EMPTY_CONDITION_ARGS,
f"nodes[{index}].check.args",
"condition args must not be empty",
)
_validate_condition_expr( _validate_condition_expr(
node.check, node.check,
f"nodes[{index}].check", f"nodes[{index}].check",
@@ -195,8 +250,9 @@ def _validate_foreach_node(
state_root_fields: set[str], state_root_fields: set[str],
input_root_fields: set[str], input_root_fields: set[str],
) -> None: ) -> None:
if not _is_valid_source_path(node.over, state_root_fields, input_root_fields): if not is_valid_source_path(node.over, state_root_fields, input_root_fields):
report.add( report.add(
ValidationIssueCode.INVALID_FOREACH_SOURCE,
f"nodes[{index}].over", f"nodes[{index}].over",
"foreach source path must start with input. or state. and reference a declared root field", "foreach source path must start with input. or state. and reference a declared root field",
) )
@@ -210,10 +266,17 @@ def _validate_condition_expr(
input_root_fields: set[str], input_root_fields: set[str],
) -> None: ) -> None:
if isinstance(condition, ExistsCondition): if isinstance(condition, ExistsCondition):
if not _is_valid_condition_path( if not is_valid_source_path(
condition.path, state_root_fields, input_root_fields condition.path,
state_root_fields,
input_root_fields,
allow_context=True,
): ):
report.add(path, f"invalid condition path {condition.path!r}") report.add(
ValidationIssueCode.INVALID_CONDITION_PATH,
path,
f"invalid condition path {condition.path!r}",
)
return return
if isinstance(condition, NotCondition): if isinstance(condition, NotCondition):
@@ -263,8 +326,14 @@ def _validate_operand(
) -> None: ) -> None:
if isinstance(operand, LiteralOperand): if isinstance(operand, LiteralOperand):
return return
if not _is_valid_condition_path(operand.path, state_root_fields, input_root_fields): if not is_valid_source_path(
report.add(path, f"invalid operand path {operand.path!r}") operand.path, state_root_fields, input_root_fields, allow_context=True
):
report.add(
ValidationIssueCode.INVALID_CONDITION_PATH,
path,
f"invalid operand path {operand.path!r}",
)
def _declared_outcomes_for_step(step: Step, node_defs: dict[str, NodeDef]) -> set[str]: def _declared_outcomes_for_step(step: Step, node_defs: dict[str, NodeDef]) -> set[str]:
@@ -280,17 +349,19 @@ def _declared_outcomes_for_step(step: Step, node_defs: dict[str, NodeDef]) -> se
return set() return set()
def _reachable_node_ids(start: str, edges, nodes_by_id: dict[str, Step]) -> set[str]: def _reachable_node_ids(
start: str, edges: list[Edge], nodes_by_id: dict[str, Step]
) -> set[str]:
if start not in nodes_by_id: if start not in nodes_by_id:
return set() return set()
adjacency: dict[str, list[str]] = {} adjacency: dict[str, list[str]] = {}
for edge in edges: for edge in edges:
if edge.to == "__end__": if edge.to == END:
continue continue
adjacency.setdefault(edge.from_, []).append(edge.to) adjacency.setdefault(edge.from_, []).append(edge.to)
seen = set() seen: set[str] = set()
stack = [start] stack = [start]
while stack: while stack:
node_id = stack.pop() node_id = stack.pop()
@@ -299,35 +370,3 @@ def _reachable_node_ids(start: str, edges, nodes_by_id: dict[str, Step]) -> set[
seen.add(node_id) seen.add(node_id)
stack.extend(adjacency.get(node_id, [])) stack.extend(adjacency.get(node_id, []))
return seen return seen
def _is_valid_source_path(
path: str, state_root_fields: set[str], input_root_fields: set[str]
) -> bool:
if "." not in path:
return False
root, field_name, *_ = path.split(".")
if root == "state":
return field_name in state_root_fields
if root == "input":
return field_name in input_root_fields
return False
def _is_valid_condition_path(
path: str, state_root_fields: set[str], input_root_fields: set[str]
) -> bool:
if "." not in path:
return False
root, field_name, *_ = path.split(".")
if root == "context":
return True
if root == "state":
return field_name in state_root_fields
if root == "input":
return field_name in input_root_fields
return False
def _is_valid_destination_path(path: str) -> bool:
return path.startswith("state.") and len(path.split(".")) >= 2