Files
lda-wf/src/wf_core/runtime/ops/interrupts.py
T

137 lines
4.6 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from wf_core.errors import WorkflowExecutionError
from wf_core.models.steps import InterruptNode
from wf_core.models.workflow import Workflow
from wf_core.run_state import (
FrameStatus,
InterruptRequest,
InterruptRoute,
RunState,
StepExecutionResult,
)
from wf_core.runtime.input_bindings import resolve_step_input_bindings
from wf_core.runtime.lineage import commit_foreach_aware_patch
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.overlays import state_view_for_frame
from wf_core.runtime.ops.schemas import validate_payload_against_schema
from wf_core.runtime.ops.state import build_output_patch
def build_interrupt_request(
node: InterruptNode,
*,
frame_id: str,
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 = resolve_step_input_bindings(
node.request,
state=state,
workflow_input=workflow_input,
context=context,
label=f"interrupt {node.id!r} request",
)
validate_payload_against_schema(
node.request_schema,
payload,
f"interrupt request for {node.id}",
)
return InterruptRequest(
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,
outcomes=list(node.outcomes),
request_schema=dict(node.request_schema),
resume_schema=dict(node.resume_schema),
typed=node.has_explicit_contract,
)
def resume_interrupt(
workflow: Workflow,
run: RunState,
*,
index: WorkflowIndex,
resume_payload: dict[str, Any],
resume_outcome: str,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> None:
if run.interrupt is None:
raise WorkflowExecutionError("run is interrupted but has no interrupt request")
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(
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}"
)
validate_payload_against_schema(
run.interrupt.resume_schema,
resume_payload,
f"interrupt resume for {step.id}",
)
patch = build_output_patch(
workflow,
step.resume,
resume_payload,
state_view_for_frame(run, frame),
reducers=reducers,
missing_field_message="interrupt resume payload is missing required field {field}",
)
# Foreach-aware routing: a serial item resume commits through the parent
# scope, a concurrent one buffers in the item lineage for barrier merge.
state_changes = commit_foreach_aware_patch(run, frame, patch)
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)