676 lines
21 KiB
Python
676 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
from typing import Any, Mapping, Optional, Sequence, TYPE_CHECKING, Set
|
|
|
|
from .dispatcher import Decision, StateMachineRuntime
|
|
from .semantics import classify_core_pair
|
|
from .state_apply import apply_state_to_node
|
|
|
|
if TYPE_CHECKING:
|
|
from ..common.sync_node import SyncNode
|
|
|
|
|
|
class CoreBindingResult(str, Enum):
|
|
NORMAL = "NORMAL"
|
|
WARNING = "WARNING"
|
|
ABNORMAL = "ABNORMAL"
|
|
|
|
|
|
class AutoBindOutcome(str, Enum):
|
|
ONE_TO_ONE = "ONE_TO_ONE"
|
|
AMBIGUOUS = "AMBIGUOUS"
|
|
ZERO = "ZERO"
|
|
KEY_MISSING = "KEY_MISSING"
|
|
KEY_ID_RESOLVE_FAIL = "KEY_ID_RESOLVE_FAIL"
|
|
DATA_MISSING = "DATA_MISSING"
|
|
|
|
|
|
class ExecuteAction(str, Enum):
|
|
CREATE = "CREATE"
|
|
UPDATE = "UPDATE"
|
|
DELETE = "DELETE"
|
|
|
|
|
|
class HandlerResult(str, Enum):
|
|
SUCCESS = "SUCCESS"
|
|
FAILED = "FAILED"
|
|
SKIPPED = "SKIPPED"
|
|
IN_PROGRESS = "IN_PROGRESS"
|
|
|
|
|
|
def _as_upper_text(value: Any, *, field_name: str) -> str:
|
|
if isinstance(value, Enum):
|
|
raw = value.value
|
|
else:
|
|
raw = value
|
|
if not isinstance(raw, str):
|
|
raise RuntimeError(f"{field_name} must be str/Enum, got {type(raw).__name__}")
|
|
return raw.upper()
|
|
|
|
|
|
def decision_note(decision: Decision) -> str:
|
|
if not decision.emit:
|
|
return ""
|
|
note = decision.emit.get("note")
|
|
return note if isinstance(note, str) else ""
|
|
|
|
|
|
def _apply_decision(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
decision: Decision,
|
|
fields: Optional[Set[str]] = None,
|
|
reason_override: Optional[str] = None,
|
|
) -> str:
|
|
reason = reason_override if reason_override is not None else decision_note(decision)
|
|
if decision.to_state:
|
|
reason_text = reason or "<no-reason>"
|
|
node.append_log(
|
|
f"状态机流转: {decision.from_state} --{decision.event_id}[{decision.outcome_index}]--> {decision.to_state} | reason={reason_text}"
|
|
)
|
|
apply_state_to_node(runtime, node, state_id=decision.to_state, reason=reason, fields=fields)
|
|
|
|
if decision.to_state:
|
|
snapshot = {
|
|
"binding_status": node.binding_status.value,
|
|
"action": node.action.value,
|
|
"status": node.status.value,
|
|
"data_id_exist": bool(node.data_id),
|
|
}
|
|
violations = runtime.check_invariants(snapshot)
|
|
if violations:
|
|
details = "; ".join(f"{v.invariant_id}: {v.message}" for v in violations)
|
|
raise RuntimeError(f"Invariant violation after {decision.event_id}->{decision.to_state}: {details}")
|
|
|
|
return reason
|
|
|
|
|
|
def _format_dependency_reason(
|
|
*,
|
|
base_reason: str,
|
|
dep_errors: Optional[list[str]] = None,
|
|
semantic_errors: Optional[list[str]] = None,
|
|
) -> str:
|
|
details: list[str] = []
|
|
for dep_err in dep_errors or []:
|
|
parts = dep_err.split("|")
|
|
tag = parts[0] if parts else "unknown"
|
|
if tag == "not_found" and len(parts) >= 4:
|
|
details.append(f"{parts[1]}:{parts[2]} data_id={parts[3]} -> not_found")
|
|
elif tag == "bad_status" and len(parts) >= 3:
|
|
details.append(f"{parts[1]} status={parts[2]} -> not_normal")
|
|
elif tag == "no_data_id" and len(parts) >= 3:
|
|
details.append(f"{parts[1]} {parts[2]} -> no_data_id")
|
|
else:
|
|
details.append(dep_err)
|
|
|
|
if semantic_errors:
|
|
details.extend([e for e in semantic_errors if e])
|
|
|
|
if not details:
|
|
return base_reason
|
|
return f"{base_reason} | depends=[{'; '.join(details)}]"
|
|
|
|
|
|
def _expected_matches_actual(expected: Any, actual: Any) -> bool:
|
|
if isinstance(expected, list):
|
|
return any(_expected_matches_actual(x, actual) for x in expected)
|
|
|
|
if isinstance(expected, str):
|
|
actual_candidates = set()
|
|
if isinstance(actual, Enum):
|
|
actual_candidates.update({str(actual.name), str(actual.value)})
|
|
else:
|
|
actual_candidates.add(str(actual))
|
|
expected_lower = expected.lower()
|
|
return any(str(c).lower() == expected_lower for c in actual_candidates)
|
|
|
|
return expected == actual
|
|
|
|
|
|
def _state_matches_node(
|
|
runtime: StateMachineRuntime,
|
|
node: "SyncNode",
|
|
state_id: str,
|
|
*,
|
|
action_hint: Optional[str] = None,
|
|
match_fields: Optional[Set[str]] = None,
|
|
) -> bool:
|
|
states = runtime._config.raw.get("states")
|
|
pseudo_states = runtime._config.raw.get("pseudo_states")
|
|
if not isinstance(states, dict):
|
|
raise RuntimeError("invalid state-machine config: missing states")
|
|
if pseudo_states is not None and not isinstance(pseudo_states, dict):
|
|
raise RuntimeError("invalid state-machine config: pseudo_states must be mapping")
|
|
state_def = states.get(state_id)
|
|
if state_def is None and isinstance(pseudo_states, dict):
|
|
state_def = pseudo_states.get(state_id)
|
|
if not isinstance(state_def, dict):
|
|
return False
|
|
|
|
snapshot = {
|
|
"binding_status": node.binding_status,
|
|
"action": action_hint if action_hint is not None else node.action,
|
|
"status": node.status,
|
|
"data_id_exist": bool(node.data_id),
|
|
}
|
|
|
|
selected_fields = match_fields or set(snapshot.keys())
|
|
|
|
for k, actual in snapshot.items():
|
|
if k not in selected_fields:
|
|
continue
|
|
if k not in state_def:
|
|
continue
|
|
expected = state_def[k]
|
|
if not _expected_matches_actual(expected, actual):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _resolve_current_state(
|
|
runtime: StateMachineRuntime,
|
|
node: "SyncNode",
|
|
*,
|
|
candidate_states: Sequence[str],
|
|
action_hint: Optional[str] = None,
|
|
match_fields: Optional[Set[str]] = None,
|
|
) -> str:
|
|
matches = [
|
|
s
|
|
for s in candidate_states
|
|
if _state_matches_node(runtime, node, s, action_hint=action_hint, match_fields=match_fields)
|
|
]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
if not matches:
|
|
raise RuntimeError(
|
|
f"cannot resolve current state for node={node.node_id}, candidates={list(candidate_states)}"
|
|
)
|
|
raise RuntimeError(
|
|
f"ambiguous current state for node={node.node_id}, candidates={list(candidate_states)}, matches={matches}"
|
|
)
|
|
|
|
|
|
def _config_from_states(runtime: StateMachineRuntime, event_id: str) -> list[str]:
|
|
transition = runtime._config.transitions.get(event_id)
|
|
if transition is None:
|
|
raise RuntimeError(f"unknown event: {event_id}")
|
|
from_states = [s for s in transition.from_states if s != "*"]
|
|
if not from_states:
|
|
raise RuntimeError(f"event {event_id} has no concrete from states")
|
|
return from_states
|
|
|
|
|
|
def _assert_expected_from_states(
|
|
*,
|
|
event_id: str,
|
|
config_from_states: Sequence[str],
|
|
expected_from_states: Optional[Sequence[str]],
|
|
) -> None:
|
|
if expected_from_states is None:
|
|
return
|
|
config_set = set(config_from_states)
|
|
expected_set = set(expected_from_states)
|
|
if config_set != expected_set:
|
|
raise RuntimeError(
|
|
f"event {event_id} from-states mismatch with config: "
|
|
f"config={sorted(config_set)}, expected={sorted(expected_set)}"
|
|
)
|
|
|
|
|
|
def _dispatch_on_resolved_state(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
current_state: str,
|
|
event_id: str,
|
|
context: Mapping[str, Any],
|
|
) -> Decision:
|
|
decision = runtime.dispatch(current_state=current_state, event_id=event_id, context=context)
|
|
if decision is None:
|
|
raise RuntimeError(
|
|
f"state-machine miss: node={node.node_id}, state={current_state}, event={event_id}, context={dict(context)}"
|
|
)
|
|
return decision
|
|
|
|
|
|
def _dispatch_required(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
event_id: str,
|
|
context: Mapping[str, Any],
|
|
action_hint: Optional[str] = None,
|
|
match_fields: Optional[Set[str]] = None,
|
|
expected_from_states: Optional[Sequence[str]] = None,
|
|
) -> Decision:
|
|
from_states = _config_from_states(runtime, event_id)
|
|
_assert_expected_from_states(
|
|
event_id=event_id,
|
|
config_from_states=from_states,
|
|
expected_from_states=expected_from_states,
|
|
)
|
|
|
|
current_state = _resolve_current_state(
|
|
runtime,
|
|
node,
|
|
candidate_states=from_states,
|
|
action_hint=action_hint,
|
|
match_fields=match_fields,
|
|
)
|
|
|
|
return _dispatch_on_resolved_state(
|
|
runtime,
|
|
node=node,
|
|
current_state=current_state,
|
|
event_id=event_id,
|
|
context=context,
|
|
)
|
|
|
|
|
|
def e01_bootstrap(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
is_create_zombie: bool,
|
|
) -> Decision:
|
|
context = {"is_create_zombie": is_create_zombie}
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id="E01",
|
|
context=context,
|
|
expected_from_states=["S16"],
|
|
)
|
|
if decision.to_state == "S00":
|
|
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status", "action", "status"})
|
|
node.error = None
|
|
return decision
|
|
if decision.to_state == "S15":
|
|
reason_text = decision_note(decision) or "<no-reason>"
|
|
node.append_log(
|
|
f"状态机流转: {decision.from_state} --{decision.event_id}[{decision.outcome_index}]--> {decision.to_state} | reason={reason_text}"
|
|
)
|
|
return decision
|
|
raise RuntimeError(f"unexpected bootstrap state: {decision.to_state}")
|
|
|
|
|
|
def e10_bind_core(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
has_binding_record: bool,
|
|
core_binding_result: Optional[str] = None,
|
|
peer_core_binding_result: Optional[str] = None,
|
|
local_data: Optional[Any] = None,
|
|
peer_data: Optional[Any] = None,
|
|
local_valid: bool = True,
|
|
peer_valid: bool = True,
|
|
) -> Decision:
|
|
if has_binding_record and (core_binding_result is None or peer_core_binding_result is None):
|
|
local_result, peer_result = classify_core_pair(
|
|
has_binding_record=has_binding_record,
|
|
local_data=local_data,
|
|
peer_data=peer_data,
|
|
local_valid=local_valid,
|
|
peer_valid=peer_valid,
|
|
)
|
|
core_binding_result = local_result.value
|
|
peer_core_binding_result = peer_result.value
|
|
|
|
normalized_core = _as_upper_text(core_binding_result, field_name="core_binding_result") if core_binding_result is not None else None
|
|
normalized_peer_core = (
|
|
_as_upper_text(peer_core_binding_result, field_name="peer_core_binding_result")
|
|
if peer_core_binding_result is not None
|
|
else None
|
|
)
|
|
|
|
context: dict[str, Any] = {
|
|
"has_binding_record": has_binding_record,
|
|
}
|
|
if normalized_core is not None:
|
|
context["core_binding_result"] = normalized_core
|
|
if normalized_peer_core is not None:
|
|
context["peer_core_binding_result"] = normalized_peer_core
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id="E10",
|
|
context=context,
|
|
expected_from_states=["S00"],
|
|
)
|
|
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status"})
|
|
return decision
|
|
|
|
|
|
def e20_create_prepare(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
spawn_success: bool,
|
|
) -> Decision:
|
|
if not spawn_success:
|
|
node.append_log("状态机流转: S13 --E20[-1]--> <no-transition> | reason=创建副作用未完成")
|
|
return Decision(
|
|
event_id="E20",
|
|
from_state="S13",
|
|
outcome_index=-1,
|
|
to_state=None,
|
|
emit={"note": "创建副作用未完成"},
|
|
)
|
|
|
|
context = {
|
|
"spawn_success": spawn_success,
|
|
}
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id="E20",
|
|
context=context,
|
|
expected_from_states=["S13"],
|
|
)
|
|
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status", "action", "status"})
|
|
return decision
|
|
|
|
|
|
def e21_create_prepare(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
create_enabled: bool,
|
|
data_present: bool,
|
|
create_id_resolve_ok: bool,
|
|
) -> Decision:
|
|
context = {
|
|
"create_enabled": create_enabled,
|
|
"data_present": data_present,
|
|
"create_id_resolve_ok": create_id_resolve_ok,
|
|
}
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id="E21",
|
|
context=context,
|
|
expected_from_states=["S02"],
|
|
)
|
|
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status"})
|
|
return decision
|
|
|
|
|
|
def e22_delete_prepare(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
) -> Decision:
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id="E22",
|
|
context={},
|
|
expected_from_states=["S02"],
|
|
)
|
|
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status", "action", "status"})
|
|
return decision
|
|
|
|
|
|
def e30_update_prepare(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
source_node: "SyncNode",
|
|
target_node: "SyncNode",
|
|
update_enabled: bool,
|
|
target_has_data_id: bool,
|
|
update_id_resolve_ok: bool,
|
|
has_diff: bool,
|
|
id_resolve_detail: Optional[str] = None,
|
|
) -> Decision:
|
|
context = {
|
|
"update_enabled": update_enabled,
|
|
"target_has_data_id": target_has_data_id,
|
|
"update_id_resolve_ok": update_id_resolve_ok,
|
|
"has_diff": has_diff,
|
|
}
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=source_node,
|
|
event_id="E30",
|
|
context=context,
|
|
expected_from_states=["S01"],
|
|
)
|
|
if decision.to_state not in {"S07", "S08"}:
|
|
raise RuntimeError(f"unexpected update-prepare state: {decision.to_state}")
|
|
reason_override: Optional[str] = None
|
|
if decision.to_state == "S08":
|
|
base_reason = decision_note(decision)
|
|
details: list[str] = []
|
|
if not target_has_data_id:
|
|
details.append(f"target_data_id_missing=true")
|
|
details.append(f"target_node_id={target_node.node_id}")
|
|
if target_has_data_id and not update_id_resolve_ok:
|
|
if id_resolve_detail:
|
|
details.append(f"update_id_resolve_fail=true")
|
|
details.append(f"update_id_resolve_detail={id_resolve_detail}")
|
|
else:
|
|
details.append("update_id_resolve_fail=true")
|
|
if details:
|
|
reason_override = f"{base_reason} | {'; '.join(details)}"
|
|
target_node.error = reason_override
|
|
|
|
_apply_decision(
|
|
runtime,
|
|
node=target_node,
|
|
decision=decision,
|
|
fields={"action", "status"},
|
|
reason_override=reason_override,
|
|
)
|
|
return decision
|
|
|
|
|
|
def e41_post_create_ready(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
execute_action: str = "CREATE",
|
|
) -> Decision:
|
|
execute_action_norm = _as_upper_text(execute_action, field_name="execute_action")
|
|
context = {
|
|
"execute_action": execute_action_norm,
|
|
}
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id="E41",
|
|
context=context,
|
|
expected_from_states=["S11C"],
|
|
)
|
|
_apply_decision(runtime, node=node, decision=decision, fields={"action", "status"})
|
|
return decision
|
|
|
|
|
|
def e15_bind_dependency(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
data_present: bool,
|
|
has_depend_fields: bool = True,
|
|
dep_satisfied: bool = True,
|
|
dep_errors: Optional[list[str]] = None,
|
|
semantic_errors: Optional[list[str]] = None,
|
|
) -> Decision:
|
|
if data_present and dep_satisfied:
|
|
node.append_log("状态机流转: S02 --E15[-1]--> <no-transition> | reason=依赖满足")
|
|
return Decision(
|
|
event_id="E15",
|
|
from_state="S02",
|
|
outcome_index=-1,
|
|
to_state=None,
|
|
emit={"note": "依赖满足"},
|
|
)
|
|
|
|
context = {
|
|
"data_present": data_present,
|
|
"has_depend_fields": has_depend_fields,
|
|
"dep_satisfied": dep_satisfied,
|
|
}
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id="E15",
|
|
context=context,
|
|
expected_from_states=["S02"],
|
|
)
|
|
reason = decision_note(decision)
|
|
if decision.to_state in {"S03", "S04", "S05"}:
|
|
reason = _format_dependency_reason(
|
|
base_reason=reason,
|
|
dep_errors=dep_errors,
|
|
semantic_errors=semantic_errors,
|
|
)
|
|
node.error = reason
|
|
|
|
_apply_decision(
|
|
runtime,
|
|
node=node,
|
|
decision=decision,
|
|
fields={"binding_status"},
|
|
reason_override=reason,
|
|
)
|
|
return decision
|
|
|
|
|
|
def e16_bind_auto(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
auto_bind_enabled: bool,
|
|
auto_bind_outcome: Optional[str] = None,
|
|
create_enabled: Optional[bool] = None,
|
|
id_resolve_detail: Optional[str] = None,
|
|
) -> Decision:
|
|
auto_bind_outcome_norm = (
|
|
_as_upper_text(auto_bind_outcome, field_name="auto_bind_outcome")
|
|
if auto_bind_outcome is not None
|
|
else None
|
|
)
|
|
|
|
context: dict[str, Any] = {"auto_bind_enabled": auto_bind_enabled}
|
|
if auto_bind_outcome_norm is not None:
|
|
context["auto_bind_outcome"] = auto_bind_outcome_norm
|
|
if create_enabled is not None:
|
|
context["create_enabled"] = create_enabled
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id="E16",
|
|
context=context,
|
|
expected_from_states=["S02"],
|
|
)
|
|
reason_override: Optional[str] = None
|
|
if auto_bind_outcome_norm == "KEY_ID_RESOLVE_FAIL" and id_resolve_detail:
|
|
base_reason = decision_note(decision)
|
|
reason_override = f"{base_reason} | key_id_resolve_fail=true; key_id_resolve_detail={id_resolve_detail}"
|
|
node.error = reason_override
|
|
|
|
_apply_decision(
|
|
runtime,
|
|
node=node,
|
|
decision=decision,
|
|
fields={"binding_status", "action", "status"},
|
|
reason_override=reason_override,
|
|
)
|
|
return decision
|
|
|
|
|
|
def e40_sync_execute(
|
|
runtime: StateMachineRuntime,
|
|
*,
|
|
node: "SyncNode",
|
|
handler_result: str,
|
|
action: str,
|
|
poll_timeout: bool,
|
|
result_data_id: Optional[str] = None,
|
|
) -> Optional[Decision]:
|
|
action_norm = _as_upper_text(action, field_name="action")
|
|
if action_norm == "CREATE":
|
|
start_event_id = "E40C_START"
|
|
start_states = ["S06"]
|
|
result_event_id = "E40C"
|
|
result_states = ["S10C"]
|
|
elif action_norm == "UPDATE":
|
|
start_event_id = "E40U_START"
|
|
start_states = ["S07"]
|
|
result_event_id = "E40U"
|
|
result_states = ["S10U"]
|
|
elif action_norm == "DELETE":
|
|
start_event_id = "E40D_START"
|
|
start_states = ["S09"]
|
|
result_event_id = "E40D"
|
|
result_states = ["S10D"]
|
|
else:
|
|
return None
|
|
|
|
normalized_result = "FAILED" if poll_timeout else _as_upper_text(handler_result, field_name="handler_result")
|
|
if normalized_result == "SKIPPED" and action_norm != "UPDATE":
|
|
return None
|
|
|
|
start_context = {
|
|
"poll_timeout": poll_timeout,
|
|
"handler_result": "IN_PROGRESS",
|
|
"execute_action": action_norm,
|
|
}
|
|
result_context = {
|
|
"poll_timeout": poll_timeout,
|
|
"handler_result": normalized_result,
|
|
"execute_action": action_norm,
|
|
}
|
|
|
|
decision: Optional[Decision] = None
|
|
in_entry_state = any(_state_matches_node(runtime, node, s) for s in start_states)
|
|
if in_entry_state:
|
|
start_decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id=start_event_id,
|
|
context=start_context,
|
|
expected_from_states=start_states,
|
|
)
|
|
_apply_decision(runtime, node=node, decision=start_decision, fields={"action", "status"})
|
|
if normalized_result == "IN_PROGRESS":
|
|
return start_decision
|
|
|
|
if normalized_result == "IN_PROGRESS":
|
|
return None
|
|
|
|
if normalized_result not in {"SUCCESS", "FAILED", "SKIPPED"}:
|
|
return None
|
|
|
|
decision = _dispatch_required(
|
|
runtime,
|
|
node=node,
|
|
event_id=result_event_id,
|
|
context=result_context,
|
|
action_hint="UPDATE" if action_norm == "UPDATE" else None,
|
|
expected_from_states=result_states,
|
|
)
|
|
|
|
if decision is None:
|
|
return None
|
|
|
|
apply_fields: Set[str] = {"action", "status"}
|
|
apply_data_id: Optional[str] = None
|
|
apply_data_id_field = False
|
|
if decision.to_state == "S11C":
|
|
if action_norm == "CREATE" and not result_data_id:
|
|
raise RuntimeError("E40 SUCCESS for CREATE requires non-empty result_data_id")
|
|
if result_data_id:
|
|
apply_data_id = result_data_id
|
|
apply_data_id_field = True
|
|
|
|
_apply_decision(runtime, node=node, decision=decision, fields=apply_fields)
|
|
if apply_data_id_field:
|
|
apply_state_to_node(runtime, node, state_id=decision.to_state, reason=decision_note(decision), fields={"data_id"}, data_id=apply_data_id)
|
|
return decision
|