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 "" 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 "" 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]--> | 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]--> | 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