first commit
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
from .dispatcher import Decision, StateMachineRuntime
|
||||
from .events import (
|
||||
e01_bootstrap,
|
||||
e10_bind_core,
|
||||
decision_note,
|
||||
e20_create_prepare,
|
||||
e21_create_prepare,
|
||||
e22_delete_prepare,
|
||||
e30_update_prepare,
|
||||
e41_post_create_ready,
|
||||
e15_bind_dependency,
|
||||
e16_bind_auto,
|
||||
e40_sync_execute,
|
||||
)
|
||||
from .model import Outcome, StateMachineConfig, Transition
|
||||
from .state_apply import apply_state_to_node
|
||||
|
||||
__all__ = [
|
||||
"Decision",
|
||||
"Outcome",
|
||||
"StateMachineConfig",
|
||||
"StateMachineRuntime",
|
||||
"Transition",
|
||||
"apply_state_to_node",
|
||||
"e01_bootstrap",
|
||||
"e10_bind_core",
|
||||
"decision_note",
|
||||
"e20_create_prepare",
|
||||
"e21_create_prepare",
|
||||
"e22_delete_prepare",
|
||||
"e30_update_prepare",
|
||||
"e41_post_create_ready",
|
||||
"e15_bind_dependency",
|
||||
"e16_bind_auto",
|
||||
"e40_sync_execute",
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from .evaluator import guard_matches
|
||||
from .invariants import InvariantViolation
|
||||
from .model import StateMachineConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
event_id: str
|
||||
from_state: str
|
||||
outcome_index: int
|
||||
to_state: Optional[str]
|
||||
emit: Optional[Mapping[str, Any]]
|
||||
|
||||
|
||||
class StateMachineRuntime:
|
||||
def __init__(self, config: StateMachineConfig):
|
||||
self._config = config
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
*,
|
||||
current_state: str,
|
||||
event_id: str,
|
||||
context: Mapping[str, Any],
|
||||
) -> Optional[Decision]:
|
||||
transition = self._config.transitions.get(event_id)
|
||||
if transition is None:
|
||||
raise KeyError(f"Unknown event: {event_id}")
|
||||
|
||||
if transition.from_states and current_state not in transition.from_states and "*" not in transition.from_states:
|
||||
return None
|
||||
|
||||
for index, outcome in enumerate(transition.outcomes):
|
||||
if guard_matches(outcome.when, context):
|
||||
return Decision(
|
||||
event_id=event_id,
|
||||
from_state=current_state,
|
||||
outcome_index=index,
|
||||
to_state=outcome.to,
|
||||
emit=outcome.emit,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def check_invariants(self, snapshot: Mapping[str, Any]) -> list[InvariantViolation]:
|
||||
def _match_all(cond: Mapping[str, Any]) -> bool:
|
||||
for key, value in cond.items():
|
||||
if snapshot.get(key) != value:
|
||||
return False
|
||||
return True
|
||||
|
||||
violations: list[InvariantViolation] = []
|
||||
for invariant_id, idef in self._config.invariants.items():
|
||||
if not isinstance(idef, dict):
|
||||
continue
|
||||
|
||||
should_check = False
|
||||
when = idef.get("when")
|
||||
when_any = idef.get("when_any")
|
||||
if isinstance(when, dict):
|
||||
should_check = _match_all(when)
|
||||
elif isinstance(when_any, list):
|
||||
for cond in when_any:
|
||||
if isinstance(cond, dict) and _match_all(cond):
|
||||
should_check = True
|
||||
break
|
||||
|
||||
if not should_check:
|
||||
continue
|
||||
|
||||
require = idef.get("require")
|
||||
if not isinstance(require, dict):
|
||||
continue
|
||||
|
||||
for rk, rv in require.items():
|
||||
if snapshot.get(rk) != rv:
|
||||
violations.append(
|
||||
InvariantViolation(
|
||||
invariant_id=str(invariant_id),
|
||||
message=f"require {rk}={rv}, got {snapshot.get(rk)}",
|
||||
)
|
||||
)
|
||||
return violations
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def guard_matches(guard: Mapping[str, Any], context: Mapping[str, Any]) -> bool:
|
||||
for key, expected in guard.items():
|
||||
if context.get(key) != expected:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,675 @@
|
||||
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
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InvariantViolation:
|
||||
invariant_id: str
|
||||
message: str
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Outcome:
|
||||
when: Mapping[str, Any]
|
||||
to: Optional[str] = None
|
||||
emit: Optional[Mapping[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transition:
|
||||
event_id: str
|
||||
stage: str
|
||||
desc: str
|
||||
from_states: List[str]
|
||||
outcomes: List[Outcome]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateMachineConfig:
|
||||
path: Path
|
||||
raw: Mapping[str, Any]
|
||||
transitions: Mapping[str, Transition]
|
||||
invariants: Mapping[str, Mapping[str, Any]]
|
||||
|
||||
@staticmethod
|
||||
def load(path: Path) -> "StateMachineConfig":
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise TypeError(f"Invalid state machine yaml: {path}")
|
||||
|
||||
transitions_raw = raw.get("transitions")
|
||||
if not isinstance(transitions_raw, dict):
|
||||
raise TypeError("'transitions' must be a mapping")
|
||||
|
||||
parsed: Dict[str, Transition] = {}
|
||||
for event_id, tdef in transitions_raw.items():
|
||||
if not isinstance(tdef, dict):
|
||||
continue
|
||||
outcomes: List[Outcome] = []
|
||||
for o in tdef.get("outcomes", []):
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
when = o.get("when", {})
|
||||
if not isinstance(when, dict):
|
||||
continue
|
||||
outcomes.append(
|
||||
Outcome(
|
||||
when=when,
|
||||
to=o.get("to") if isinstance(o.get("to"), str) else None,
|
||||
emit=o.get("emit") if isinstance(o.get("emit"), dict) else None,
|
||||
)
|
||||
)
|
||||
parsed[str(event_id)] = Transition(
|
||||
event_id=str(event_id),
|
||||
stage=str(tdef.get("stage", "")),
|
||||
desc=str(tdef.get("desc", "")),
|
||||
from_states=[s for s in tdef.get("from", []) if isinstance(s, str)],
|
||||
outcomes=outcomes,
|
||||
)
|
||||
|
||||
invariants = raw.get("invariants") if isinstance(raw.get("invariants"), dict) else {}
|
||||
return StateMachineConfig(path=path, raw=raw, transitions=parsed, invariants=invariants)
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Tuple
|
||||
|
||||
from ..common.types import BindingStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..common.sync_node import SyncNode
|
||||
|
||||
|
||||
class CoreBindingResult(str, Enum):
|
||||
NORMAL = "NORMAL"
|
||||
WARNING = "WARNING"
|
||||
ABNORMAL = "ABNORMAL"
|
||||
|
||||
|
||||
def classify_core_pair(
|
||||
*,
|
||||
has_binding_record: bool,
|
||||
local_data: Optional[Any],
|
||||
peer_data: Optional[Any],
|
||||
local_valid: bool = True,
|
||||
peer_valid: bool = True,
|
||||
) -> Tuple[CoreBindingResult, CoreBindingResult]:
|
||||
if not has_binding_record:
|
||||
raise ValueError("classify_core_pair requires has_binding_record=True")
|
||||
|
||||
local_exists = local_data is not None
|
||||
peer_exists = peer_data is not None
|
||||
|
||||
if local_exists and local_valid and peer_exists and peer_valid:
|
||||
return CoreBindingResult.NORMAL, CoreBindingResult.NORMAL
|
||||
if local_exists and local_valid and (not peer_exists or not peer_valid):
|
||||
return CoreBindingResult.WARNING, CoreBindingResult.ABNORMAL
|
||||
if (not local_exists or not local_valid) and peer_exists and peer_valid:
|
||||
return CoreBindingResult.ABNORMAL, CoreBindingResult.WARNING
|
||||
return CoreBindingResult.ABNORMAL, CoreBindingResult.ABNORMAL
|
||||
|
||||
|
||||
def check_dependency_satisfied(
|
||||
dependency_nodes: List[Optional["SyncNode"]],
|
||||
field_values: Optional[List[str]] = None,
|
||||
*,
|
||||
allow_empty_dependency_ref: bool = True,
|
||||
allow_missing_dep_data_id: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
判断依赖是否满足(供 strategy 组装 E15 上下文)。
|
||||
|
||||
该函数本质是“业务语义判断”,状态机只消费最终上下文:
|
||||
- dep_satisfied (bool)
|
||||
- dep_errors (结构化错误列表)
|
||||
|
||||
关键语义:
|
||||
1) 空依赖引用可放行(allow_empty_dependency_ref=True)
|
||||
- 例如 contract_id="",表示该字段本轮不参与依赖约束。
|
||||
2) 依赖节点 data_id 为空可放行(allow_missing_dep_data_id=True)
|
||||
- 这不是严格意义上的“无错误”,而是当前业务策略下的“可继续”。
|
||||
- 原因:刚创建/回填中的依赖节点可能暂时无 data_id,但业务仍希望继续同步。
|
||||
- 未来可通过 strategy 配置将其切换为严格阻断。
|
||||
"""
|
||||
if not dependency_nodes:
|
||||
return True
|
||||
|
||||
values = field_values or []
|
||||
for i, dep_node in enumerate(dependency_nodes):
|
||||
dep_value = values[i] if i < len(values) else ""
|
||||
dep_value_str = "" if dep_value is None else str(dep_value)
|
||||
|
||||
if dep_node is None:
|
||||
if allow_empty_dependency_ref and dep_value_str == "":
|
||||
continue
|
||||
return False
|
||||
if dep_node.binding_status != BindingStatus.NORMAL:
|
||||
return False
|
||||
if not dep_node.data_id and not allow_missing_dep_data_id:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def collect_dependency_errors(
|
||||
dependency_nodes: List[Optional["SyncNode"]],
|
||||
field_names: List[str],
|
||||
field_types: List[str],
|
||||
field_values: List[str],
|
||||
*,
|
||||
allow_empty_dependency_ref: bool = True,
|
||||
allow_missing_dep_data_id: bool = True,
|
||||
) -> List[str]:
|
||||
"""
|
||||
收集依赖错误(结构化错误码),由 strategy 传给状态机上下文。
|
||||
|
||||
设计原则:
|
||||
- 该函数负责“业务层事实归纳”,不是状态迁移本身。
|
||||
- 状态机只关心:是否满足(dep_satisfied) 与错误列表(dep_errors)。
|
||||
|
||||
关于“依赖项缺乏数据不一定是错”:
|
||||
- 某些业务场景里,父节点可能刚创建或尚未回填 data_id;
|
||||
但子节点仍需要进入同步队列,等待后续补齐。
|
||||
- 因此默认 allow_missing_dep_data_id=True:不产出 no_data_id 错误。
|
||||
- 后续可由 strategy 配置切换为严格模式(False),届时 no_data_id 将参与阻断。
|
||||
"""
|
||||
errors: List[str] = []
|
||||
for i, dep_node in enumerate(dependency_nodes):
|
||||
field_name = field_names[i]
|
||||
field_value = field_values[i] if i < len(field_values) else ""
|
||||
field_value_str = "" if field_value is None else str(field_value)
|
||||
|
||||
if dep_node is None:
|
||||
if allow_empty_dependency_ref and field_value_str == "":
|
||||
continue
|
||||
errors.append(f"not_found|{field_name}|{field_types[i]}|{field_values[i]}")
|
||||
continue
|
||||
if dep_node.binding_status != BindingStatus.NORMAL:
|
||||
errors.append(f"bad_status|{field_name}|{dep_node.binding_status.value}")
|
||||
continue
|
||||
if not dep_node.data_id and not allow_missing_dep_data_id:
|
||||
status_info = f"status={dep_node.status.value}" if dep_node.status else "status=未知"
|
||||
errors.append(f"no_data_id|{field_name}|{status_info}")
|
||||
return errors
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Set, Type, TypeVar, cast
|
||||
|
||||
from ..common.sync_node import SyncNode
|
||||
from ..common.types import BindingStatus, SyncAction, SyncStatus
|
||||
from .dispatcher import StateMachineRuntime
|
||||
|
||||
E = TypeVar("E", bound=Enum)
|
||||
|
||||
|
||||
def _state_def(runtime: StateMachineRuntime, state_id: str) -> dict[str, Any]:
|
||||
states = runtime._config.raw.get("states")
|
||||
if not isinstance(states, dict):
|
||||
raise RuntimeError("invalid state-machine config: missing states")
|
||||
state_def = states.get(state_id)
|
||||
if not isinstance(state_def, dict):
|
||||
raise RuntimeError(f"unknown state in config: {state_id}")
|
||||
return state_def
|
||||
|
||||
|
||||
def _to_enum(enum_cls: Type[E], raw_value: Any, field_name: str, state_id: str) -> E:
|
||||
if not isinstance(raw_value, str):
|
||||
raise RuntimeError(
|
||||
f"invalid {field_name} for {state_id}: expected str/list[str], got {type(raw_value).__name__}"
|
||||
)
|
||||
try:
|
||||
return cast(E, enum_cls(raw_value))
|
||||
except Exception:
|
||||
raw_lower = str(raw_value).lower()
|
||||
for member in enum_cls:
|
||||
if str(member.value).lower() == raw_lower or str(member.name).lower() == raw_lower:
|
||||
return cast(E, member)
|
||||
raise RuntimeError(f"invalid {field_name} value for {state_id}: {raw_value}")
|
||||
|
||||
|
||||
def _resolve_enum_value(
|
||||
*,
|
||||
state_id: str,
|
||||
field_name: str,
|
||||
raw_value: Any,
|
||||
enum_cls: Type[E],
|
||||
current_value: E,
|
||||
) -> E:
|
||||
if isinstance(raw_value, str):
|
||||
return _to_enum(enum_cls, raw_value, field_name, state_id)
|
||||
|
||||
if isinstance(raw_value, list):
|
||||
candidates = [_to_enum(enum_cls, v, field_name, state_id) for v in raw_value]
|
||||
if not candidates:
|
||||
raise RuntimeError(f"empty {field_name} candidates for {state_id}")
|
||||
if current_value in candidates:
|
||||
return current_value
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
raise RuntimeError(
|
||||
f"ambiguous {field_name} for {state_id}: candidates={[c.value for c in candidates]}, "
|
||||
f"current={current_value.value}"
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
f"invalid {field_name} for {state_id}: expected str/list[str], got {type(raw_value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def apply_state_to_node(
|
||||
runtime: StateMachineRuntime,
|
||||
node: SyncNode,
|
||||
*,
|
||||
state_id: Optional[str],
|
||||
reason: str = "",
|
||||
fields: Optional[Set[str]] = None,
|
||||
data_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
if not state_id:
|
||||
return False
|
||||
|
||||
state_def = _state_def(runtime, state_id)
|
||||
selected_fields = fields or {"binding_status", "action", "status"}
|
||||
|
||||
with node.allow_core_state_write(source="state_machine"):
|
||||
if "binding_status" in selected_fields and "binding_status" in state_def:
|
||||
target_binding = _resolve_enum_value(
|
||||
state_id=state_id,
|
||||
field_name="binding_status",
|
||||
raw_value=state_def["binding_status"],
|
||||
enum_cls=BindingStatus,
|
||||
current_value=node.binding_status,
|
||||
)
|
||||
node.set_binding_status(target_binding, reason)
|
||||
|
||||
if "action" in selected_fields and "action" in state_def:
|
||||
target_action = _resolve_enum_value(
|
||||
state_id=state_id,
|
||||
field_name="action",
|
||||
raw_value=state_def["action"],
|
||||
enum_cls=SyncAction,
|
||||
current_value=node.action,
|
||||
)
|
||||
node.set_action(target_action, reason)
|
||||
|
||||
if "status" in selected_fields and "status" in state_def:
|
||||
target_status = _resolve_enum_value(
|
||||
state_id=state_id,
|
||||
field_name="status",
|
||||
raw_value=state_def["status"],
|
||||
enum_cls=SyncStatus,
|
||||
current_value=node.status,
|
||||
)
|
||||
node.set_status(target_status, reason)
|
||||
|
||||
if "data_id" in selected_fields:
|
||||
if data_id is None:
|
||||
raise RuntimeError(f"state {state_id} requested data_id update but no data_id provided")
|
||||
node.set_data_id(data_id, reason)
|
||||
|
||||
return True
|
||||
Reference in New Issue
Block a user