child graphs can interrupt/resume
This commit is contained in:
@@ -81,12 +81,17 @@ def resume_workflow(
|
||||
subgraphs: Mapping[str, PreparedSubgraph[NodeHandler]] | None = None,
|
||||
) -> RunState:
|
||||
"""Resume a synchronous run from its current state."""
|
||||
interrupted_workflow, interrupted_reducers = _interrupt_resume_target(
|
||||
workflow, reducers, run, subgraphs, resuming=resume_payload is not None
|
||||
)
|
||||
index = prepare_resume(
|
||||
workflow,
|
||||
run,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
reducers=reducers,
|
||||
interrupted_workflow=interrupted_workflow,
|
||||
interrupted_reducers=interrupted_reducers,
|
||||
)
|
||||
if index is None:
|
||||
if run.current_node_id == END:
|
||||
@@ -128,12 +133,17 @@ async def resume_workflow_async(
|
||||
subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None,
|
||||
) -> RunState:
|
||||
"""Resume an async run from its current state."""
|
||||
interrupted_workflow, interrupted_reducers = _interrupt_resume_target(
|
||||
workflow, reducers, run, subgraphs, resuming=resume_payload is not None
|
||||
)
|
||||
index = prepare_resume(
|
||||
workflow,
|
||||
run,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
reducers=reducers,
|
||||
interrupted_workflow=interrupted_workflow,
|
||||
interrupted_reducers=interrupted_reducers,
|
||||
)
|
||||
if index is None:
|
||||
if run.current_node_id == END:
|
||||
@@ -164,6 +174,21 @@ async def resume_workflow_async(
|
||||
return finalize_run(workflow, run)
|
||||
|
||||
|
||||
def _interrupt_resume_target(
|
||||
root_workflow: Workflow,
|
||||
root_reducers: Mapping[str, ReducerDefinition] | None,
|
||||
run: RunState,
|
||||
subgraphs: Mapping[str, PreparedSubgraph[Any]] | None,
|
||||
*,
|
||||
resuming: bool,
|
||||
) -> tuple[Workflow | None, Mapping[str, ReducerDefinition] | None]:
|
||||
"""Resolve the workflow that owns an outstanding routed child interrupt."""
|
||||
if not resuming or run.interrupt is None or run.interrupt.route is None:
|
||||
return None, root_reducers
|
||||
child = resolve_prepared_subgraph(run.interrupt.route.workflow_ref, subgraphs)
|
||||
return child.workflow, child.reducers
|
||||
|
||||
|
||||
def _sync_execution_target(
|
||||
root_workflow: Workflow,
|
||||
root_registry: Mapping[str, NodeHandler],
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.conditions import eval_condition
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.models.steps import ConditionNode, InterruptNode
|
||||
from wf_core.run_state import FrameStatus, RunState, RunStatus, StepExecutionResult
|
||||
from wf_core.run_state import (
|
||||
ROOT_SCOPE_ID,
|
||||
ExecutionFrame,
|
||||
FrameStatus,
|
||||
InterruptRoute,
|
||||
RunState,
|
||||
RunStatus,
|
||||
StepExecutionResult,
|
||||
)
|
||||
from wf_core.runtime.lineage import scope_input_for_frame
|
||||
from wf_core.runtime.ops.flow import append_trace
|
||||
from wf_core.runtime.ops.frames import frame_context_values
|
||||
@@ -44,12 +53,32 @@ def handle_interrupt_step(
|
||||
step: InterruptNode,
|
||||
) -> RunState:
|
||||
frame = run.current_frame()
|
||||
public_frame = frame
|
||||
route = None
|
||||
if frame.scope_id != ROOT_SCOPE_ID:
|
||||
public_frame = _owning_subgraph_frame(run, frame)
|
||||
scope = run.scopes.get(frame.scope_id)
|
||||
if scope is None or scope.workflow_ref is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"child interrupt frame {frame.id!r} has no workflow scope"
|
||||
)
|
||||
route = InterruptRoute(
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
scope_id=frame.scope_id,
|
||||
lineage_id=frame.lineage_id,
|
||||
parent_frame_id=public_frame.id,
|
||||
workflow_ref=scope.workflow_ref,
|
||||
)
|
||||
interrupt_request = build_interrupt_request(
|
||||
step,
|
||||
frame_id=frame.id,
|
||||
state=run.state,
|
||||
workflow_input=run.workflow_input,
|
||||
state=state_view_for_frame(run, frame),
|
||||
workflow_input=scope_input_for_frame(run, frame),
|
||||
context=frame_context_values(frame),
|
||||
public_frame_id=public_frame.id,
|
||||
public_node_id=public_frame.node_id,
|
||||
route=route,
|
||||
)
|
||||
run.interrupt = interrupt_request
|
||||
run.status = RunStatus.INTERRUPTED
|
||||
@@ -66,3 +95,21 @@ def handle_interrupt_step(
|
||||
state_changes={},
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
def _owning_subgraph_frame(run: RunState, frame: ExecutionFrame) -> ExecutionFrame:
|
||||
"""Return the graph-boundary frame that owns one child-scope interrupt."""
|
||||
cursor = frame
|
||||
while cursor.parent_frame_id is not None:
|
||||
parent = run.frames.get(cursor.parent_frame_id)
|
||||
if parent is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"child interrupt frame {frame.id!r} references missing parent "
|
||||
f"{cursor.parent_frame_id!r}"
|
||||
)
|
||||
if parent.scope_id != frame.scope_id:
|
||||
return parent
|
||||
cursor = parent
|
||||
raise WorkflowExecutionError(
|
||||
f"child interrupt frame {frame.id!r} has no parent subgraph"
|
||||
)
|
||||
|
||||
@@ -8,11 +8,19 @@ from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.local_paths import LocalPathError, set_local_value
|
||||
from wf_core.models.steps import InputPathBinding, InputValueBinding, InterruptNode
|
||||
from wf_core.models.workflow import Workflow
|
||||
from wf_core.run_state import InterruptRequest, RunState, StepExecutionResult
|
||||
from wf_core.run_state import (
|
||||
FrameStatus,
|
||||
InterruptRequest,
|
||||
InterruptRoute,
|
||||
RunState,
|
||||
StepExecutionResult,
|
||||
)
|
||||
from wf_core.runtime.lineage import commit_patch_for_frame
|
||||
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.merges import ReducerDefinition
|
||||
from wf_core.runtime.ops.state import apply_output_bindings
|
||||
from wf_core.runtime.ops.overlays import state_view_for_frame
|
||||
from wf_core.runtime.ops.state import build_output_patch
|
||||
|
||||
|
||||
def build_interrupt_request(
|
||||
@@ -22,6 +30,9 @@ def build_interrupt_request(
|
||||
state: dict[str, Any],
|
||||
workflow_input: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
public_frame_id: str | None = None,
|
||||
public_node_id: str | None = None,
|
||||
route: InterruptRoute | None = None,
|
||||
) -> InterruptRequest:
|
||||
payload: dict[str, Any] = {}
|
||||
for binding in node.request:
|
||||
@@ -43,11 +54,12 @@ def build_interrupt_request(
|
||||
except LocalPathError as exc:
|
||||
raise WorkflowExecutionError(str(exc)) from exc
|
||||
return InterruptRequest(
|
||||
id=f"interrupt:{node.id}",
|
||||
frame_id=frame_id,
|
||||
node_id=node.id,
|
||||
id=f"interrupt:{public_node_id or node.id}",
|
||||
frame_id=public_frame_id or frame_id,
|
||||
node_id=public_node_id or node.id,
|
||||
kind=node.kind,
|
||||
payload=payload,
|
||||
route=route,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,14 +72,28 @@ def resume_interrupt(
|
||||
resume_outcome: str,
|
||||
reducers: Mapping[str, ReducerDefinition] | None = None,
|
||||
) -> 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()
|
||||
route = run.interrupt.route
|
||||
if route is not None:
|
||||
frame = run.frames.get(route.frame_id)
|
||||
if (
|
||||
frame is None
|
||||
or frame.scope_id != route.scope_id
|
||||
or frame.lineage_id != route.lineage_id
|
||||
or frame.node_id != route.node_id
|
||||
or frame.status != FrameStatus.INTERRUPTED
|
||||
):
|
||||
raise WorkflowExecutionError("child interrupt route is no longer resumable")
|
||||
run.current_frame_id = frame.id
|
||||
run.sync_from_current_frame()
|
||||
else:
|
||||
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")
|
||||
frame = run.current_frame()
|
||||
step = index.nodes_by_id[frame.node_id]
|
||||
if not isinstance(step, InterruptNode):
|
||||
raise WorkflowExecutionError(
|
||||
@@ -78,14 +104,15 @@ def resume_interrupt(
|
||||
f"interrupt node {step.id!r} does not declare resume outcome {resume_outcome!r}"
|
||||
)
|
||||
|
||||
state_changes = apply_output_bindings(
|
||||
patch = build_output_patch(
|
||||
workflow,
|
||||
step.resume,
|
||||
resume_payload,
|
||||
run.state,
|
||||
state_view_for_frame(run, frame),
|
||||
reducers=reducers,
|
||||
missing_field_message="interrupt resume payload is missing required field {field}",
|
||||
)
|
||||
state_changes = commit_patch_for_frame(run, frame, patch)
|
||||
next_node_id = index.next_node_id(frame.node_id, resume_outcome)
|
||||
append_step_result_trace(
|
||||
run,
|
||||
|
||||
@@ -34,6 +34,8 @@ def prepare_resume(
|
||||
resume_payload: dict[str, Any] | None,
|
||||
resume_outcome: str,
|
||||
reducers: Mapping[str, ReducerDefinition] | None = None,
|
||||
interrupted_workflow: Workflow | None = None,
|
||||
interrupted_reducers: Mapping[str, ReducerDefinition] | None = None,
|
||||
) -> WorkflowIndex | None:
|
||||
"""Validate and normalize a run state before resume execution."""
|
||||
if run.workflow_name != workflow.name:
|
||||
@@ -55,13 +57,21 @@ def prepare_resume(
|
||||
if run.status == RunStatus.INTERRUPTED:
|
||||
if resume_payload is None:
|
||||
return None
|
||||
resume_workflow = interrupted_workflow or workflow
|
||||
resume_index = (
|
||||
index
|
||||
if resume_workflow is workflow
|
||||
else build_workflow_index(resume_workflow)
|
||||
)
|
||||
resume_interrupt(
|
||||
workflow,
|
||||
resume_workflow,
|
||||
run,
|
||||
index=index,
|
||||
index=resume_index,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
reducers=reducers,
|
||||
reducers=(
|
||||
reducers if interrupted_workflow is None else interrupted_reducers
|
||||
),
|
||||
)
|
||||
if run.current_frame_id is not None:
|
||||
frame = run.current_frame()
|
||||
|
||||
@@ -40,7 +40,6 @@ from wf_core.runtime.scheduler import (
|
||||
)
|
||||
from wf_core.runtime.subgraphs import PreparedSubgraph, step_subgraph
|
||||
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult
|
||||
from wf_core.run_state import ROOT_SCOPE_ID
|
||||
from wf_core.tokens import END
|
||||
|
||||
from .preparation import prepare_step
|
||||
@@ -155,10 +154,6 @@ def step_workflow(
|
||||
outcome=step.outcome,
|
||||
)
|
||||
elif isinstance(step, InterruptNode):
|
||||
if frame.kind == "subgraph_root" or frame.scope_id != ROOT_SCOPE_ID:
|
||||
raise WorkflowExecutionError(
|
||||
"child interrupts are not supported until native subgraph resume routing exists"
|
||||
)
|
||||
return handle_interrupt_step(run, step)
|
||||
elif isinstance(step, ForeachNode):
|
||||
return step_foreach(workflow, run, step, index, reducers=reducers)
|
||||
@@ -271,10 +266,6 @@ async def step_workflow_async(
|
||||
outcome=step.outcome,
|
||||
)
|
||||
elif isinstance(step, InterruptNode):
|
||||
if frame.kind == "subgraph_root" or frame.scope_id != ROOT_SCOPE_ID:
|
||||
raise WorkflowExecutionError(
|
||||
"child interrupts are not supported until native subgraph resume routing exists"
|
||||
)
|
||||
return handle_interrupt_step(run, step)
|
||||
elif isinstance(step, ForeachNode):
|
||||
return step_foreach(workflow, run, step, index, reducers=reducers)
|
||||
|
||||
Reference in New Issue
Block a user