interrupt to get the same treatment too!

This commit is contained in:
lda
2026-05-21 14:15:52 +07:00 Verified
parent 274bf34edf
commit b619e529fa
15 changed files with 317 additions and 66 deletions
+40 -2
View File
@@ -143,10 +143,48 @@ class InterruptNode(BaseModel):
id: str
type: Literal["interrupt"]
kind: str
request_map: dict[str, str] = Field(default_factory=dict)
out_map: dict[str, str] = Field(default_factory=dict)
request: list[InputBinding] = Field(default_factory=list)
resume: list[OutputBinding] = Field(default_factory=list)
outcomes: list[str] = Field(default_factory=lambda: ["submitted"])
@model_validator(mode="before")
@classmethod
def _coerce_deprecated_maps(cls, data: object) -> object:
"""Normalize legacy interrupt maps into canonical parse-only bindings."""
if not isinstance(data, Mapping):
return data
old_fields = ("request_map", "out_map")
has_canonical = "request" in data or "resume" in data
present_old_fields = [field for field in old_fields if field in data]
if has_canonical and present_old_fields:
old_names = ", ".join(present_old_fields)
raise ValueError(
f"cannot mix canonical request/resume with deprecated fields: {old_names}"
)
normalized = dict(data)
request_bindings = list(normalized.pop("request", []))
resume_bindings = list(normalized.pop("resume", []))
request_map = NodeUse._deprecated_mapping(
normalized.pop("request_map", {}), field_name="request_map"
)
out_map = NodeUse._deprecated_mapping(
normalized.pop("out_map", {}), field_name="out_map"
)
request_bindings.extend(
{"target": target, "path": path} for path, target in request_map.items()
)
resume_bindings.extend(
{"source": source, "target": target} for source, target in out_map.items()
)
normalized["request"] = request_bindings
normalized["resume"] = resume_bindings
return normalized
Step = Annotated[
NodeUse | ConditionNode | ForeachNode | JoinNode | InterruptNode,
+24 -13
View File
@@ -5,13 +5,14 @@ from typing import Any
from wf_core.conditions import safe_resolve_path
from wf_core.errors import WorkflowExecutionError
from wf_core.models.steps import InterruptNode
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.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_mapped_state
from wf_core.runtime.ops.state import apply_output_bindings
def build_interrupt_request(
@@ -22,15 +23,25 @@ def build_interrupt_request(
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()
}
payload: dict[str, Any] = {}
for binding in node.request:
if isinstance(binding, InputValueBinding):
value = binding.value
elif isinstance(binding, InputPathBinding):
value = safe_resolve_path(
str(binding.path),
state=state,
workflow_input=workflow_input,
context=context,
)
else:
raise WorkflowExecutionError(
f"unsupported request binding for interrupt {node.id!r}"
)
try:
set_local_value(payload, binding.target, value)
except LocalPathError as exc:
raise WorkflowExecutionError(str(exc)) from exc
return InterruptRequest(
id=f"interrupt:{node.id}",
frame_id=frame_id,
@@ -67,10 +78,10 @@ def resume_interrupt(
f"interrupt node {step.id!r} does not declare resume outcome {resume_outcome!r}"
)
state_changes = apply_mapped_state(
state_changes = apply_output_bindings(
workflow,
step.resume,
resume_payload,
step.out_map,
run.state,
reducers=reducers,
missing_field_message="interrupt resume payload is missing required field {field}",
+18 -11
View File
@@ -165,31 +165,38 @@ def validate_interrupt_node(
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:
for binding_index, binding in enumerate(node.request):
field_path = f"nodes[{index}].request[{binding_index}]"
if not str(binding.target):
report.add(
ValidationIssueCode.INVALID_INTERRUPT_SOURCE,
f"nodes[{index}].request_map[{source_path!r}]",
field_path,
"interrupt request payload field must not be empty",
)
if not is_valid_source_path(source_path, state_root_fields, input_root_fields):
if isinstance(binding, InputPathBinding) and not is_valid_source_path(
binding.path,
state_root_fields,
input_root_fields,
allow_context=True,
):
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",
field_path,
"interrupt request source must start with input., state., or context. and reference a declared root field when applicable",
)
for resume_field, destination_path in node.out_map.items():
if not resume_field:
for binding_index, binding in enumerate(node.resume):
field_path = f"nodes[{index}].resume[{binding_index}]"
if not str(binding.source):
report.add(
ValidationIssueCode.INVALID_INTERRUPT_DESTINATION,
f"nodes[{index}].out_map[{resume_field!r}]",
field_path,
"interrupt resume field must not be empty",
)
if not is_valid_destination_path(destination_path):
if not is_valid_destination_path(binding.target):
report.add(
ValidationIssueCode.INVALID_INTERRUPT_DESTINATION,
f"nodes[{index}].out_map[{resume_field!r}]",
field_path,
"interrupt resume destination must start with state.",
)