wf-core reorg 1
runtime/validation
This commit is contained in:
+2
-90
@@ -1,92 +1,4 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for runtime flow operations."""
|
||||
|
||||
from typing import Any
|
||||
from wf_core.runtime.ops.flow import * # noqa: F403
|
||||
|
||||
from .model import Workflow
|
||||
from .run_state import (
|
||||
ExecutionFrame,
|
||||
FrameStatus,
|
||||
RunState,
|
||||
RunStatus,
|
||||
StepExecutionResult,
|
||||
TraceEntry,
|
||||
)
|
||||
from .schema_tools import validate_payload_against_schema
|
||||
from .state_ops import project_output
|
||||
from .tokens import END
|
||||
|
||||
|
||||
def append_trace(
|
||||
run: RunState,
|
||||
*,
|
||||
frame_id: str,
|
||||
node_id: str,
|
||||
step_type: str,
|
||||
resolved_input: dict[str, Any],
|
||||
outcome: str,
|
||||
next_node_id: str,
|
||||
output: dict[str, Any],
|
||||
state_changes: dict[str, Any],
|
||||
) -> None:
|
||||
run.trace.append(
|
||||
TraceEntry(
|
||||
frame_id=frame_id,
|
||||
node_id=node_id,
|
||||
step_type=step_type,
|
||||
resolved_input=resolved_input,
|
||||
outcome=outcome,
|
||||
next_node_id=next_node_id,
|
||||
output=output,
|
||||
state_changes=state_changes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def append_step_result_trace(
|
||||
run: RunState,
|
||||
*,
|
||||
frame_id: str,
|
||||
node_id: str,
|
||||
step_type: str,
|
||||
next_node_id: str,
|
||||
result: StepExecutionResult,
|
||||
) -> None:
|
||||
append_trace(
|
||||
run,
|
||||
frame_id=frame_id,
|
||||
node_id=node_id,
|
||||
step_type=step_type,
|
||||
resolved_input=result.resolved_input,
|
||||
outcome=result.outcome,
|
||||
next_node_id=next_node_id,
|
||||
output=result.output,
|
||||
state_changes=result.state_changes,
|
||||
)
|
||||
|
||||
|
||||
def advance_frame(
|
||||
run: RunState,
|
||||
frame: ExecutionFrame,
|
||||
*,
|
||||
outcome: str,
|
||||
next_node_id: str,
|
||||
) -> None:
|
||||
frame.prior_outcome = outcome
|
||||
frame.activated_incoming_edge = frame.node_id
|
||||
frame.node_id = next_node_id
|
||||
if next_node_id == END:
|
||||
frame.status = FrameStatus.COMPLETED
|
||||
frame.finished_at_node_id = END
|
||||
else:
|
||||
frame.finished_at_node_id = None
|
||||
run.sync_from_current_frame()
|
||||
|
||||
|
||||
def finalize_run(workflow: Workflow, run: RunState) -> RunState:
|
||||
run.output = project_output(workflow, run.state)
|
||||
validate_payload_against_schema(
|
||||
workflow.output_schema, run.output, "workflow output"
|
||||
)
|
||||
run.status = RunStatus.COMPLETED
|
||||
run.current_node_id = END
|
||||
return run
|
||||
|
||||
@@ -1,92 +1,4 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for foreach runtime operations."""
|
||||
|
||||
from .conditions import safe_resolve_path
|
||||
from .errors import WorkflowExecutionError
|
||||
from .flow_ops import advance_frame, append_step_result_trace
|
||||
from .frame_ops import frame_context_values
|
||||
from .model import ForeachNode, Workflow
|
||||
from .run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult
|
||||
from .workflow_index import WorkflowIndex
|
||||
from wf_core.runtime.ops.foreach import * # noqa: F403
|
||||
|
||||
|
||||
def step_foreach(
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
step: ForeachNode,
|
||||
index: WorkflowIndex,
|
||||
) -> RunState:
|
||||
if step.mode != "serial":
|
||||
raise WorkflowExecutionError(
|
||||
"parallel foreach execution is not implemented yet"
|
||||
)
|
||||
|
||||
frame = run.current_frame()
|
||||
progress_map = frame.metadata.setdefault("foreach_progress", {})
|
||||
progress = progress_map.setdefault(step.id, {"index": 0})
|
||||
|
||||
iterable = safe_resolve_path(
|
||||
step.over,
|
||||
state=run.state,
|
||||
workflow_input=run.workflow_input,
|
||||
context=frame_context_values(frame),
|
||||
)
|
||||
if not isinstance(iterable, list):
|
||||
raise WorkflowExecutionError(
|
||||
f"foreach source {step.over!r} must resolve to a list"
|
||||
)
|
||||
|
||||
loop_index = progress["index"]
|
||||
if loop_index >= len(iterable):
|
||||
outcome = "done"
|
||||
next_node_id = index.next_node_id(frame.node_id, outcome)
|
||||
append_step_result_trace(
|
||||
run,
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
step_type=step.type,
|
||||
next_node_id=next_node_id,
|
||||
result=StepExecutionResult(
|
||||
outcome=outcome,
|
||||
resolved_input={"count": len(iterable), "index": loop_index},
|
||||
output={},
|
||||
state_changes={},
|
||||
),
|
||||
)
|
||||
advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id)
|
||||
return run
|
||||
|
||||
loop_start = index.next_node_id(frame.node_id, "loop")
|
||||
|
||||
item = iterable[loop_index]
|
||||
progress["index"] = loop_index + 1
|
||||
child_id = f"{frame.id}:{step.id}:{loop_index}"
|
||||
child_metadata = {
|
||||
"foreach_node_id": step.id,
|
||||
"loop_index": loop_index,
|
||||
"loop_item": item,
|
||||
"loop_alias": step.as_,
|
||||
}
|
||||
run.frames[child_id] = ExecutionFrame(
|
||||
id=child_id,
|
||||
kind="foreach_iteration",
|
||||
node_id=loop_start,
|
||||
status=FrameStatus.PENDING,
|
||||
parent_frame_id=frame.id,
|
||||
metadata=child_metadata,
|
||||
)
|
||||
append_step_result_trace(
|
||||
run,
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
step_type=step.type,
|
||||
next_node_id=loop_start,
|
||||
result=StepExecutionResult(
|
||||
outcome="loop",
|
||||
resolved_input={"item": item, "index": loop_index},
|
||||
output={},
|
||||
state_changes={},
|
||||
),
|
||||
)
|
||||
run.current_frame_id = child_id
|
||||
run.sync_from_current_frame()
|
||||
return run
|
||||
|
||||
@@ -1,36 +1,4 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for frame runtime operations."""
|
||||
|
||||
from .run_state import ExecutionFrame, FrameStatus, RunState
|
||||
from .tokens import END
|
||||
from wf_core.runtime.ops.frames import * # noqa: F403
|
||||
|
||||
|
||||
def collapse_completed_frames(run: RunState) -> None:
|
||||
while run.current_frame_id is not None:
|
||||
frame = run.current_frame()
|
||||
if frame.node_id == END and frame.status != FrameStatus.COMPLETED:
|
||||
frame.status = FrameStatus.COMPLETED
|
||||
frame.finished_at_node_id = END
|
||||
if frame.status != FrameStatus.COMPLETED or frame.parent_frame_id is None:
|
||||
run.sync_from_current_frame()
|
||||
return
|
||||
run.current_frame_id = frame.parent_frame_id
|
||||
parent = run.current_frame()
|
||||
if parent.status == FrameStatus.PENDING:
|
||||
parent.status = FrameStatus.RUNNING
|
||||
run.sync_from_current_frame()
|
||||
|
||||
|
||||
def frame_context_values(frame: ExecutionFrame) -> dict[str, object | None]:
|
||||
context: dict[str, object | None] = {
|
||||
"prior_outcome": frame.prior_outcome,
|
||||
"activated_incoming_edge": frame.activated_incoming_edge,
|
||||
}
|
||||
if frame.kind == "foreach_iteration":
|
||||
loop_item = frame.metadata.get("loop_item")
|
||||
loop_index = frame.metadata.get("loop_index")
|
||||
loop_alias = frame.metadata.get("loop_alias")
|
||||
context["loop_item"] = loop_item
|
||||
context["loop_index"] = loop_index
|
||||
if isinstance(loop_alias, str) and loop_alias:
|
||||
context[loop_alias] = loop_item
|
||||
return context
|
||||
|
||||
@@ -1,88 +1,4 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for interrupt runtime operations."""
|
||||
|
||||
from typing import Any
|
||||
from wf_core.runtime.ops.interrupts import * # noqa: F403
|
||||
|
||||
from .conditions import safe_resolve_path
|
||||
from .errors import WorkflowExecutionError
|
||||
from .flow_ops import advance_frame, append_step_result_trace
|
||||
from .model import InterruptNode, Workflow
|
||||
from .run_state import InterruptRequest, RunState, StepExecutionResult
|
||||
from .state_ops import apply_mapped_state
|
||||
from .workflow_index import WorkflowIndex
|
||||
|
||||
|
||||
def build_interrupt_request(
|
||||
node: InterruptNode,
|
||||
*,
|
||||
frame_id: str,
|
||||
state: dict[str, Any],
|
||||
workflow_input: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> InterruptRequest:
|
||||
payload = {
|
||||
payload_field: safe_resolve_path(
|
||||
source_path,
|
||||
state=state,
|
||||
workflow_input=workflow_input,
|
||||
context=context,
|
||||
)
|
||||
for source_path, payload_field in node.request_map.items()
|
||||
}
|
||||
return InterruptRequest(
|
||||
id=f"interrupt:{node.id}",
|
||||
frame_id=frame_id,
|
||||
node_id=node.id,
|
||||
kind=node.kind,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def resume_interrupt(
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
*,
|
||||
index: WorkflowIndex,
|
||||
resume_payload: dict[str, Any],
|
||||
resume_outcome: str,
|
||||
) -> None:
|
||||
if run.current_frame_id is None:
|
||||
raise WorkflowExecutionError("interrupted run has no current frame")
|
||||
if run.current_node_id is None:
|
||||
raise WorkflowExecutionError("interrupted run has no current node")
|
||||
if run.interrupt is None:
|
||||
raise WorkflowExecutionError("run is interrupted but has no interrupt request")
|
||||
|
||||
frame = run.current_frame()
|
||||
step = index.nodes_by_id[frame.node_id]
|
||||
if not isinstance(step, InterruptNode):
|
||||
raise WorkflowExecutionError(
|
||||
f"interrupted run expected interrupt node, got {step.type!r}"
|
||||
)
|
||||
if resume_outcome not in step.outcomes:
|
||||
raise WorkflowExecutionError(
|
||||
f"interrupt node {step.id!r} does not declare resume outcome {resume_outcome!r}"
|
||||
)
|
||||
|
||||
state_changes = apply_mapped_state(
|
||||
workflow,
|
||||
resume_payload,
|
||||
step.out_map,
|
||||
run.state,
|
||||
missing_field_message="interrupt resume payload is missing required field {field}",
|
||||
)
|
||||
next_node_id = index.next_node_id(frame.node_id, resume_outcome)
|
||||
append_step_result_trace(
|
||||
run,
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
step_type=step.type,
|
||||
next_node_id=next_node_id,
|
||||
result=StepExecutionResult(
|
||||
outcome=resume_outcome,
|
||||
resolved_input=resume_payload,
|
||||
output=resume_payload,
|
||||
state_changes=state_changes,
|
||||
),
|
||||
)
|
||||
run.interrupt = None
|
||||
advance_frame(run, frame, outcome=resume_outcome, next_node_id=next_node_id)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any, Literal
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wf_core.validation.issues import ValidationReport
|
||||
|
||||
|
||||
class SchemaRef(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
@@ -138,10 +141,11 @@ class Workflow(BaseModel):
|
||||
nodes: list[Step]
|
||||
edges: list[Edge]
|
||||
|
||||
def validate_structure(self):
|
||||
from .validate import validate_workflow
|
||||
def validate_structure(self) -> "ValidationReport":
|
||||
from importlib import import_module
|
||||
|
||||
return validate_workflow(self)
|
||||
validation = import_module("wf_core.validation.core")
|
||||
return cast("ValidationReport", validation.validate_workflow(self))
|
||||
|
||||
|
||||
class NodeResult(BaseModel):
|
||||
|
||||
+2
-148
@@ -1,150 +1,4 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for node execution operations."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import Any, cast
|
||||
from wf_core.runtime.ops.nodes import * # noqa: F403
|
||||
|
||||
from .conditions import safe_resolve_path
|
||||
from .errors import WorkflowExecutionError
|
||||
from .frame_ops import frame_context_values
|
||||
from .model import NodeDef, NodeResult, NodeUse, Workflow
|
||||
from .run_state import RunState, RuntimeContext, StepExecutionResult
|
||||
from .schema_tools import validate_payload_against_schema
|
||||
from .state_ops import apply_output_map
|
||||
|
||||
NodeHandler = Callable[[dict[str, Any], RuntimeContext], NodeResult | dict[str, Any]]
|
||||
AsyncNodeHandler = Callable[
|
||||
[dict[str, Any], RuntimeContext],
|
||||
Awaitable[NodeResult | dict[str, Any]] | NodeResult | dict[str, Any],
|
||||
]
|
||||
|
||||
|
||||
def _resolve_node_execution(
|
||||
*,
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
node: NodeUse,
|
||||
node_def: NodeDef,
|
||||
) -> tuple[dict[str, Any], RuntimeContext]:
|
||||
frame = run.current_frame()
|
||||
context_values = frame_context_values(frame)
|
||||
resolved_input = {
|
||||
destination_field: safe_resolve_path(
|
||||
source_path,
|
||||
state=run.state,
|
||||
workflow_input=run.workflow_input,
|
||||
context=context_values,
|
||||
)
|
||||
for source_path, destination_field in node.in_map.items()
|
||||
}
|
||||
validate_payload_against_schema(
|
||||
node_def.input_schema, resolved_input, f"node input for {node.id}"
|
||||
)
|
||||
|
||||
context = RuntimeContext(
|
||||
current_node_id=node.id,
|
||||
frame_id=frame.id,
|
||||
prior_outcome=frame.prior_outcome,
|
||||
activated_incoming_edge=frame.activated_incoming_edge,
|
||||
metadata=dict(frame.metadata),
|
||||
)
|
||||
return resolved_input, context
|
||||
|
||||
|
||||
def _finalize_node_execution(
|
||||
*,
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
node: NodeUse,
|
||||
node_def: NodeDef,
|
||||
resolved_input: dict[str, Any],
|
||||
raw_result: NodeResult | dict[str, Any],
|
||||
) -> StepExecutionResult:
|
||||
result = coerce_node_result(raw_result)
|
||||
|
||||
if result.outcome not in node_def.outcomes:
|
||||
raise WorkflowExecutionError(
|
||||
f"node {node.id!r} returned undeclared outcome {result.outcome!r}"
|
||||
)
|
||||
|
||||
validate_payload_against_schema(
|
||||
node_def.output_schema, result.output, f"node output for {node.id}"
|
||||
)
|
||||
state_changes = apply_output_map(workflow, node, result.output, run.state)
|
||||
return StepExecutionResult(
|
||||
outcome=result.outcome,
|
||||
resolved_input=resolved_input,
|
||||
output=result.output,
|
||||
state_changes=state_changes,
|
||||
)
|
||||
|
||||
|
||||
def execute_node_use(
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
node: NodeUse,
|
||||
node_def: NodeDef,
|
||||
registry: Mapping[str, NodeHandler],
|
||||
) -> StepExecutionResult:
|
||||
handler = registry.get(node.node)
|
||||
if handler is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"no handler registered for node def {node.node!r}"
|
||||
)
|
||||
|
||||
resolved_input, context = _resolve_node_execution(
|
||||
workflow=workflow,
|
||||
run=run,
|
||||
node=node,
|
||||
node_def=node_def,
|
||||
)
|
||||
raw_result = handler(resolved_input, context)
|
||||
return _finalize_node_execution(
|
||||
workflow=workflow,
|
||||
run=run,
|
||||
node=node,
|
||||
node_def=node_def,
|
||||
resolved_input=resolved_input,
|
||||
raw_result=raw_result,
|
||||
)
|
||||
|
||||
|
||||
async def execute_node_use_async(
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
node: NodeUse,
|
||||
node_def: NodeDef,
|
||||
registry: Mapping[str, AsyncNodeHandler],
|
||||
) -> StepExecutionResult:
|
||||
handler = registry.get(node.node)
|
||||
if handler is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"no handler registered for node def {node.node!r}"
|
||||
)
|
||||
|
||||
resolved_input, context = _resolve_node_execution(
|
||||
workflow=workflow,
|
||||
run=run,
|
||||
node=node,
|
||||
node_def=node_def,
|
||||
)
|
||||
raw_or_awaitable = handler(resolved_input, context)
|
||||
if isinstance(raw_or_awaitable, Awaitable):
|
||||
raw_result = await raw_or_awaitable
|
||||
else:
|
||||
raw_result = raw_or_awaitable
|
||||
return _finalize_node_execution(
|
||||
workflow=workflow,
|
||||
run=run,
|
||||
node=node,
|
||||
node_def=node_def,
|
||||
resolved_input=resolved_input,
|
||||
raw_result=cast(NodeResult | dict[str, Any], raw_result),
|
||||
)
|
||||
|
||||
|
||||
def coerce_node_result(raw_result: NodeResult | dict[str, Any]) -> NodeResult:
|
||||
if isinstance(raw_result, NodeResult):
|
||||
return raw_result
|
||||
if "outcome" in raw_result and "output" in raw_result:
|
||||
return NodeResult.model_validate(raw_result)
|
||||
return NodeResult(outcome="ok", output=raw_result)
|
||||
|
||||
@@ -1,33 +1,4 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for run-state construction."""
|
||||
|
||||
from copy import deepcopy
|
||||
from wf_core.runtime.ops.runs import * # noqa: F403
|
||||
|
||||
from .model import Workflow
|
||||
from .run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
|
||||
|
||||
|
||||
def create_run_state(workflow: Workflow, workflow_input: dict[str, object]) -> RunState:
|
||||
state = {
|
||||
name: deepcopy(field.default)
|
||||
for name, field in workflow.state_schema.fields.items()
|
||||
if field.default is not None
|
||||
}
|
||||
state.update(dict(workflow_input))
|
||||
run = RunState(
|
||||
workflow_name=workflow.name,
|
||||
status=RunStatus.PENDING,
|
||||
workflow_input=dict(workflow_input),
|
||||
state=state,
|
||||
frames={
|
||||
"root": ExecutionFrame(
|
||||
id="root",
|
||||
kind="workflow",
|
||||
node_id=workflow.start,
|
||||
status=FrameStatus.PENDING,
|
||||
)
|
||||
},
|
||||
current_frame_id="root",
|
||||
current_node_id=workflow.start,
|
||||
)
|
||||
run.sync_from_current_frame()
|
||||
return run
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.node_exec import (
|
||||
from wf_core.runtime.ops.nodes import (
|
||||
AsyncNodeHandler,
|
||||
NodeHandler,
|
||||
coerce_node_result,
|
||||
|
||||
@@ -3,11 +3,11 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from wf_core.flow_ops import finalize_run
|
||||
from wf_core.frame_ops import collapse_completed_frames
|
||||
from wf_core.model import Workflow
|
||||
from wf_core.node_exec import AsyncNodeHandler, NodeHandler
|
||||
from wf_core.run_factory import create_run_state
|
||||
from wf_core.runtime.ops.flow import finalize_run
|
||||
from wf_core.runtime.ops.frames import collapse_completed_frames
|
||||
from wf_core.runtime.ops.nodes import AsyncNodeHandler, NodeHandler
|
||||
from wf_core.runtime.ops.runs import create_run_state
|
||||
from wf_core.run_state import RunState, RunStatus
|
||||
from wf_core.tokens import END
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Executor-only operations used by `wf_core.runtime`.
|
||||
|
||||
Root modules such as `wf_core.node_exec` remain as compatibility shims. New
|
||||
runtime internals should import from this package so the execution seam stays
|
||||
easy to navigate.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from wf_core.model import Workflow
|
||||
from wf_core.run_state import (
|
||||
ExecutionFrame,
|
||||
FrameStatus,
|
||||
RunState,
|
||||
RunStatus,
|
||||
StepExecutionResult,
|
||||
TraceEntry,
|
||||
)
|
||||
from wf_core.runtime.ops.schemas import validate_payload_against_schema
|
||||
from wf_core.runtime.ops.state import project_output
|
||||
from wf_core.tokens import END
|
||||
|
||||
|
||||
def append_trace(
|
||||
run: RunState,
|
||||
*,
|
||||
frame_id: str,
|
||||
node_id: str,
|
||||
step_type: str,
|
||||
resolved_input: dict[str, Any],
|
||||
outcome: str,
|
||||
next_node_id: str,
|
||||
output: dict[str, Any],
|
||||
state_changes: dict[str, Any],
|
||||
) -> None:
|
||||
run.trace.append(
|
||||
TraceEntry(
|
||||
frame_id=frame_id,
|
||||
node_id=node_id,
|
||||
step_type=step_type,
|
||||
resolved_input=resolved_input,
|
||||
outcome=outcome,
|
||||
next_node_id=next_node_id,
|
||||
output=output,
|
||||
state_changes=state_changes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def append_step_result_trace(
|
||||
run: RunState,
|
||||
*,
|
||||
frame_id: str,
|
||||
node_id: str,
|
||||
step_type: str,
|
||||
next_node_id: str,
|
||||
result: StepExecutionResult,
|
||||
) -> None:
|
||||
append_trace(
|
||||
run,
|
||||
frame_id=frame_id,
|
||||
node_id=node_id,
|
||||
step_type=step_type,
|
||||
resolved_input=result.resolved_input,
|
||||
outcome=result.outcome,
|
||||
next_node_id=next_node_id,
|
||||
output=result.output,
|
||||
state_changes=result.state_changes,
|
||||
)
|
||||
|
||||
|
||||
def advance_frame(
|
||||
run: RunState,
|
||||
frame: ExecutionFrame,
|
||||
*,
|
||||
outcome: str,
|
||||
next_node_id: str,
|
||||
) -> None:
|
||||
frame.prior_outcome = outcome
|
||||
frame.activated_incoming_edge = frame.node_id
|
||||
frame.node_id = next_node_id
|
||||
if next_node_id == END:
|
||||
frame.status = FrameStatus.COMPLETED
|
||||
frame.finished_at_node_id = END
|
||||
else:
|
||||
frame.finished_at_node_id = None
|
||||
run.sync_from_current_frame()
|
||||
|
||||
|
||||
def finalize_run(workflow: Workflow, run: RunState) -> RunState:
|
||||
run.output = project_output(workflow, run.state)
|
||||
validate_payload_against_schema(
|
||||
workflow.output_schema, run.output, "workflow output"
|
||||
)
|
||||
run.status = RunStatus.COMPLETED
|
||||
run.current_node_id = END
|
||||
return run
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.conditions import safe_resolve_path
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.model import ForeachNode, Workflow
|
||||
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult
|
||||
from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace
|
||||
from wf_core.runtime.ops.frames import frame_context_values
|
||||
from wf_core.runtime.ops.index import WorkflowIndex
|
||||
|
||||
|
||||
def step_foreach(
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
step: ForeachNode,
|
||||
index: WorkflowIndex,
|
||||
) -> RunState:
|
||||
if step.mode != "serial":
|
||||
raise WorkflowExecutionError(
|
||||
"parallel foreach execution is not implemented yet"
|
||||
)
|
||||
|
||||
frame = run.current_frame()
|
||||
progress_map = frame.metadata.setdefault("foreach_progress", {})
|
||||
progress = progress_map.setdefault(step.id, {"index": 0})
|
||||
|
||||
iterable = safe_resolve_path(
|
||||
step.over,
|
||||
state=run.state,
|
||||
workflow_input=run.workflow_input,
|
||||
context=frame_context_values(frame),
|
||||
)
|
||||
if not isinstance(iterable, list):
|
||||
raise WorkflowExecutionError(
|
||||
f"foreach source {step.over!r} must resolve to a list"
|
||||
)
|
||||
|
||||
loop_index = progress["index"]
|
||||
if loop_index >= len(iterable):
|
||||
outcome = "done"
|
||||
next_node_id = index.next_node_id(frame.node_id, outcome)
|
||||
append_step_result_trace(
|
||||
run,
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
step_type=step.type,
|
||||
next_node_id=next_node_id,
|
||||
result=StepExecutionResult(
|
||||
outcome=outcome,
|
||||
resolved_input={"count": len(iterable), "index": loop_index},
|
||||
output={},
|
||||
state_changes={},
|
||||
),
|
||||
)
|
||||
advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id)
|
||||
return run
|
||||
|
||||
loop_start = index.next_node_id(frame.node_id, "loop")
|
||||
|
||||
item = iterable[loop_index]
|
||||
progress["index"] = loop_index + 1
|
||||
child_id = f"{frame.id}:{step.id}:{loop_index}"
|
||||
child_metadata = {
|
||||
"foreach_node_id": step.id,
|
||||
"loop_index": loop_index,
|
||||
"loop_item": item,
|
||||
"loop_alias": step.as_,
|
||||
}
|
||||
run.frames[child_id] = ExecutionFrame(
|
||||
id=child_id,
|
||||
kind="foreach_iteration",
|
||||
node_id=loop_start,
|
||||
status=FrameStatus.PENDING,
|
||||
parent_frame_id=frame.id,
|
||||
metadata=child_metadata,
|
||||
)
|
||||
append_step_result_trace(
|
||||
run,
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
step_type=step.type,
|
||||
next_node_id=loop_start,
|
||||
result=StepExecutionResult(
|
||||
outcome="loop",
|
||||
resolved_input={"item": item, "index": loop_index},
|
||||
output={},
|
||||
state_changes={},
|
||||
),
|
||||
)
|
||||
run.current_frame_id = child_id
|
||||
run.sync_from_current_frame()
|
||||
return run
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState
|
||||
from wf_core.tokens import END
|
||||
|
||||
|
||||
def collapse_completed_frames(run: RunState) -> None:
|
||||
while run.current_frame_id is not None:
|
||||
frame = run.current_frame()
|
||||
if frame.node_id == END and frame.status != FrameStatus.COMPLETED:
|
||||
frame.status = FrameStatus.COMPLETED
|
||||
frame.finished_at_node_id = END
|
||||
if frame.status != FrameStatus.COMPLETED or frame.parent_frame_id is None:
|
||||
run.sync_from_current_frame()
|
||||
return
|
||||
run.current_frame_id = frame.parent_frame_id
|
||||
parent = run.current_frame()
|
||||
if parent.status == FrameStatus.PENDING:
|
||||
parent.status = FrameStatus.RUNNING
|
||||
run.sync_from_current_frame()
|
||||
|
||||
|
||||
def frame_context_values(frame: ExecutionFrame) -> dict[str, object | None]:
|
||||
context: dict[str, object | None] = {
|
||||
"prior_outcome": frame.prior_outcome,
|
||||
"activated_incoming_edge": frame.activated_incoming_edge,
|
||||
}
|
||||
if frame.kind == "foreach_iteration":
|
||||
loop_item = frame.metadata.get("loop_item")
|
||||
loop_index = frame.metadata.get("loop_index")
|
||||
loop_alias = frame.metadata.get("loop_alias")
|
||||
context["loop_item"] = loop_item
|
||||
context["loop_index"] = loop_index
|
||||
if isinstance(loop_alias, str) and loop_alias:
|
||||
context[loop_alias] = loop_item
|
||||
return context
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.conditions import eval_condition
|
||||
from wf_core.model import ConditionNode, InterruptNode
|
||||
from wf_core.run_state import FrameStatus, RunState, RunStatus, StepExecutionResult
|
||||
from wf_core.runtime.ops.flow import append_trace
|
||||
from wf_core.runtime.ops.frames import frame_context_values
|
||||
from wf_core.runtime.ops.interrupts import build_interrupt_request
|
||||
|
||||
|
||||
def handle_condition_step(
|
||||
run: RunState,
|
||||
step: ConditionNode,
|
||||
) -> StepExecutionResult:
|
||||
frame = run.current_frame()
|
||||
predicate = eval_condition(
|
||||
step.check,
|
||||
run.state,
|
||||
run.workflow_input,
|
||||
frame.prior_outcome,
|
||||
)
|
||||
outcome = "true" if predicate else "false"
|
||||
return StepExecutionResult(
|
||||
outcome=outcome,
|
||||
resolved_input={},
|
||||
output={"predicate": predicate},
|
||||
state_changes={},
|
||||
)
|
||||
|
||||
|
||||
def handle_join_step() -> StepExecutionResult:
|
||||
return StepExecutionResult(
|
||||
outcome="done",
|
||||
resolved_input={},
|
||||
output={},
|
||||
state_changes={},
|
||||
)
|
||||
|
||||
|
||||
def handle_interrupt_step(
|
||||
run: RunState,
|
||||
step: InterruptNode,
|
||||
) -> RunState:
|
||||
frame = run.current_frame()
|
||||
interrupt_request = build_interrupt_request(
|
||||
step,
|
||||
frame_id=frame.id,
|
||||
state=run.state,
|
||||
workflow_input=run.workflow_input,
|
||||
context=frame_context_values(frame),
|
||||
)
|
||||
run.interrupt = interrupt_request
|
||||
run.status = RunStatus.INTERRUPTED
|
||||
frame.status = FrameStatus.INTERRUPTED
|
||||
append_trace(
|
||||
run,
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
step_type=step.type,
|
||||
resolved_input=interrupt_request.payload,
|
||||
outcome="interrupt",
|
||||
next_node_id=frame.node_id,
|
||||
output=interrupt_request.payload,
|
||||
state_changes={},
|
||||
)
|
||||
return run
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.model import NodeDef, Workflow
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkflowIndex:
|
||||
node_defs: dict[str, NodeDef]
|
||||
nodes_by_id: dict[str, Any]
|
||||
edge_map: dict[tuple[str, str], str]
|
||||
|
||||
def next_node_id(self, node_id: str, outcome: str) -> str:
|
||||
next_node_id = self.edge_map.get((node_id, outcome))
|
||||
if next_node_id is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"no edge found for node {node_id!r} and outcome {outcome!r}"
|
||||
)
|
||||
return next_node_id
|
||||
|
||||
|
||||
def build_workflow_index(workflow: Workflow) -> WorkflowIndex:
|
||||
return WorkflowIndex(
|
||||
node_defs={node_def.name: node_def for node_def in workflow.node_defs},
|
||||
nodes_by_id={node.id: node for node in workflow.nodes},
|
||||
edge_map={(edge.from_, edge.outcome): edge.to for edge in workflow.edges},
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from wf_core.conditions import safe_resolve_path
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.model import InterruptNode, Workflow
|
||||
from wf_core.run_state import InterruptRequest, RunState, StepExecutionResult
|
||||
from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace
|
||||
from wf_core.runtime.ops.index import WorkflowIndex
|
||||
from wf_core.runtime.ops.state import apply_mapped_state
|
||||
|
||||
|
||||
def build_interrupt_request(
|
||||
node: InterruptNode,
|
||||
*,
|
||||
frame_id: str,
|
||||
state: dict[str, Any],
|
||||
workflow_input: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> InterruptRequest:
|
||||
payload = {
|
||||
payload_field: safe_resolve_path(
|
||||
source_path,
|
||||
state=state,
|
||||
workflow_input=workflow_input,
|
||||
context=context,
|
||||
)
|
||||
for source_path, payload_field in node.request_map.items()
|
||||
}
|
||||
return InterruptRequest(
|
||||
id=f"interrupt:{node.id}",
|
||||
frame_id=frame_id,
|
||||
node_id=node.id,
|
||||
kind=node.kind,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def resume_interrupt(
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
*,
|
||||
index: WorkflowIndex,
|
||||
resume_payload: dict[str, Any],
|
||||
resume_outcome: str,
|
||||
) -> None:
|
||||
if run.current_frame_id is None:
|
||||
raise WorkflowExecutionError("interrupted run has no current frame")
|
||||
if run.current_node_id is None:
|
||||
raise WorkflowExecutionError("interrupted run has no current node")
|
||||
if run.interrupt is None:
|
||||
raise WorkflowExecutionError("run is interrupted but has no interrupt request")
|
||||
|
||||
frame = run.current_frame()
|
||||
step = index.nodes_by_id[frame.node_id]
|
||||
if not isinstance(step, InterruptNode):
|
||||
raise WorkflowExecutionError(
|
||||
f"interrupted run expected interrupt node, got {step.type!r}"
|
||||
)
|
||||
if resume_outcome not in step.outcomes:
|
||||
raise WorkflowExecutionError(
|
||||
f"interrupt node {step.id!r} does not declare resume outcome {resume_outcome!r}"
|
||||
)
|
||||
|
||||
state_changes = apply_mapped_state(
|
||||
workflow,
|
||||
resume_payload,
|
||||
step.out_map,
|
||||
run.state,
|
||||
missing_field_message="interrupt resume payload is missing required field {field}",
|
||||
)
|
||||
next_node_id = index.next_node_id(frame.node_id, resume_outcome)
|
||||
append_step_result_trace(
|
||||
run,
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
step_type=step.type,
|
||||
next_node_id=next_node_id,
|
||||
result=StepExecutionResult(
|
||||
outcome=resume_outcome,
|
||||
resolved_input=resume_payload,
|
||||
output=resume_payload,
|
||||
state_changes=state_changes,
|
||||
),
|
||||
)
|
||||
run.interrupt = None
|
||||
advance_frame(run, frame, outcome=resume_outcome, next_node_id=next_node_id)
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
from wf_core.conditions import safe_resolve_path
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.model import NodeDef, NodeResult, NodeUse, Workflow
|
||||
from wf_core.run_state import RunState, RuntimeContext, StepExecutionResult
|
||||
from wf_core.runtime.ops.frames import frame_context_values
|
||||
from wf_core.runtime.ops.schemas import validate_payload_against_schema
|
||||
from wf_core.runtime.ops.state import apply_output_map
|
||||
|
||||
NodeHandler = Callable[[dict[str, Any], RuntimeContext], NodeResult | dict[str, Any]]
|
||||
AsyncNodeHandler = Callable[
|
||||
[dict[str, Any], RuntimeContext],
|
||||
Awaitable[NodeResult | dict[str, Any]] | NodeResult | dict[str, Any],
|
||||
]
|
||||
|
||||
|
||||
def _resolve_node_execution(
|
||||
*,
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
node: NodeUse,
|
||||
node_def: NodeDef,
|
||||
) -> tuple[dict[str, Any], RuntimeContext]:
|
||||
frame = run.current_frame()
|
||||
context_values = frame_context_values(frame)
|
||||
resolved_input = {
|
||||
destination_field: safe_resolve_path(
|
||||
source_path,
|
||||
state=run.state,
|
||||
workflow_input=run.workflow_input,
|
||||
context=context_values,
|
||||
)
|
||||
for source_path, destination_field in node.in_map.items()
|
||||
}
|
||||
validate_payload_against_schema(
|
||||
node_def.input_schema, resolved_input, f"node input for {node.id}"
|
||||
)
|
||||
|
||||
context = RuntimeContext(
|
||||
current_node_id=node.id,
|
||||
frame_id=frame.id,
|
||||
prior_outcome=frame.prior_outcome,
|
||||
activated_incoming_edge=frame.activated_incoming_edge,
|
||||
metadata=dict(frame.metadata),
|
||||
)
|
||||
return resolved_input, context
|
||||
|
||||
|
||||
def _finalize_node_execution(
|
||||
*,
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
node: NodeUse,
|
||||
node_def: NodeDef,
|
||||
resolved_input: dict[str, Any],
|
||||
raw_result: NodeResult | dict[str, Any],
|
||||
) -> StepExecutionResult:
|
||||
result = coerce_node_result(raw_result)
|
||||
|
||||
if result.outcome not in node_def.outcomes:
|
||||
raise WorkflowExecutionError(
|
||||
f"node {node.id!r} returned undeclared outcome {result.outcome!r}"
|
||||
)
|
||||
|
||||
validate_payload_against_schema(
|
||||
node_def.output_schema, result.output, f"node output for {node.id}"
|
||||
)
|
||||
state_changes = apply_output_map(workflow, node, result.output, run.state)
|
||||
return StepExecutionResult(
|
||||
outcome=result.outcome,
|
||||
resolved_input=resolved_input,
|
||||
output=result.output,
|
||||
state_changes=state_changes,
|
||||
)
|
||||
|
||||
|
||||
def execute_node_use(
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
node: NodeUse,
|
||||
node_def: NodeDef,
|
||||
registry: Mapping[str, NodeHandler],
|
||||
) -> StepExecutionResult:
|
||||
handler = registry.get(node.node)
|
||||
if handler is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"no handler registered for node def {node.node!r}"
|
||||
)
|
||||
|
||||
resolved_input, context = _resolve_node_execution(
|
||||
workflow=workflow,
|
||||
run=run,
|
||||
node=node,
|
||||
node_def=node_def,
|
||||
)
|
||||
raw_result = handler(resolved_input, context)
|
||||
return _finalize_node_execution(
|
||||
workflow=workflow,
|
||||
run=run,
|
||||
node=node,
|
||||
node_def=node_def,
|
||||
resolved_input=resolved_input,
|
||||
raw_result=raw_result,
|
||||
)
|
||||
|
||||
|
||||
async def execute_node_use_async(
|
||||
workflow: Workflow,
|
||||
run: RunState,
|
||||
node: NodeUse,
|
||||
node_def: NodeDef,
|
||||
registry: Mapping[str, AsyncNodeHandler],
|
||||
) -> StepExecutionResult:
|
||||
handler = registry.get(node.node)
|
||||
if handler is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"no handler registered for node def {node.node!r}"
|
||||
)
|
||||
|
||||
resolved_input, context = _resolve_node_execution(
|
||||
workflow=workflow,
|
||||
run=run,
|
||||
node=node,
|
||||
node_def=node_def,
|
||||
)
|
||||
raw_or_awaitable = handler(resolved_input, context)
|
||||
if isinstance(raw_or_awaitable, Awaitable):
|
||||
raw_result = await raw_or_awaitable
|
||||
else:
|
||||
raw_result = raw_or_awaitable
|
||||
return _finalize_node_execution(
|
||||
workflow=workflow,
|
||||
run=run,
|
||||
node=node,
|
||||
node_def=node_def,
|
||||
resolved_input=resolved_input,
|
||||
raw_result=cast(NodeResult | dict[str, Any], raw_result),
|
||||
)
|
||||
|
||||
|
||||
def coerce_node_result(raw_result: NodeResult | dict[str, Any]) -> NodeResult:
|
||||
if isinstance(raw_result, NodeResult):
|
||||
return raw_result
|
||||
if "outcome" in raw_result and "output" in raw_result:
|
||||
return NodeResult.model_validate(raw_result)
|
||||
return NodeResult(outcome="ok", output=raw_result)
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from wf_core.model import Workflow
|
||||
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
|
||||
|
||||
|
||||
def create_run_state(workflow: Workflow, workflow_input: dict[str, object]) -> RunState:
|
||||
state = {
|
||||
name: deepcopy(field.default)
|
||||
for name, field in workflow.state_schema.fields.items()
|
||||
if field.default is not None
|
||||
}
|
||||
state.update(dict(workflow_input))
|
||||
run = RunState(
|
||||
workflow_name=workflow.name,
|
||||
status=RunStatus.PENDING,
|
||||
workflow_input=dict(workflow_input),
|
||||
state=state,
|
||||
frames={
|
||||
"root": ExecutionFrame(
|
||||
id="root",
|
||||
kind="workflow",
|
||||
node_id=workflow.start,
|
||||
status=FrameStatus.PENDING,
|
||||
)
|
||||
},
|
||||
current_frame_id="root",
|
||||
current_node_id=workflow.start,
|
||||
)
|
||||
run.sync_from_current_frame()
|
||||
return run
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
|
||||
|
||||
def validate_payload_against_schema(schema: Any, payload: Any, label: str) -> None:
|
||||
if schema.type == "object":
|
||||
if not isinstance(payload, dict):
|
||||
raise WorkflowExecutionError(f"{label} must be an object")
|
||||
for required_key in schema.required:
|
||||
if required_key not in payload:
|
||||
raise WorkflowExecutionError(
|
||||
f"{label} is missing required field {required_key!r}"
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.model import NodeUse, Workflow
|
||||
from wf_core.paths import (
|
||||
PathResolutionError,
|
||||
get_nested_value,
|
||||
set_nested_value,
|
||||
split_graph_path,
|
||||
)
|
||||
|
||||
|
||||
def apply_output_map(
|
||||
workflow: Workflow,
|
||||
node: NodeUse,
|
||||
node_output: dict[str, Any],
|
||||
state: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return apply_mapped_state(
|
||||
workflow,
|
||||
node_output,
|
||||
node.out_map,
|
||||
state,
|
||||
missing_field_message=f"node {node.id!r} did not return required mapped field {{field}}",
|
||||
)
|
||||
|
||||
|
||||
def apply_mapped_state(
|
||||
workflow: Workflow,
|
||||
source_data: dict[str, Any],
|
||||
mapping: dict[str, str],
|
||||
state: dict[str, Any],
|
||||
*,
|
||||
missing_field_message: str,
|
||||
) -> dict[str, Any]:
|
||||
state_changes: dict[str, Any] = {}
|
||||
for source_field, destination_path in mapping.items():
|
||||
if source_field not in source_data:
|
||||
raise WorkflowExecutionError(
|
||||
missing_field_message.format(field=repr(source_field))
|
||||
)
|
||||
value = source_data[source_field]
|
||||
write_state_value(workflow, state, destination_path, value)
|
||||
state_changes[destination_path] = value
|
||||
return state_changes
|
||||
|
||||
|
||||
def write_state_value(
|
||||
workflow: Workflow, state: dict[str, Any], destination_path: str, value: Any
|
||||
) -> None:
|
||||
try:
|
||||
root, parts = split_graph_path(destination_path)
|
||||
except PathResolutionError as exc:
|
||||
raise WorkflowExecutionError(str(exc)) from exc
|
||||
|
||||
if root != "state":
|
||||
raise WorkflowExecutionError(
|
||||
f"executor only supports writes into state.*, got {destination_path!r}"
|
||||
)
|
||||
|
||||
field_name = parts[0]
|
||||
declared_field = workflow.state_schema.fields.get(field_name)
|
||||
merge_strategy = declared_field.merge_strategy if declared_field else "replace"
|
||||
key_path = parts
|
||||
|
||||
if merge_strategy == "replace":
|
||||
safe_set_nested_value(state, key_path, value)
|
||||
return
|
||||
|
||||
current_value = get_nested_value(state, key_path)
|
||||
if merge_strategy == "append":
|
||||
if current_value is None:
|
||||
safe_set_nested_value(
|
||||
state, key_path, [value] if not isinstance(value, list) else value
|
||||
)
|
||||
return
|
||||
if not isinstance(current_value, list):
|
||||
raise WorkflowExecutionError(
|
||||
f"cannot append into non-list state path {destination_path!r}"
|
||||
)
|
||||
if isinstance(value, list):
|
||||
current_value.extend(value)
|
||||
else:
|
||||
current_value.append(value)
|
||||
return
|
||||
|
||||
if merge_strategy == "merge_object":
|
||||
if current_value is None:
|
||||
if not isinstance(value, dict):
|
||||
raise WorkflowExecutionError(
|
||||
f"cannot merge non-object value into {destination_path!r}"
|
||||
)
|
||||
safe_set_nested_value(state, key_path, dict(value))
|
||||
return
|
||||
if not isinstance(current_value, dict) or not isinstance(value, dict):
|
||||
raise WorkflowExecutionError(
|
||||
f"merge_object requires dict values at {destination_path!r}"
|
||||
)
|
||||
current_value.update(value)
|
||||
return
|
||||
|
||||
raise WorkflowExecutionError(f"unknown merge strategy {merge_strategy!r}")
|
||||
|
||||
|
||||
def project_output(workflow: Workflow, state: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: state[key] for key in workflow.output_schema.properties if key in state
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -3,14 +3,14 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.frame_ops import collapse_completed_frames
|
||||
from wf_core.interrupt_ops import resume_interrupt
|
||||
from wf_core.model import Workflow
|
||||
from wf_core.run_factory import create_run_state
|
||||
from wf_core.runtime.ops.frames import collapse_completed_frames
|
||||
from wf_core.runtime.ops.index import WorkflowIndex, build_workflow_index
|
||||
from wf_core.runtime.ops.interrupts import resume_interrupt
|
||||
from wf_core.runtime.ops.runs import create_run_state
|
||||
from wf_core.runtime.ops.schemas import validate_payload_against_schema
|
||||
from wf_core.run_state import FrameStatus, RunState, RunStatus
|
||||
from wf_core.schema_tools import validate_payload_against_schema
|
||||
from wf_core.tokens import END
|
||||
from wf_core.workflow_index import WorkflowIndex, build_workflow_index
|
||||
|
||||
|
||||
def prepare_new_run(workflow: Workflow, workflow_input: dict[str, Any]) -> RunState:
|
||||
|
||||
@@ -4,8 +4,6 @@ from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.flow_ops import advance_frame, append_step_result_trace
|
||||
from wf_core.foreach_ops import step_foreach
|
||||
from wf_core.model import (
|
||||
ConditionNode,
|
||||
ForeachNode,
|
||||
@@ -14,19 +12,21 @@ from wf_core.model import (
|
||||
NodeUse,
|
||||
Workflow,
|
||||
)
|
||||
from wf_core.node_exec import (
|
||||
from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace
|
||||
from wf_core.runtime.ops.foreach import step_foreach
|
||||
from wf_core.runtime.ops.handlers import (
|
||||
handle_condition_step,
|
||||
handle_interrupt_step,
|
||||
handle_join_step,
|
||||
)
|
||||
from wf_core.runtime.ops.index import WorkflowIndex
|
||||
from wf_core.runtime.ops.nodes import (
|
||||
AsyncNodeHandler,
|
||||
NodeHandler,
|
||||
execute_node_use,
|
||||
execute_node_use_async,
|
||||
)
|
||||
from wf_core.run_state import RunState
|
||||
from wf_core.step_handlers import (
|
||||
handle_condition_step,
|
||||
handle_interrupt_step,
|
||||
handle_join_step,
|
||||
)
|
||||
from wf_core.workflow_index import WorkflowIndex
|
||||
|
||||
from .preparation import prepare_step
|
||||
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for runtime schema validation helpers."""
|
||||
|
||||
from typing import Any
|
||||
from wf_core.runtime.ops.schemas import * # noqa: F403
|
||||
|
||||
from .errors import WorkflowExecutionError
|
||||
|
||||
|
||||
def validate_payload_against_schema(schema: Any, payload: Any, label: str) -> None:
|
||||
if schema.type == "object":
|
||||
if not isinstance(payload, dict):
|
||||
raise WorkflowExecutionError(f"{label} must be an object")
|
||||
for required_key in schema.required:
|
||||
if required_key not in payload:
|
||||
raise WorkflowExecutionError(
|
||||
f"{label} is missing required field {required_key!r}"
|
||||
)
|
||||
|
||||
+2
-117
@@ -1,119 +1,4 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for state mutation operations."""
|
||||
|
||||
from typing import Any
|
||||
from wf_core.runtime.ops.state import * # noqa: F403
|
||||
|
||||
from .errors import WorkflowExecutionError
|
||||
from .model import NodeUse, Workflow
|
||||
from .paths import (
|
||||
PathResolutionError,
|
||||
get_nested_value,
|
||||
set_nested_value,
|
||||
split_graph_path,
|
||||
)
|
||||
|
||||
|
||||
def apply_output_map(
|
||||
workflow: Workflow,
|
||||
node: NodeUse,
|
||||
node_output: dict[str, Any],
|
||||
state: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return apply_mapped_state(
|
||||
workflow,
|
||||
node_output,
|
||||
node.out_map,
|
||||
state,
|
||||
missing_field_message=f"node {node.id!r} did not return required mapped field {{field}}",
|
||||
)
|
||||
|
||||
|
||||
def apply_mapped_state(
|
||||
workflow: Workflow,
|
||||
source_data: dict[str, Any],
|
||||
mapping: dict[str, str],
|
||||
state: dict[str, Any],
|
||||
*,
|
||||
missing_field_message: str,
|
||||
) -> dict[str, Any]:
|
||||
state_changes: dict[str, Any] = {}
|
||||
for source_field, destination_path in mapping.items():
|
||||
if source_field not in source_data:
|
||||
raise WorkflowExecutionError(
|
||||
missing_field_message.format(field=repr(source_field))
|
||||
)
|
||||
value = source_data[source_field]
|
||||
write_state_value(workflow, state, destination_path, value)
|
||||
state_changes[destination_path] = value
|
||||
return state_changes
|
||||
|
||||
|
||||
def write_state_value(
|
||||
workflow: Workflow, state: dict[str, Any], destination_path: str, value: Any
|
||||
) -> None:
|
||||
try:
|
||||
root, parts = split_graph_path(destination_path)
|
||||
except PathResolutionError as exc:
|
||||
raise WorkflowExecutionError(str(exc)) from exc
|
||||
|
||||
if root != "state":
|
||||
raise WorkflowExecutionError(
|
||||
f"executor only supports writes into state.*, got {destination_path!r}"
|
||||
)
|
||||
|
||||
field_name = parts[0]
|
||||
declared_field = workflow.state_schema.fields.get(field_name)
|
||||
merge_strategy = declared_field.merge_strategy if declared_field else "replace"
|
||||
key_path = parts
|
||||
|
||||
if merge_strategy == "replace":
|
||||
safe_set_nested_value(state, key_path, value)
|
||||
return
|
||||
|
||||
current_value = get_nested_value(state, key_path)
|
||||
if merge_strategy == "append":
|
||||
if current_value is None:
|
||||
safe_set_nested_value(
|
||||
state, key_path, [value] if not isinstance(value, list) else value
|
||||
)
|
||||
return
|
||||
if not isinstance(current_value, list):
|
||||
raise WorkflowExecutionError(
|
||||
f"cannot append into non-list state path {destination_path!r}"
|
||||
)
|
||||
if isinstance(value, list):
|
||||
current_value.extend(value)
|
||||
else:
|
||||
current_value.append(value)
|
||||
return
|
||||
|
||||
if merge_strategy == "merge_object":
|
||||
if current_value is None:
|
||||
if not isinstance(value, dict):
|
||||
raise WorkflowExecutionError(
|
||||
f"cannot merge non-object value into {destination_path!r}"
|
||||
)
|
||||
safe_set_nested_value(state, key_path, dict(value))
|
||||
return
|
||||
if not isinstance(current_value, dict) or not isinstance(value, dict):
|
||||
raise WorkflowExecutionError(
|
||||
f"merge_object requires dict values at {destination_path!r}"
|
||||
)
|
||||
current_value.update(value)
|
||||
return
|
||||
|
||||
raise WorkflowExecutionError(f"unknown merge strategy {merge_strategy!r}")
|
||||
|
||||
|
||||
def project_output(workflow: Workflow, state: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: state[key] for key in workflow.output_schema.properties if key in state
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,66 +1,4 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for non-node step handlers."""
|
||||
|
||||
from .conditions import eval_condition
|
||||
from .flow_ops import append_trace
|
||||
from .frame_ops import frame_context_values
|
||||
from .interrupt_ops import build_interrupt_request
|
||||
from .model import ConditionNode, InterruptNode
|
||||
from .run_state import FrameStatus, RunState, RunStatus, StepExecutionResult
|
||||
from wf_core.runtime.ops.handlers import * # noqa: F403
|
||||
|
||||
|
||||
def handle_condition_step(
|
||||
run: RunState,
|
||||
step: ConditionNode,
|
||||
) -> StepExecutionResult:
|
||||
frame = run.current_frame()
|
||||
predicate = eval_condition(
|
||||
step.check,
|
||||
run.state,
|
||||
run.workflow_input,
|
||||
frame.prior_outcome,
|
||||
)
|
||||
outcome = "true" if predicate else "false"
|
||||
return StepExecutionResult(
|
||||
outcome=outcome,
|
||||
resolved_input={},
|
||||
output={"predicate": predicate},
|
||||
state_changes={},
|
||||
)
|
||||
|
||||
|
||||
def handle_join_step() -> StepExecutionResult:
|
||||
return StepExecutionResult(
|
||||
outcome="done",
|
||||
resolved_input={},
|
||||
output={},
|
||||
state_changes={},
|
||||
)
|
||||
|
||||
|
||||
def handle_interrupt_step(
|
||||
run: RunState,
|
||||
step: InterruptNode,
|
||||
) -> RunState:
|
||||
frame = run.current_frame()
|
||||
interrupt_request = build_interrupt_request(
|
||||
step,
|
||||
frame_id=frame.id,
|
||||
state=run.state,
|
||||
workflow_input=run.workflow_input,
|
||||
context=frame_context_values(frame),
|
||||
)
|
||||
run.interrupt = interrupt_request
|
||||
run.status = RunStatus.INTERRUPTED
|
||||
frame.status = FrameStatus.INTERRUPTED
|
||||
append_trace(
|
||||
run,
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
step_type=step.type,
|
||||
resolved_input=interrupt_request.payload,
|
||||
outcome="interrupt",
|
||||
next_node_id=frame.node_id,
|
||||
output=interrupt_request.payload,
|
||||
state_changes={},
|
||||
)
|
||||
return run
|
||||
|
||||
+12
-415
@@ -1,418 +1,15 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility facade for workflow validation."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
from .model import (
|
||||
BinaryCondition,
|
||||
Condition,
|
||||
ConditionNode,
|
||||
Edge,
|
||||
ExistsCondition,
|
||||
ForeachNode,
|
||||
InterruptNode,
|
||||
LiteralOperand,
|
||||
NodeDef,
|
||||
NodeUse,
|
||||
NotCondition,
|
||||
PathOperand,
|
||||
Step,
|
||||
VariadicCondition,
|
||||
Workflow,
|
||||
from wf_core.validation import (
|
||||
ValidationIssue,
|
||||
ValidationIssueCode,
|
||||
ValidationReport,
|
||||
validate_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"
|
||||
INVALID_INTERRUPT_SOURCE = "invalid_interrupt_source"
|
||||
INVALID_INTERRUPT_DESTINATION = "invalid_interrupt_destination"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ValidationIssue:
|
||||
code: ValidationIssueCode
|
||||
path: str
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ValidationReport:
|
||||
errors: list[ValidationIssue] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
def add(self, code: ValidationIssueCode, path: str, message: str) -> None:
|
||||
self.errors.append(ValidationIssue(code=code, path=path, message=message))
|
||||
|
||||
def raise_for_errors(self) -> None:
|
||||
if not self.errors:
|
||||
return
|
||||
rendered = "\n".join(
|
||||
f"- [{issue.code}] {issue.path}: {issue.message}" for issue in self.errors
|
||||
)
|
||||
raise ValueError(f"Workflow validation failed:\n{rendered}")
|
||||
|
||||
|
||||
def validate_workflow(workflow: Workflow) -> ValidationReport:
|
||||
report = ValidationReport()
|
||||
|
||||
node_defs: dict[str, NodeDef] = {}
|
||||
for index, node_def in enumerate(workflow.node_defs):
|
||||
if node_def.name in node_defs:
|
||||
report.add(
|
||||
ValidationIssueCode.DUPLICATE_NODE_DEF,
|
||||
f"node_defs[{index}].name",
|
||||
f"duplicate node def name {node_def.name!r}",
|
||||
)
|
||||
else:
|
||||
node_defs[node_def.name] = node_def
|
||||
|
||||
nodes_by_id: dict[str, Step] = {}
|
||||
state_root_fields = set(workflow.state_schema.fields)
|
||||
input_root_fields = set(workflow.input_schema.properties)
|
||||
|
||||
for index, node in enumerate(workflow.nodes):
|
||||
if node.id in nodes_by_id:
|
||||
report.add(
|
||||
ValidationIssueCode.DUPLICATE_NODE_ID,
|
||||
f"nodes[{index}].id",
|
||||
f"duplicate node id {node.id!r}",
|
||||
)
|
||||
else:
|
||||
nodes_by_id[node.id] = node
|
||||
|
||||
if isinstance(node, NodeUse):
|
||||
_validate_node_use(node, index, node_defs, workflow, report)
|
||||
elif isinstance(node, ConditionNode):
|
||||
_validate_condition_node(
|
||||
node, index, report, state_root_fields, input_root_fields
|
||||
)
|
||||
elif isinstance(node, ForeachNode):
|
||||
_validate_foreach_node(
|
||||
node, index, report, state_root_fields, input_root_fields
|
||||
)
|
||||
elif isinstance(node, InterruptNode):
|
||||
_validate_interrupt_node(
|
||||
node, index, report, state_root_fields, input_root_fields
|
||||
)
|
||||
|
||||
if workflow.start not in nodes_by_id:
|
||||
report.add(
|
||||
ValidationIssueCode.UNKNOWN_START,
|
||||
"start",
|
||||
f"unknown start node {workflow.start!r}",
|
||||
)
|
||||
|
||||
outgoing: dict[str, set[str]] = {}
|
||||
edge_keys: set[tuple[str, str]] = set()
|
||||
|
||||
for index, edge in enumerate(workflow.edges):
|
||||
edge_key = (edge.from_, edge.outcome)
|
||||
if edge_key in edge_keys:
|
||||
report.add(
|
||||
ValidationIssueCode.DUPLICATE_EDGE,
|
||||
f"edges[{index}]",
|
||||
f"duplicate edge for source {edge.from_!r} and outcome {edge.outcome!r}",
|
||||
)
|
||||
else:
|
||||
edge_keys.add(edge_key)
|
||||
|
||||
source = nodes_by_id.get(edge.from_)
|
||||
if source is None:
|
||||
report.add(
|
||||
ValidationIssueCode.UNKNOWN_EDGE_SOURCE,
|
||||
f"edges[{index}].from",
|
||||
f"unknown source node {edge.from_!r}",
|
||||
)
|
||||
else:
|
||||
allowed = _declared_outcomes_for_step(source, node_defs)
|
||||
if edge.outcome not in allowed:
|
||||
report.add(
|
||||
ValidationIssueCode.UNDECLARED_EDGE_OUTCOME,
|
||||
f"edges[{index}].outcome",
|
||||
f"outcome {edge.outcome!r} is not declared by node {edge.from_!r}",
|
||||
)
|
||||
outgoing.setdefault(edge.from_, set()).add(edge.outcome)
|
||||
|
||||
if edge.to != END and edge.to not in nodes_by_id:
|
||||
report.add(
|
||||
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)
|
||||
|
||||
for node_id in reachable:
|
||||
node = nodes_by_id[node_id]
|
||||
declared_outcomes = _declared_outcomes_for_step(node, node_defs)
|
||||
wired = outgoing.get(node_id, set())
|
||||
missing = declared_outcomes - wired
|
||||
if missing:
|
||||
report.add(
|
||||
ValidationIssueCode.MISSING_OUTCOME_EDGE,
|
||||
f"nodes[{node_id}]",
|
||||
f"reachable node is missing edges for outcomes {sorted(missing)!r}",
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def _validate_node_use(
|
||||
node: NodeUse,
|
||||
index: int,
|
||||
node_defs: dict[str, NodeDef],
|
||||
workflow: Workflow,
|
||||
report: ValidationReport,
|
||||
) -> None:
|
||||
node_def = node_defs.get(node.node)
|
||||
if node_def is None:
|
||||
report.add(
|
||||
ValidationIssueCode.UNKNOWN_NODE_DEF,
|
||||
f"nodes[{index}].node",
|
||||
f"unknown node def {node.node!r}",
|
||||
)
|
||||
return
|
||||
|
||||
input_fields = set(node_def.input_schema.properties)
|
||||
output_fields = set(node_def.output_schema.properties)
|
||||
state_fields = set(workflow.state_schema.fields)
|
||||
input_root_fields = set(workflow.input_schema.properties)
|
||||
|
||||
for source_path, destination_field in node.in_map.items():
|
||||
if destination_field not in input_fields:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||
f"nodes[{index}].in_map[{source_path!r}]",
|
||||
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, allow_context=True
|
||||
):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_SOURCE_PATH,
|
||||
f"nodes[{index}].in_map[{source_path!r}]",
|
||||
"source path must start with input., state., or context. and reference a declared root field when applicable",
|
||||
)
|
||||
|
||||
for source_field, destination_path in node.out_map.items():
|
||||
if source_field not in output_fields:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
||||
f"nodes[{index}].out_map[{source_field!r}]",
|
||||
f"source field {source_field!r} is not declared in node output schema",
|
||||
)
|
||||
if not is_valid_destination_path(destination_path):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_DESTINATION_PATH,
|
||||
f"nodes[{index}].out_map[{source_field!r}]",
|
||||
"destination path must start with state.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_condition_node(
|
||||
node: ConditionNode,
|
||||
index: int,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
if isinstance(node.check, VariadicCondition) and not node.check.args:
|
||||
report.add(
|
||||
ValidationIssueCode.EMPTY_CONDITION_ARGS,
|
||||
f"nodes[{index}].check.args",
|
||||
"condition args must not be empty",
|
||||
)
|
||||
_validate_condition_expr(
|
||||
node.check,
|
||||
f"nodes[{index}].check",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
|
||||
|
||||
def _validate_foreach_node(
|
||||
node: ForeachNode,
|
||||
index: int,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
if not is_valid_source_path(node.over, state_root_fields, input_root_fields):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_FOREACH_SOURCE,
|
||||
f"nodes[{index}].over",
|
||||
"foreach source path must start with input. or state. and reference a declared root field",
|
||||
)
|
||||
|
||||
|
||||
def _validate_interrupt_node(
|
||||
node: InterruptNode,
|
||||
index: int,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
for source_path, payload_field in node.request_map.items():
|
||||
if not payload_field:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_INTERRUPT_SOURCE,
|
||||
f"nodes[{index}].request_map[{source_path!r}]",
|
||||
"interrupt request payload field must not be empty",
|
||||
)
|
||||
if not is_valid_source_path(source_path, state_root_fields, input_root_fields):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_INTERRUPT_SOURCE,
|
||||
f"nodes[{index}].request_map[{source_path!r}]",
|
||||
"interrupt request source must start with input. or state. and reference a declared root field",
|
||||
)
|
||||
|
||||
for resume_field, destination_path in node.out_map.items():
|
||||
if not resume_field:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_INTERRUPT_DESTINATION,
|
||||
f"nodes[{index}].out_map[{resume_field!r}]",
|
||||
"interrupt resume field must not be empty",
|
||||
)
|
||||
if not is_valid_destination_path(destination_path):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_INTERRUPT_DESTINATION,
|
||||
f"nodes[{index}].out_map[{resume_field!r}]",
|
||||
"interrupt resume destination must start with state.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_condition_expr(
|
||||
condition: Condition,
|
||||
path: str,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
if isinstance(condition, ExistsCondition):
|
||||
if not is_valid_source_path(
|
||||
condition.path,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
allow_context=True,
|
||||
):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_CONDITION_PATH,
|
||||
path,
|
||||
f"invalid condition path {condition.path!r}",
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(condition, NotCondition):
|
||||
_validate_condition_expr(
|
||||
condition.arg,
|
||||
f"{path}.arg",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(condition, VariadicCondition):
|
||||
for index, arg in enumerate(condition.args):
|
||||
_validate_condition_expr(
|
||||
arg,
|
||||
f"{path}.args[{index}]",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(condition, BinaryCondition):
|
||||
_validate_operand(
|
||||
condition.left,
|
||||
f"{path}.left",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
_validate_operand(
|
||||
condition.right,
|
||||
f"{path}.right",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
|
||||
|
||||
def _validate_operand(
|
||||
operand: PathOperand | LiteralOperand,
|
||||
path: str,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
if isinstance(operand, LiteralOperand):
|
||||
return
|
||||
if not is_valid_source_path(
|
||||
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]:
|
||||
if isinstance(step, NodeUse):
|
||||
node_def = node_defs.get(step.node)
|
||||
return set(node_def.outcomes) if node_def else set()
|
||||
if step.type == "condition":
|
||||
return {"true", "false"}
|
||||
if step.type == "foreach":
|
||||
return {"loop", "done"}
|
||||
if step.type == "join":
|
||||
return {"done"}
|
||||
if isinstance(step, InterruptNode):
|
||||
return set(step.outcomes)
|
||||
return set()
|
||||
|
||||
|
||||
def _reachable_node_ids(
|
||||
start: str, edges: list[Edge], nodes_by_id: dict[str, Step]
|
||||
) -> set[str]:
|
||||
if start not in nodes_by_id:
|
||||
return set()
|
||||
|
||||
adjacency: dict[str, list[str]] = {}
|
||||
for edge in edges:
|
||||
if edge.to == END:
|
||||
continue
|
||||
adjacency.setdefault(edge.from_, []).append(edge.to)
|
||||
|
||||
seen: set[str] = set()
|
||||
stack = [start]
|
||||
while stack:
|
||||
node_id = stack.pop()
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
stack.extend(adjacency.get(node_id, []))
|
||||
return seen
|
||||
__all__ = [
|
||||
"ValidationIssue",
|
||||
"ValidationIssueCode",
|
||||
"ValidationReport",
|
||||
"validate_workflow",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from wf_core.validation.core import validate_workflow
|
||||
from wf_core.validation.issues import (
|
||||
ValidationIssue,
|
||||
ValidationIssueCode,
|
||||
ValidationReport,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ValidationIssue",
|
||||
"ValidationIssueCode",
|
||||
"ValidationReport",
|
||||
"validate_workflow",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.model import (
|
||||
ConditionNode,
|
||||
Edge,
|
||||
ForeachNode,
|
||||
InterruptNode,
|
||||
NodeDef,
|
||||
NodeUse,
|
||||
Step,
|
||||
Workflow,
|
||||
)
|
||||
from wf_core.tokens import END
|
||||
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.steps import (
|
||||
validate_condition_node,
|
||||
validate_foreach_node,
|
||||
validate_interrupt_node,
|
||||
validate_node_use,
|
||||
)
|
||||
|
||||
|
||||
def validate_workflow(workflow: Workflow) -> ValidationReport:
|
||||
report = ValidationReport()
|
||||
|
||||
node_defs = _collect_node_defs(workflow, report)
|
||||
nodes_by_id = _validate_nodes(workflow, node_defs, report)
|
||||
_validate_start(workflow, nodes_by_id, report)
|
||||
outgoing = _validate_edges(workflow.edges, nodes_by_id, node_defs, report)
|
||||
_validate_reachable_outcomes(workflow, nodes_by_id, node_defs, outgoing, report)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def _collect_node_defs(
|
||||
workflow: Workflow, report: ValidationReport
|
||||
) -> dict[str, NodeDef]:
|
||||
node_defs: dict[str, NodeDef] = {}
|
||||
for index, node_def in enumerate(workflow.node_defs):
|
||||
if node_def.name in node_defs:
|
||||
report.add(
|
||||
ValidationIssueCode.DUPLICATE_NODE_DEF,
|
||||
f"node_defs[{index}].name",
|
||||
f"duplicate node def name {node_def.name!r}",
|
||||
)
|
||||
else:
|
||||
node_defs[node_def.name] = node_def
|
||||
return node_defs
|
||||
|
||||
|
||||
def _validate_nodes(
|
||||
workflow: Workflow,
|
||||
node_defs: dict[str, NodeDef],
|
||||
report: ValidationReport,
|
||||
) -> dict[str, Step]:
|
||||
nodes_by_id: dict[str, Step] = {}
|
||||
state_root_fields = set(workflow.state_schema.fields)
|
||||
input_root_fields = set(workflow.input_schema.properties)
|
||||
|
||||
for index, node in enumerate(workflow.nodes):
|
||||
if node.id in nodes_by_id:
|
||||
report.add(
|
||||
ValidationIssueCode.DUPLICATE_NODE_ID,
|
||||
f"nodes[{index}].id",
|
||||
f"duplicate node id {node.id!r}",
|
||||
)
|
||||
else:
|
||||
nodes_by_id[node.id] = node
|
||||
|
||||
if isinstance(node, NodeUse):
|
||||
validate_node_use(node, index, node_defs, workflow, report)
|
||||
elif isinstance(node, ConditionNode):
|
||||
validate_condition_node(
|
||||
node, index, report, state_root_fields, input_root_fields
|
||||
)
|
||||
elif isinstance(node, ForeachNode):
|
||||
validate_foreach_node(
|
||||
node, index, report, state_root_fields, input_root_fields
|
||||
)
|
||||
elif isinstance(node, InterruptNode):
|
||||
validate_interrupt_node(
|
||||
node, index, report, state_root_fields, input_root_fields
|
||||
)
|
||||
|
||||
return nodes_by_id
|
||||
|
||||
|
||||
def _validate_start(
|
||||
workflow: Workflow,
|
||||
nodes_by_id: dict[str, Step],
|
||||
report: ValidationReport,
|
||||
) -> None:
|
||||
if workflow.start not in nodes_by_id:
|
||||
report.add(
|
||||
ValidationIssueCode.UNKNOWN_START,
|
||||
"start",
|
||||
f"unknown start node {workflow.start!r}",
|
||||
)
|
||||
|
||||
|
||||
def _validate_edges(
|
||||
edges: list[Edge],
|
||||
nodes_by_id: dict[str, Step],
|
||||
node_defs: dict[str, NodeDef],
|
||||
report: ValidationReport,
|
||||
) -> dict[str, set[str]]:
|
||||
outgoing: dict[str, set[str]] = {}
|
||||
edge_keys: set[tuple[str, str]] = set()
|
||||
|
||||
for index, edge in enumerate(edges):
|
||||
edge_key = (edge.from_, edge.outcome)
|
||||
if edge_key in edge_keys:
|
||||
report.add(
|
||||
ValidationIssueCode.DUPLICATE_EDGE,
|
||||
f"edges[{index}]",
|
||||
f"duplicate edge for source {edge.from_!r} and outcome {edge.outcome!r}",
|
||||
)
|
||||
else:
|
||||
edge_keys.add(edge_key)
|
||||
|
||||
source = nodes_by_id.get(edge.from_)
|
||||
if source is None:
|
||||
report.add(
|
||||
ValidationIssueCode.UNKNOWN_EDGE_SOURCE,
|
||||
f"edges[{index}].from",
|
||||
f"unknown source node {edge.from_!r}",
|
||||
)
|
||||
else:
|
||||
allowed = declared_outcomes_for_step(source, node_defs)
|
||||
if edge.outcome not in allowed:
|
||||
report.add(
|
||||
ValidationIssueCode.UNDECLARED_EDGE_OUTCOME,
|
||||
f"edges[{index}].outcome",
|
||||
f"outcome {edge.outcome!r} is not declared by node {edge.from_!r}",
|
||||
)
|
||||
outgoing.setdefault(edge.from_, set()).add(edge.outcome)
|
||||
|
||||
if edge.to != END and edge.to not in nodes_by_id:
|
||||
report.add(
|
||||
ValidationIssueCode.UNKNOWN_EDGE_DESTINATION,
|
||||
f"edges[{index}].to",
|
||||
f"unknown destination node {edge.to!r}",
|
||||
)
|
||||
|
||||
return outgoing
|
||||
|
||||
|
||||
def _validate_reachable_outcomes(
|
||||
workflow: Workflow,
|
||||
nodes_by_id: dict[str, Step],
|
||||
node_defs: dict[str, NodeDef],
|
||||
outgoing: dict[str, set[str]],
|
||||
report: ValidationReport,
|
||||
) -> None:
|
||||
reachable = reachable_node_ids(workflow.start, workflow.edges, nodes_by_id)
|
||||
|
||||
for node_id in reachable:
|
||||
node = nodes_by_id[node_id]
|
||||
declared_outcomes = declared_outcomes_for_step(node, node_defs)
|
||||
wired = outgoing.get(node_id, set())
|
||||
missing = declared_outcomes - wired
|
||||
if missing:
|
||||
report.add(
|
||||
ValidationIssueCode.MISSING_OUTCOME_EDGE,
|
||||
f"nodes[{node_id}]",
|
||||
f"reachable node is missing edges for outcomes {sorted(missing)!r}",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
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"
|
||||
INVALID_INTERRUPT_SOURCE = "invalid_interrupt_source"
|
||||
INVALID_INTERRUPT_DESTINATION = "invalid_interrupt_destination"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ValidationIssue:
|
||||
code: ValidationIssueCode
|
||||
path: str
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ValidationReport:
|
||||
errors: list[ValidationIssue] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
def add(self, code: ValidationIssueCode, path: str, message: str) -> None:
|
||||
self.errors.append(ValidationIssue(code=code, path=path, message=message))
|
||||
|
||||
def raise_for_errors(self) -> None:
|
||||
if not self.errors:
|
||||
return
|
||||
rendered = "\n".join(
|
||||
f"- [{issue.code}] {issue.path}: {issue.message}" for issue in self.errors
|
||||
)
|
||||
raise ValueError(f"Workflow validation failed:\n{rendered}")
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.model import Edge, InterruptNode, NodeDef, NodeUse, Step
|
||||
from wf_core.tokens import END
|
||||
|
||||
|
||||
def declared_outcomes_for_step(step: Step, node_defs: dict[str, NodeDef]) -> set[str]:
|
||||
if isinstance(step, NodeUse):
|
||||
node_def = node_defs.get(step.node)
|
||||
return set(node_def.outcomes) if node_def else set()
|
||||
if step.type == "condition":
|
||||
return {"true", "false"}
|
||||
if step.type == "foreach":
|
||||
return {"loop", "done"}
|
||||
if step.type == "join":
|
||||
return {"done"}
|
||||
if isinstance(step, InterruptNode):
|
||||
return set(step.outcomes)
|
||||
return set()
|
||||
|
||||
|
||||
def reachable_node_ids(start: str, edges: list[Edge], nodes_by_id: dict[str, Step]) -> set[str]:
|
||||
if start not in nodes_by_id:
|
||||
return set()
|
||||
|
||||
adjacency: dict[str, list[str]] = {}
|
||||
for edge in edges:
|
||||
if edge.to == END:
|
||||
continue
|
||||
adjacency.setdefault(edge.from_, []).append(edge.to)
|
||||
|
||||
seen: set[str] = set()
|
||||
stack = [start]
|
||||
while stack:
|
||||
node_id = stack.pop()
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
stack.extend(adjacency.get(node_id, []))
|
||||
return seen
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.model import (
|
||||
BinaryCondition,
|
||||
Condition,
|
||||
ConditionNode,
|
||||
ExistsCondition,
|
||||
ForeachNode,
|
||||
InterruptNode,
|
||||
LiteralOperand,
|
||||
NodeDef,
|
||||
NodeUse,
|
||||
NotCondition,
|
||||
PathOperand,
|
||||
VariadicCondition,
|
||||
Workflow,
|
||||
)
|
||||
from wf_core.paths import is_valid_destination_path, is_valid_source_path
|
||||
from wf_core.validation.issues import ValidationIssueCode, ValidationReport
|
||||
|
||||
|
||||
def validate_node_use(
|
||||
node: NodeUse,
|
||||
index: int,
|
||||
node_defs: dict[str, NodeDef],
|
||||
workflow: Workflow,
|
||||
report: ValidationReport,
|
||||
) -> None:
|
||||
node_def = node_defs.get(node.node)
|
||||
if node_def is None:
|
||||
report.add(
|
||||
ValidationIssueCode.UNKNOWN_NODE_DEF,
|
||||
f"nodes[{index}].node",
|
||||
f"unknown node def {node.node!r}",
|
||||
)
|
||||
return
|
||||
|
||||
input_fields = set(node_def.input_schema.properties)
|
||||
output_fields = set(node_def.output_schema.properties)
|
||||
state_fields = set(workflow.state_schema.fields)
|
||||
input_root_fields = set(workflow.input_schema.properties)
|
||||
|
||||
for source_path, destination_field in node.in_map.items():
|
||||
if destination_field not in input_fields:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||
f"nodes[{index}].in_map[{source_path!r}]",
|
||||
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, allow_context=True
|
||||
):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_SOURCE_PATH,
|
||||
f"nodes[{index}].in_map[{source_path!r}]",
|
||||
"source path must start with input., state., or context. and reference a declared root field when applicable",
|
||||
)
|
||||
|
||||
for source_field, destination_path in node.out_map.items():
|
||||
if source_field not in output_fields:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
||||
f"nodes[{index}].out_map[{source_field!r}]",
|
||||
f"source field {source_field!r} is not declared in node output schema",
|
||||
)
|
||||
if not is_valid_destination_path(destination_path):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_DESTINATION_PATH,
|
||||
f"nodes[{index}].out_map[{source_field!r}]",
|
||||
"destination path must start with state.",
|
||||
)
|
||||
|
||||
|
||||
def validate_condition_node(
|
||||
node: ConditionNode,
|
||||
index: int,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
if isinstance(node.check, VariadicCondition) and not node.check.args:
|
||||
report.add(
|
||||
ValidationIssueCode.EMPTY_CONDITION_ARGS,
|
||||
f"nodes[{index}].check.args",
|
||||
"condition args must not be empty",
|
||||
)
|
||||
validate_condition_expr(
|
||||
node.check,
|
||||
f"nodes[{index}].check",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
|
||||
|
||||
def validate_foreach_node(
|
||||
node: ForeachNode,
|
||||
index: int,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
if not is_valid_source_path(node.over, state_root_fields, input_root_fields):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_FOREACH_SOURCE,
|
||||
f"nodes[{index}].over",
|
||||
"foreach source path must start with input. or state. and reference a declared root field",
|
||||
)
|
||||
|
||||
|
||||
def validate_interrupt_node(
|
||||
node: InterruptNode,
|
||||
index: int,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
for source_path, payload_field in node.request_map.items():
|
||||
if not payload_field:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_INTERRUPT_SOURCE,
|
||||
f"nodes[{index}].request_map[{source_path!r}]",
|
||||
"interrupt request payload field must not be empty",
|
||||
)
|
||||
if not is_valid_source_path(source_path, state_root_fields, input_root_fields):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_INTERRUPT_SOURCE,
|
||||
f"nodes[{index}].request_map[{source_path!r}]",
|
||||
"interrupt request source must start with input. or state. and reference a declared root field",
|
||||
)
|
||||
|
||||
for resume_field, destination_path in node.out_map.items():
|
||||
if not resume_field:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_INTERRUPT_DESTINATION,
|
||||
f"nodes[{index}].out_map[{resume_field!r}]",
|
||||
"interrupt resume field must not be empty",
|
||||
)
|
||||
if not is_valid_destination_path(destination_path):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_INTERRUPT_DESTINATION,
|
||||
f"nodes[{index}].out_map[{resume_field!r}]",
|
||||
"interrupt resume destination must start with state.",
|
||||
)
|
||||
|
||||
|
||||
def validate_condition_expr(
|
||||
condition: Condition,
|
||||
path: str,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
if isinstance(condition, ExistsCondition):
|
||||
if not is_valid_source_path(
|
||||
condition.path,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
allow_context=True,
|
||||
):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_CONDITION_PATH,
|
||||
path,
|
||||
f"invalid condition path {condition.path!r}",
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(condition, NotCondition):
|
||||
validate_condition_expr(
|
||||
condition.arg,
|
||||
f"{path}.arg",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(condition, VariadicCondition):
|
||||
for index, arg in enumerate(condition.args):
|
||||
validate_condition_expr(
|
||||
arg,
|
||||
f"{path}.args[{index}]",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(condition, BinaryCondition):
|
||||
validate_operand(
|
||||
condition.left,
|
||||
f"{path}.left",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
validate_operand(
|
||||
condition.right,
|
||||
f"{path}.right",
|
||||
report,
|
||||
state_root_fields,
|
||||
input_root_fields,
|
||||
)
|
||||
|
||||
|
||||
def validate_operand(
|
||||
operand: PathOperand | LiteralOperand,
|
||||
path: str,
|
||||
report: ValidationReport,
|
||||
state_root_fields: set[str],
|
||||
input_root_fields: set[str],
|
||||
) -> None:
|
||||
if isinstance(operand, LiteralOperand):
|
||||
return
|
||||
if not is_valid_source_path(
|
||||
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}",
|
||||
)
|
||||
|
||||
@@ -1,30 +1,3 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility shim for workflow index helpers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .errors import WorkflowExecutionError
|
||||
from .model import NodeDef, Workflow
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkflowIndex:
|
||||
node_defs: dict[str, NodeDef]
|
||||
nodes_by_id: dict[str, Any]
|
||||
edge_map: dict[tuple[str, str], str]
|
||||
|
||||
def next_node_id(self, node_id: str, outcome: str) -> str:
|
||||
next_node_id = self.edge_map.get((node_id, outcome))
|
||||
if next_node_id is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"no edge found for node {node_id!r} and outcome {outcome!r}"
|
||||
)
|
||||
return next_node_id
|
||||
|
||||
|
||||
def build_workflow_index(workflow: Workflow) -> WorkflowIndex:
|
||||
return WorkflowIndex(
|
||||
node_defs={node_def.name: node_def for node_def in workflow.node_defs},
|
||||
nodes_by_id={node.id: node for node in workflow.nodes},
|
||||
edge_map={(edge.from_, edge.outcome): edge.to for edge in workflow.edges},
|
||||
)
|
||||
from wf_core.runtime.ops.index import * # noqa: F403
|
||||
|
||||
Reference in New Issue
Block a user