general improvements to modularity
This commit is contained in:
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
+61
-73
@@ -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
|
|
||||||
elif root == "context":
|
|
||||||
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:
|
|
||||||
current: Any = state
|
|
||||||
for part in path_parts:
|
|
||||||
if not isinstance(current, dict) or part not in current:
|
|
||||||
return None
|
|
||||||
current = current[part]
|
|
||||||
return current
|
|
||||||
|
|
||||||
|
|
||||||
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
|
except PathResolutionError as exc:
|
||||||
current[path_parts[-1]] = value
|
raise WorkflowExecutionError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_set_nested_value(
|
||||||
|
state: dict[str, Any], path_parts: list[str], value: Any
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
set_nested_value(state, path_parts, value)
|
||||||
|
except PathResolutionError as exc:
|
||||||
|
raise WorkflowExecutionError(str(exc)) from exc
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
START = "__start__"
|
||||||
|
END = "__end__"
|
||||||
|
|
||||||
|
__all__ = ["START", "END"]
|
||||||
+95
-56
@@ -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,20 +132,26 @@ 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(
|
||||||
f"edges[{index}].to", f"unknown destination node {edge.to!r}"
|
ValidationIssueCode.UNKNOWN_EDGE_DESTINATION,
|
||||||
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user