mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 06:41:07 +08:00
146 lines
5.8 KiB
Python
146 lines
5.8 KiB
Python
"""Structure-fingerprint recovery (C-1).
|
|
|
|
Detect a canvas hand-edit made while a dify_builder session waited, classify the
|
|
drift, and drive continue-vs-restart. Triggered by three free-string action
|
|
ids (``check_recovery`` / ``recovery_continue`` / ``recovery_restart``) that
|
|
the runner short-circuits on, exactly like ``stop`` / ``resume``.
|
|
|
|
Recovery only changes durable state. The service projects its explanation in
|
|
the existing decision card; the runner preserves user choice receipts.
|
|
"""
|
|
|
|
from core.dify_builder.contract import RecoveryClass, RecoveryRef
|
|
from core.dify_builder.models import ConversationItem, DifyBuilderContext, EntryMode, Session, Turn
|
|
from core.dify_builder.state import PcState
|
|
from services.dify_builder.application.ports import DifyPort
|
|
|
|
__all__ = [
|
|
"apply_recovery_action",
|
|
"classify",
|
|
"entry_state_for",
|
|
"recovery_ref_for",
|
|
"target_node_ids",
|
|
]
|
|
|
|
_ENTRY_STATE: dict[EntryMode, PcState] = {
|
|
EntryMode.FIX: PcState.FIX_DIAGNOSE,
|
|
EntryMode.FIX_CHECKLIST: PcState.CHECKLIST_DIAGNOSE,
|
|
EntryMode.BUILD: PcState.BUILD_CAPABILITY_CHECK,
|
|
EntryMode.EDIT: PcState.EDIT_CAPABILITY_CHECK,
|
|
}
|
|
|
|
|
|
def entry_state_for(entry_mode: EntryMode) -> PcState:
|
|
"""The state a fresh session of this mode starts in. Mirrors
|
|
``service.create_*_session``; a parity test guards drift."""
|
|
return _ENTRY_STATE[entry_mode]
|
|
|
|
|
|
def recovery_ref_for(recovery_class: str) -> RecoveryRef | None:
|
|
"""Build the SessionView recovery offer from a stored class string.
|
|
``None`` when there is no active recovery (``recovery_class == ""``).
|
|
can_continue is false only for a structural-invalidating drift; can_restart
|
|
is false only when nothing changed."""
|
|
if not recovery_class:
|
|
return None
|
|
return RecoveryRef(
|
|
recovery_class=recovery_class,
|
|
can_continue=recovery_class != RecoveryClass.STRUCTURAL_INVALIDATING,
|
|
can_restart=recovery_class != RecoveryClass.UNCHANGED,
|
|
)
|
|
|
|
|
|
def target_node_ids(fc: DifyBuilderContext, entry_mode: EntryMode) -> list[str]:
|
|
"""The node ids this task is about -- used to tell a structural edit that
|
|
LEAVES the targets intact (compatible) from one that removes them
|
|
(invalidating)."""
|
|
if entry_mode == EntryMode.BUILD:
|
|
return list(fc.built_node_ids)
|
|
if entry_mode == EntryMode.EDIT:
|
|
return list(fc.edit_target_node_ids)
|
|
ids: list[str] = []
|
|
if entry_mode == EntryMode.FIX_CHECKLIST:
|
|
ids = [e.node_id for e in fc.checklist_errors if e.node_id]
|
|
if fc.diagnosis is not None and fc.diagnosis.culprit_node_id and fc.diagnosis.culprit_node_id not in ids:
|
|
ids.append(fc.diagnosis.culprit_node_id)
|
|
return ids
|
|
|
|
|
|
def classify(
|
|
cur_hash: str,
|
|
cur_fingerprint: str,
|
|
present_node_ids: list[str],
|
|
fc: DifyBuilderContext,
|
|
entry_mode: EntryMode,
|
|
) -> RecoveryClass:
|
|
"""Four-way drift classification (C-1)."""
|
|
if cur_hash == fc.last_snapshot_hash:
|
|
return RecoveryClass.UNCHANGED
|
|
if cur_fingerprint == fc.last_structure_fingerprint:
|
|
return RecoveryClass.CONFIG_ONLY
|
|
if set(target_node_ids(fc, entry_mode)) <= set(present_node_ids):
|
|
return RecoveryClass.STRUCTURAL_COMPATIBLE
|
|
return RecoveryClass.STRUCTURAL_INVALIDATING
|
|
|
|
|
|
def _reset_working_fields(fc: DifyBuilderContext) -> None:
|
|
"""Clear per-flow working state for a clean re-entry. Preserves the
|
|
conversation, next_seq, and the mode's durable inputs (goal_text,
|
|
requirements, edit_rules, checklist_errors, failed_run_id, source)."""
|
|
fc.diagnosis = None
|
|
fc.staged_repair = []
|
|
# Same-task recovery retains native failure/graph convergence evidence.
|
|
fc.risk = None
|
|
fc.change_set = None
|
|
fc.checkpoint_id = ""
|
|
fc.checkpoint_seq = 0
|
|
fc.verify_run_id = ""
|
|
fc.test_input_ref = ""
|
|
fc.effect_confirmation = {}
|
|
fc.effect_confirmation_form = {}
|
|
fc.plan_items = []
|
|
fc.plan_title = ""
|
|
fc.plan_input_summary = ""
|
|
fc.plan_output_summary = ""
|
|
fc.plan_version_tag = ""
|
|
fc.resource_selection = {}
|
|
fc.built_node_ids = []
|
|
fc.edit_target_node_ids = []
|
|
# The engine's refusal of a batch that is no longer staged: quoting it at
|
|
# the next proposal would send the agent after a problem that is gone.
|
|
fc.last_edit_rejection = ""
|
|
fc.last_snapshot_hash = ""
|
|
fc.last_structure_fingerprint = ""
|
|
fc.paused = False
|
|
|
|
|
|
def apply_recovery_action(
|
|
dify: DifyPort, turn: Turn, s: Session, fc: DifyBuilderContext
|
|
) -> tuple[PcState, list[ConversationItem]]:
|
|
"""Handle check_recovery / recovery_continue / recovery_restart. Returns
|
|
``(next_state, items)`` for the runner to commit in a single CAS step.
|
|
The runner guarantees ``turn.action.kind`` is one of the three."""
|
|
kind = turn.action.kind if turn.action is not None else ""
|
|
if kind == "check_recovery":
|
|
graph, cur_hash = dify.read_graph(s.app_id, turn.actor)
|
|
cur_fp = dify.structural_fingerprint(graph)
|
|
present = dify.graph_node_ids(graph)
|
|
cls = classify(cur_hash, cur_fp, present, fc, s.entry_mode)
|
|
fc.recovery_class = str(cls)
|
|
return s.current_state, []
|
|
if kind == "recovery_continue":
|
|
graph, cur_hash = dify.read_graph(s.app_id, turn.actor)
|
|
fc.last_snapshot_hash = cur_hash
|
|
fc.last_structure_fingerprint = dify.structural_fingerprint(graph)
|
|
fc.recovery_class = ""
|
|
return s.current_state, []
|
|
if kind == "recovery_restart":
|
|
fc.task_control["generation"] = int(fc.task_control.get("generation", 0)) + 1
|
|
fc.recovery_class = ""
|
|
_reset_working_fields(fc)
|
|
graph, cur_hash = dify.read_graph(s.app_id, turn.actor)
|
|
fc.last_snapshot_hash = cur_hash
|
|
fc.last_structure_fingerprint = dify.structural_fingerprint(graph)
|
|
return entry_state_for(s.entry_mode), []
|
|
return s.current_state, [] # defensive: unreachable given the runner's guard
|