wf-core reorg 1

runtime/validation
This commit is contained in:
lda
2026-05-08 21:27:08 +07:00 Verified
parent 16d5f190ab
commit 4c860659e4
34 changed files with 1368 additions and 1141 deletions
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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
+7
View File
@@ -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.
"""
+92
View File
@@ -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
+92
View File
@@ -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
+36
View File
@@ -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
+66
View File
@@ -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
+30
View File
@@ -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},
)
+88
View File
@@ -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)
+150
View File
@@ -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)
+33
View File
@@ -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
+16
View File
@@ -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}"
)
+119
View File
@@ -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
+5 -5
View File
@@ -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:
+9 -9
View File
@@ -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