mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 14:51:39 +08:00
1460 lines
62 KiB
Python
1460 lines
62 KiB
Python
"""Edit-flow handlers: pure ``(env, turn, session, fc) -> StepResult`` steps.
|
|
|
|
Mirrors ``handlers_build.py`` for the Edit entry mode (spec: docs/superpowers/
|
|
specs/2026-08-23-dify-builder-slice3-edit-design.md). Cards for a state are
|
|
emitted by the handler transitioning INTO it; ``edit.test_affected_paths`` and
|
|
``edit.publish`` are working states that auto-advance. Publishing is separated
|
|
from the review decision so its progress and side effect precede the terminal
|
|
``edit.complete`` transition. Keeping the draft reaches that terminal directly.
|
|
"""
|
|
|
|
import logging
|
|
import uuid
|
|
from dataclasses import replace
|
|
|
|
from core.dify_builder.contract import (
|
|
DecisionItem,
|
|
FormCard,
|
|
TestResultCard,
|
|
)
|
|
from core.dify_builder.errors import DraftWouldNotStartError, HashMismatchError, ProposalWouldRunWrongError
|
|
from core.dify_builder.input_schema import schema_hash, validate_runtime_inputs
|
|
from core.dify_builder.models import (
|
|
Action,
|
|
ChangeSet,
|
|
Diagnosis,
|
|
DifyBuilderContext,
|
|
Graph,
|
|
NodeEvent,
|
|
Risk,
|
|
Run,
|
|
Session,
|
|
Snapshot,
|
|
TestInput,
|
|
Turn,
|
|
)
|
|
from core.dify_builder.progress import ProgressReporter
|
|
from core.dify_builder.state import PcState
|
|
from runtime_contracts.builder.copy_ids import BuilderCopyId
|
|
from runtime_contracts.builder.presentation import BuilderText, copy_text, text_parts
|
|
from services.dify_builder.application.handlers_fix import (
|
|
NO_OUTPUT_BODY,
|
|
UNKNOWN_OUTCOME_STUCK_BODY,
|
|
UNKNOWN_TEST_OUTCOME_REPLY,
|
|
action_kind,
|
|
action_string,
|
|
append_assistant,
|
|
append_card,
|
|
build_change_set,
|
|
confirmed_tool_test,
|
|
dead_end_branch_node_id,
|
|
drop_unapplied_repair,
|
|
emit_canvas,
|
|
first_failed_node,
|
|
input_gate_result,
|
|
is_confirmed_tool_turn,
|
|
is_input_failure,
|
|
launch_error_text,
|
|
merge_known_keys,
|
|
mint_checkpoint,
|
|
model_config_error_text,
|
|
needs_effect_confirmation,
|
|
note_repair_error,
|
|
note_unknown_outcome,
|
|
perform_revert,
|
|
publication_denied,
|
|
publication_for_context,
|
|
record_effect_confirmation,
|
|
repair_is_repeating,
|
|
run_finished_without_output,
|
|
saved_test_inputs,
|
|
start_schema,
|
|
test_failure_reason,
|
|
testdata_form,
|
|
without_endpoint_values,
|
|
)
|
|
from services.dify_builder.application.runner import Env, Handler, StepResult
|
|
from services.dify_builder.application.testdata import mock_input_business_context, stamp_testdata_http_fixtures
|
|
from services.dify_builder.application.verification import (
|
|
SUCCESS_REPLY,
|
|
has_output_blocker,
|
|
is_execution_policy_blocker,
|
|
result_card,
|
|
)
|
|
from services.dify_builder.business_acceptance import (
|
|
business_failure_context,
|
|
clear_task_acceptance,
|
|
confirm_testdata_contract,
|
|
record_run_acceptance,
|
|
)
|
|
from services.dify_builder.execution_messages import change_scope_label
|
|
from services.dify_builder.plan import EditPlanNotActionableError, WorkflowPlan, make_plan_card, store_plan
|
|
from services.dify_builder.plan_failure import edit_planning_failed
|
|
from services.dify_builder.repair_gate import NO_REPAIR_REASON
|
|
from services.dify_builder.resource_grounding import edit_rule_form_fields
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = [
|
|
"edit_registry",
|
|
"handle_apply_changes",
|
|
"handle_await_repair",
|
|
"handle_await_testdata",
|
|
"handle_capability_check",
|
|
"handle_impact_analysis",
|
|
"handle_plan_approval",
|
|
"handle_publish",
|
|
"handle_reverted",
|
|
"handle_review",
|
|
"handle_test_affected_paths",
|
|
]
|
|
|
|
|
|
def handle_capability_check(
|
|
env: Env, turn: Turn, s: Session, fc: DifyBuilderContext, *, submitted_rules: dict | None = None
|
|
) -> StepResult:
|
|
"""(waiting) Entry state. On ``send_edit_goal`` read the existing graph,
|
|
emit a read-only context summary, analyze impact into edit_rules + target
|
|
nodes (highlighting them). Necessary questions wait at edit.impact_analysis;
|
|
a sufficient goal proceeds to the existing plan/checkpoint approval gate.
|
|
The canvas is read only here after the goal is sent (mock 02-edit.txt:3,9)."""
|
|
kind = action_kind(turn)
|
|
if kind != "send_edit_goal":
|
|
return StepResult(next=PcState.EDIT_CAPABILITY_CHECK, context=fc)
|
|
|
|
text, ok = action_string(turn, "text")
|
|
if ok and text:
|
|
if text != fc.goal_text:
|
|
clear_task_acceptance(fc)
|
|
fc.repair_attempts = 0
|
|
fc.last_repair_error = ""
|
|
fc.last_repair_graph_revision = ""
|
|
fc.goal_text = text
|
|
# Re-entering with the same goal must not replenish a failed repair budget.
|
|
fc.last_edit_rejection = ""
|
|
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
reply_language=fc.reply_language,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("edit-inspect-workflow", copy_text(BuilderCopyId.PROGRESS_INSPECT_THE_CURRENT_WORKFLOW)),
|
|
("edit-analyze-impact", copy_text(BuilderCopyId.PROGRESS_ANALYZE_THE_REQUESTED_CHANGE)),
|
|
("edit-prepare-impact", copy_text(BuilderCopyId.PROGRESS_PREPARE_THE_IMPACT_SUMMARY)),
|
|
],
|
|
)
|
|
progress.activate("edit-inspect-workflow")
|
|
graph, graph_hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
fc.last_snapshot_hash = graph_hash
|
|
fc.last_structure_fingerprint = env.dify.structural_fingerprint(graph)
|
|
node_count = len(graph.get("nodes", []))
|
|
edge_count = len(graph.get("edges", []))
|
|
|
|
progress.activate("edit-analyze-impact")
|
|
impact = env.agent.analyze_impact(fc.goal_text, graph, reply_language=fc.reply_language or None)
|
|
fc.form_fields = list(impact.get("fields") or [])
|
|
fc.edit_rules = {**dict(impact.get("values") or {}), "change": fc.goal_text}
|
|
fc.edit_target_node_ids = list(impact.get("target_node_ids") or [])
|
|
if submitted_rules is not None:
|
|
# A revised goal owns a fresh resource request. Carry confirmed ordinary
|
|
# values only where that fresh impact still owns the field; never reuse
|
|
# an old choice merely because its resource field index is the same.
|
|
keys = [
|
|
field["key"]
|
|
for field in fc.form_fields
|
|
if isinstance(field, dict) and field.get("key") and not str(field["key"]).startswith("__builder_resource")
|
|
]
|
|
fc.edit_rules = merge_known_keys(fc.edit_rules, submitted_rules, keys)
|
|
fc.resource_selection = {}
|
|
|
|
for node_id in fc.edit_target_node_ids:
|
|
emit_canvas(env, "highlight_edit_target", node_id=node_id)
|
|
|
|
progress.activate("edit-prepare-impact")
|
|
form_items = []
|
|
if fc.form_fields:
|
|
form_items = append_card(
|
|
fc,
|
|
FormCard(
|
|
variant="edit_rules",
|
|
title=copy_text(BuilderCopyId.FORMS_EDIT_RULES_TITLE),
|
|
description=copy_text(BuilderCopyId.FORMS_EDIT_RULES_DESCRIPTION),
|
|
fields=edit_rule_form_fields(fc),
|
|
values=dict(fc.edit_rules),
|
|
frozen=False,
|
|
),
|
|
)
|
|
execution = progress.finish() if form_items else None
|
|
target_text = ", ".join(fc.edit_target_node_ids)
|
|
from services.dify_builder.resource_grounding import edit_resource_guidance
|
|
|
|
resource_guidance = edit_resource_guidance(fc.form_fields, fc.reply_language)
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
text_parts(
|
|
copy_text(BuilderCopyId.EDIT_INSPECTED, nodes=node_count, connections=edge_count, targets=target_text)
|
|
if target_text
|
|
else copy_text(BuilderCopyId.EDIT_INSPECTED_WITHOUT_TARGETS, nodes=node_count, connections=edge_count),
|
|
text_parts("\n\n", resource_guidance) if resource_guidance is not None else "",
|
|
),
|
|
execution=execution,
|
|
cards=["form"] if form_items else None,
|
|
turn_id=progress.operation_id,
|
|
)
|
|
if not fc.form_fields:
|
|
# Reuse the existing planning gate, including its checkpoint and error
|
|
# recovery state. This never approves a plan or applies the edit.
|
|
result = handle_impact_analysis(
|
|
env,
|
|
replace(turn, action=Action(kind="submit_edit_rules")),
|
|
replace(s, current_state=PcState.EDIT_IMPACT_ANALYSIS),
|
|
fc,
|
|
progress=progress,
|
|
)
|
|
return replace(result, items=[*turn_items, *result.items])
|
|
return StepResult(
|
|
next=PcState.EDIT_IMPACT_ANALYSIS,
|
|
context=fc,
|
|
items=[*form_items, *turn_items],
|
|
)
|
|
|
|
|
|
def handle_impact_analysis(
|
|
env: Env, turn: Turn, s: Session, fc: DifyBuilderContext, *, progress: ProgressReporter | None = None
|
|
) -> StepResult:
|
|
"""(waiting) On ``submit_edit_rules`` merge the form payload, propose the
|
|
change plan, self-mint the backend pre-edit checkpoint, and transition to
|
|
edit.plan_approval. A stored engine refusal is cleared when the submitted
|
|
rules change, because it no longer describes the next proposed edit.
|
|
Failed planning keeps this gate, its submitted rules, and the prior plan.
|
|
Automatic entry continues the caller's open reporter so the single command
|
|
keeps one operation identity and monotonically increasing progress revisions.
|
|
"""
|
|
kind = action_kind(turn)
|
|
if kind != "submit_edit_rules":
|
|
return StepResult(next=PcState.EDIT_IMPACT_ANALYSIS, context=fc)
|
|
|
|
if turn.action is not None and any(field.get("key") == "change" for field in fc.form_fields):
|
|
requested_goal = turn.action.payload.get("change")
|
|
if isinstance(requested_goal, str) and requested_goal.strip() and requested_goal != fc.edit_rules.get("change"):
|
|
# Reuse impact analysis only when the user changes the goal. Old
|
|
# resource choices cannot resolve a removed/replaced request; show
|
|
# the refreshed form for confirmation before planning or approval.
|
|
return handle_capability_check(
|
|
env,
|
|
replace(turn, action=replace(turn.action, kind="send_edit_goal", payload={"text": requested_goal})),
|
|
s,
|
|
fc,
|
|
submitted_rules=turn.action.payload,
|
|
)
|
|
|
|
steps = [
|
|
("edit-review-rules", copy_text(BuilderCopyId.PROGRESS_REVIEW_CONFIRMED_EDIT_RULES)),
|
|
("edit-draft-plan", copy_text(BuilderCopyId.PROGRESS_DRAFT_THE_CHANGE_PLAN)),
|
|
("edit-create-checkpoint", copy_text(BuilderCopyId.PROGRESS_CREATE_A_PRE_EDIT_CHECKPOINT)),
|
|
]
|
|
if progress is None:
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
reply_language=fc.reply_language,
|
|
stage_id=str(s.current_state),
|
|
steps=steps,
|
|
)
|
|
else:
|
|
progress.add_steps(steps)
|
|
progress.activate("edit-review-rules")
|
|
|
|
if turn.action is not None and isinstance(turn.action.payload, dict):
|
|
keys = [f["key"] for f in fc.form_fields if isinstance(f, dict) and f.get("key")]
|
|
previous_rules = fc.edit_rules
|
|
fc.edit_rules = merge_known_keys(fc.edit_rules, turn.action.payload, keys)
|
|
if fc.edit_rules != previous_rules:
|
|
clear_task_acceptance(fc)
|
|
# ``last_edit_rejection`` describes the batch the engine refused,
|
|
# and that batch is a function of the rules and the graph. Here --
|
|
# and, besides a brand-new goal in handle_capability_check, ONLY
|
|
# here -- the rules stop being the ones it refused, so its
|
|
# complaint stops describing what the next approval will try.
|
|
#
|
|
# Deliberately NOT cleared by the actions that merely route the
|
|
# user here (plan_approval/review's continue_adjusting) nor by
|
|
# edit.reverted's Retry: those leave the rules untouched, so a
|
|
# refusal earned by them is still exactly what the next attempt
|
|
# needs to hear. A gate revert in particular writes nothing, so
|
|
# the restored draft IS the refused draft and Retry would otherwise
|
|
# re-propose the identical plan blind.
|
|
fc.last_edit_rejection = ""
|
|
|
|
progress.activate("edit-draft-plan")
|
|
graph, graph_hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
from services.dify_builder.resource_grounding import (
|
|
ResourceBindingError,
|
|
resolve_plan_resources,
|
|
selection_failure_step,
|
|
)
|
|
|
|
try:
|
|
resolve_plan_resources(env.agent, fc, graph)
|
|
except ResourceBindingError as exc:
|
|
return selection_failure_step(env, s, fc, turn.actor, graph, progress, exc, failed_step="edit-draft-plan")
|
|
try:
|
|
plan = env.agent.propose_edit_plan(dict(fc.edit_rules), graph, reply_language=fc.reply_language or None)
|
|
except EditPlanNotActionableError as exc:
|
|
return edit_planning_failed(env, s, fc, progress, "edit-draft-plan", guidance=str(exc))
|
|
if plan is None:
|
|
return edit_planning_failed(env, s, fc, progress, "edit-draft-plan")
|
|
store_plan(fc, plan)
|
|
|
|
progress.activate("edit-create-checkpoint")
|
|
fc.checkpoint_seq = fc.next_seq
|
|
mint_checkpoint(env, s, fc, graph, graph_hash, PcState.EDIT_PLAN_APPROVAL)
|
|
|
|
decision_items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_SUBMITTED_EDIT_RULES)))
|
|
plan_items = append_card(fc, make_plan_card(fc))
|
|
execution = progress.finish()
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
copy_text(BuilderCopyId.EDIT_PLAN_READY),
|
|
execution=execution,
|
|
cards=["plan"],
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_PLAN_APPROVAL,
|
|
context=fc,
|
|
items=[*decision_items, *plan_items, *turn_items],
|
|
)
|
|
|
|
|
|
_EDIT_EXECUTION_STEPS = [
|
|
("edit-prepare", copy_text(BuilderCopyId.PROGRESS_PREPARE_CANVAS_CHANGES)),
|
|
("edit-highlight", copy_text(BuilderCopyId.PROGRESS_HIGHLIGHT_EDIT_TARGETS)),
|
|
("edit-apply", copy_text(BuilderCopyId.PROGRESS_APPLY_THE_CHANGE_PLAN)),
|
|
]
|
|
|
|
# A pydantic message quotes a truncated repr of the input it refused, so one
|
|
# node's refusal is normally a few hundred characters -- but a batch refusal
|
|
# concatenates one per node, and this text is persisted in the session's
|
|
# context blob AND prepended to the next prompt. Capped with a visible marker
|
|
# for the same reason the terminal-output preview is
|
|
# (handlers_build._MAX_TERMINAL_OUTPUT_CHARS).
|
|
_MAX_REJECTION_CHARS = 2000
|
|
_REJECTION_TRUNCATED_MARKER = "\n… (truncated)"
|
|
|
|
|
|
def _rejection_text(exc: Exception) -> str:
|
|
"""The engine's OWN words for why it refused the write, capped.
|
|
|
|
``str(exc)`` and nothing else: the ``PreflightError`` the Dify port raises
|
|
is built from ``preflight.new_preflight_problems``, whose every message is
|
|
produced by re-validating a ``credentials.redact_node_config`` copy of the
|
|
node, and the ``ValueError`` alternative comes from ``graph_ops``, whose
|
|
messages carry only paths, node ids, indexes and type names. So it is
|
|
engine-sourced: no model prose is ever mixed in, and nothing branches on
|
|
this string.
|
|
|
|
Credentials are withheld to REDACTION'S DECLARED SCOPE and no further --
|
|
an http-request node's ``authorization`` block and its ``headers`` /
|
|
``params`` lines. A secret typed into a request ``body`` value is NOT
|
|
covered and reaches this string, and therefore the session's context blob
|
|
and the next prompt, verbatim. See ``services.dify_builder.credentials``
|
|
for why that is deliberate and what closing it would take.
|
|
"""
|
|
text = str(exc)
|
|
if len(text) > _MAX_REJECTION_CHARS:
|
|
return text[:_MAX_REJECTION_CHARS] + _REJECTION_TRUNCATED_MARKER
|
|
return text
|
|
|
|
|
|
def _change_not_applied(
|
|
env: Env,
|
|
s: Session,
|
|
fc: DifyBuilderContext,
|
|
progress: ProgressReporter,
|
|
*,
|
|
title: BuilderText,
|
|
body: BuilderText,
|
|
reply_text: BuilderText,
|
|
rejection: str,
|
|
failed_step: str = "edit-apply",
|
|
) -> StepResult:
|
|
"""apply_repair refused the edit and wrote nothing: say why and keep the
|
|
change plan at its gate. (Edit applies with on_canvas=None, so no canvas
|
|
marker streamed and there is nothing to revert on the client.)
|
|
|
|
``rejection`` is remembered so the NEXT approval is not blind: the gate
|
|
offers re-approval, the draft is unchanged, and without this the agent is
|
|
re-prompted with byte-identical inputs and hands back the same refused
|
|
batch (triage edit-branch-failure-2026-09-22). Stored unconditionally, so a
|
|
second refusal at the same error location with a different bad value
|
|
replaces the first rather than being folded into it.
|
|
|
|
Re-approval is one of three exits the gate now offers (see
|
|
``handle_plan_approval``), and none of the three clears this store on its
|
|
own: only ``handle_impact_analysis`` does, and only when the rules it was
|
|
submitted differ from the ones this refusal was earned on. Continue-adjusting
|
|
routes THROUGH there, so it clears only if the user actually changed
|
|
something; a gate revert writes nothing at all, so the restored draft IS the
|
|
refused draft and its Retry needs this text more than anything else does.
|
|
``test_a_gate_revert_keeps_the_refusal_its_retry_still_needs`` and
|
|
``test_routing_back_to_the_form_does_not_forget_the_refusal`` pin both.
|
|
|
|
``failed_step`` is the activity that gets the failure, and it defaults to
|
|
the write because that is where the usual refusal happens. A caller that
|
|
gives up EARLIER must say so: marking a step that was never revealed leaves
|
|
the ones between it and the failure streaming as pending underneath it, so
|
|
the timeline shows work still to come below work that already failed."""
|
|
fc.staged_repair = []
|
|
fc.last_edit_rejection = rejection
|
|
progress.fail_step(failed_step)
|
|
execution = progress.finish(status="error")
|
|
turn_items = append_assistant(
|
|
env, s, fc, text_parts(title, ": ", body, "\n", reply_text), execution=execution, turn_id=progress.operation_id
|
|
)
|
|
return StepResult(next=PcState.EDIT_PLAN_APPROVAL, context=fc, items=turn_items)
|
|
|
|
|
|
def _resume_adjusting(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""Leave an Edit gate for edit.impact_analysis with the edit-rules form
|
|
repopulated and unfrozen, so the user changes the RULES instead of
|
|
re-submitting the same ones.
|
|
|
|
Shared by "Continue adjusting" at edit.review and edit.plan_approval.
|
|
|
|
Routing the user to the form is not the same as changing the rules -- they
|
|
may edit nothing and resubmit -- so ``last_edit_rejection`` is left alone
|
|
here and cleared by handle_impact_analysis if and when the submitted
|
|
payload actually changes ``fc.edit_rules``."""
|
|
for node_id in fc.edit_target_node_ids:
|
|
emit_canvas(env, "highlight_edit_target", node_id=node_id)
|
|
fc.test_input_ref = ""
|
|
fc.verify_run_id = ""
|
|
fc.unknown_outcome_count = 0
|
|
decision_items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.ACTIONS_CONTINUE_ADJUSTING)))
|
|
form_items = append_card(
|
|
fc,
|
|
FormCard(variant="edit_rules", fields=edit_rule_form_fields(fc), values=dict(fc.edit_rules), frozen=False),
|
|
)
|
|
target_text = ", ".join(fc.edit_target_node_ids)
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
copy_text(BuilderCopyId.EDIT_ADJUST, targets=target_text)
|
|
if target_text
|
|
else copy_text(BuilderCopyId.EDIT_ADJUST_WITHOUT_TARGETS),
|
|
cards=["form"],
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_IMPACT_ANALYSIS,
|
|
context=fc,
|
|
items=[*decision_items, *form_items, *turn_items],
|
|
)
|
|
|
|
|
|
def handle_plan_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) THE EDIT. ``approve_repair`` (resolved from approve_plan)
|
|
applies: read the current graph, generate intents from the approved plan
|
|
and edit rules,
|
|
highlight the targets, apply once (on_canvas=None -- Edit narrates its own
|
|
coarse apply_edit_plan rather than the Fix-flavored per-intent apply_error_
|
|
fix), summarize the applied changes in assistant text, and transition to
|
|
edit.apply_changes. The checkpoint stays backend-only.
|
|
|
|
An empty proposal stays at the approval gate without attempting a write.
|
|
A nonempty proposal with the same nonempty execution revision is a no-op:
|
|
it rests at edit.apply_changes without claiming an applied change, even
|
|
when the adapter reports touched node IDs.
|
|
|
|
Approval is not the only exit. ``re_fix`` (continue_adjusting) goes back to
|
|
edit.impact_analysis with the rules editable, and ``undo`` (revert) restores
|
|
the pre-edit draft from the checkpoint handle_impact_analysis minted and
|
|
lands on edit.reverted. Without those two, an approval the engine refuses
|
|
parks the user at this gate with nothing to press but the same button
|
|
(triage edit-branch-failure-2026-09-22, "Hard dead end")."""
|
|
kind = action_kind(turn)
|
|
if kind == "re_fix": # continue_adjusting -> re-open the edit rules
|
|
return _resume_adjusting(env, turn, s, fc)
|
|
if kind == "undo": # revert -> abandon the change plan at its gate
|
|
perform_revert(env, turn, s, fc)
|
|
fc.staged_repair = []
|
|
items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_REQUESTED_A_REVERT)))
|
|
return StepResult(next=PcState.EDIT_REVERTED, context=fc, items=items)
|
|
if kind != "approve_repair":
|
|
return StepResult(next=PcState.EDIT_PLAN_APPROVAL, context=fc)
|
|
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
reply_language=fc.reply_language,
|
|
stage_id=str(s.current_state),
|
|
steps=_EDIT_EXECUTION_STEPS,
|
|
)
|
|
progress.activate("edit-prepare")
|
|
graph, read_revision = env.dify.read_graph(s.app_id, turn.actor)
|
|
try:
|
|
intents = env.agent.build_edit_intents(
|
|
dict(fc.edit_rules),
|
|
graph,
|
|
edit_target_node_ids=list(fc.edit_target_node_ids),
|
|
# What the engine said last time it refused this write, if anything.
|
|
# The graph and the rules are byte-identical across re-approvals, so
|
|
# this is the only input that changes -- and the only reason a second
|
|
# approval can produce a different batch.
|
|
last_edit_rejection=fc.last_edit_rejection or None,
|
|
approved_plan=WorkflowPlan(
|
|
title=fc.plan_title,
|
|
input_summary=fc.plan_input_summary,
|
|
output_summary=fc.plan_output_summary,
|
|
items=list(fc.plan_items),
|
|
),
|
|
)
|
|
except ProposalWouldRunWrongError as exc:
|
|
# The agent judged its own final proposal wrong and refused to hand it
|
|
# on. Nothing was written and nothing was even attempted -- this is the
|
|
# one refusal with no engine error behind it, because the defect is one
|
|
# the engine ACCEPTS: a draft that would start, run green and produce
|
|
# nothing. Same recovery as a refused write: the plan stays at its gate
|
|
# with the reason on a card, and the reason goes into the next attempt.
|
|
logger.warning("Dify Builder: edit proposal judged wrong before write for app %s: %s", s.app_id, exc)
|
|
return _change_not_applied(
|
|
env,
|
|
s,
|
|
fc,
|
|
progress,
|
|
title=copy_text(BuilderCopyId.LABELS_THE_CHANGE_WOULDN_T_DO_WHAT_YOU_ASKED),
|
|
body=copy_text(BuilderCopyId.EDIT_INEFFECTIVE_PROPOSAL, value=str(exc)),
|
|
reply_text=(copy_text(BuilderCopyId.REPLIES_I_DIDN_T_APPLY_THE_CHANGE)),
|
|
rejection=_rejection_text(exc),
|
|
# Given up during "prepare": nothing was highlighted and nothing was
|
|
# applied, so the failure belongs to the step that was actually
|
|
# running. Failing the write instead would leave edit-highlight
|
|
# pending BELOW a failed edit-apply.
|
|
failed_step="edit-prepare",
|
|
)
|
|
if not intents:
|
|
return _change_not_applied(
|
|
env,
|
|
s,
|
|
fc,
|
|
progress,
|
|
title=copy_text(BuilderCopyId.LABELS_NO_CHANGES_WERE_PROPOSED),
|
|
body=copy_text(BuilderCopyId.LABELS_NOTHING_WAS_APPLIED),
|
|
reply_text=copy_text(BuilderCopyId.REPLIES_REVIEW_THE_RULES_CONTINUE_ADJUSTING_APPROVE),
|
|
rejection="The previous proposal contained no applicable intentions.",
|
|
failed_step="edit-prepare",
|
|
)
|
|
fc.staged_repair = list(intents)
|
|
|
|
progress.activate("edit-highlight")
|
|
for node_id in fc.edit_target_node_ids:
|
|
emit_canvas(env, "highlight_edit_target", node_id=node_id)
|
|
|
|
progress.activate("edit-apply")
|
|
from services.dify_builder.resource_grounding import ResourceBindingError, selection_failure_step
|
|
|
|
try:
|
|
if fc.resource_selection.get("checked"):
|
|
result = env.dify.apply_repair(
|
|
s.app_id,
|
|
turn.actor,
|
|
intents,
|
|
on_canvas=None,
|
|
expected_revision=fc.last_snapshot_hash,
|
|
confirmed_resource_ids=list(fc.resource_selection.get("resource_ids", [])),
|
|
expected_resource_fingerprints=dict(fc.resource_selection.get("fingerprints", {})),
|
|
expected_node_resources=dict(fc.resource_selection.get("node_resources", {})),
|
|
)
|
|
else:
|
|
result = env.dify.apply_repair(
|
|
s.app_id,
|
|
turn.actor,
|
|
intents,
|
|
on_canvas=None,
|
|
expected_revision=fc.last_snapshot_hash,
|
|
)
|
|
except ResourceBindingError as exc:
|
|
return selection_failure_step(env, s, fc, turn.actor, graph, progress, exc, failed_step="edit-apply")
|
|
except DraftWouldNotStartError as exc:
|
|
# apply_repair's preflight: the edited draft would fail at Graph.init.
|
|
# Nothing was written; keep the change plan at its gate as a card.
|
|
logger.warning("Dify Builder: edit rejected before write for app %s: %s", s.app_id, exc)
|
|
return _change_not_applied(
|
|
env,
|
|
s,
|
|
fc,
|
|
progress,
|
|
title=copy_text(BuilderCopyId.LABELS_THE_WORKFLOW_CAN_T_START),
|
|
body=copy_text(BuilderCopyId.BUILD_INVALID_GRAPH, value=str(exc)),
|
|
reply_text=(copy_text(BuilderCopyId.REPLIES_I_DIDN_T_APPLY_THE_CHANGE2)),
|
|
rejection=_rejection_text(exc),
|
|
)
|
|
except ValueError as exc:
|
|
# graph_ops refused an intent (e.g. "node not found") before the
|
|
# startability check ever ran. Nothing was written either -- same
|
|
# recovery, but not "can't start".
|
|
logger.warning("Dify Builder: edit could not be applied for app %s: %s", s.app_id, exc)
|
|
return _change_not_applied(
|
|
env,
|
|
s,
|
|
fc,
|
|
progress,
|
|
title=copy_text(BuilderCopyId.LABELS_COULDN_T_APPLY_THE_WORKFLOW),
|
|
body=copy_text(BuilderCopyId.BUILD_APPLY_FAILED, value=str(exc)),
|
|
reply_text=(copy_text(BuilderCopyId.REPLIES_I_COULDN_T_APPLY_THE_CHANGE)),
|
|
rejection=_rejection_text(exc),
|
|
)
|
|
fc.last_snapshot_hash = result.new_hash
|
|
fc.last_structure_fingerprint = result.structure_fingerprint
|
|
# The write went through: whatever the engine refused before is answered,
|
|
# and must not be quoted at the next edit.
|
|
fc.last_edit_rejection = ""
|
|
# Touched IDs and summary counts do not prove an effective edit. The port
|
|
# returns the same opaque execution revision for an idempotent batch.
|
|
if read_revision and result.new_hash == read_revision:
|
|
fc.change_set = ChangeSet()
|
|
decision_items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_APPROVED_THE_CHANGE_PLAN)))
|
|
execution = progress.finish()
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
copy_text(BuilderCopyId.REPLIES_NO_EFFECTIVE_CHANGES_WERE_NEEDED_THE),
|
|
execution=execution,
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_APPLY_CHANGES,
|
|
context=fc,
|
|
items=[*decision_items, *turn_items],
|
|
)
|
|
emit_canvas(env, "apply_edit_plan")
|
|
|
|
changes, scope, fc.change_set = build_change_set(result, default_scope="configuration", fallback_diff="no changes")
|
|
|
|
decision_items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_APPROVED_THE_CHANGE_PLAN)))
|
|
execution = progress.finish()
|
|
change_lines = "\n".join(f"- {change}" for change in changes) or text_parts(
|
|
"- ", copy_text(BuilderCopyId.EDIT_NO_EFFECTIVE_CHANGES)
|
|
)
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
text_parts(
|
|
copy_text(
|
|
BuilderCopyId.EDIT_APPLIED, count=len(changes), scope=change_scope_label(scope, fc.reply_language)
|
|
),
|
|
"\n",
|
|
change_lines,
|
|
),
|
|
execution=execution,
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_APPLY_CHANGES,
|
|
context=fc,
|
|
items=[*decision_items, *turn_items],
|
|
)
|
|
|
|
|
|
def handle_apply_changes(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) At rest after the edit. ``run_affected_tests`` -> edit.await_
|
|
testdata when no test input is prepared yet (gate), else straight to edit.
|
|
test_affected_paths; ``revert`` (resolved to ``undo``) -> edit.reverted:
|
|
restores the pre-edit draft from the checkpoint and invalidates the
|
|
approvals made since it (via perform_revert)."""
|
|
kind = action_kind(turn)
|
|
if kind == "undo":
|
|
perform_revert(env, turn, s, fc)
|
|
items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_REQUESTED_A_REVERT)))
|
|
return StepResult(next=PcState.EDIT_REVERTED, context=fc, items=items)
|
|
if kind == "run_affected_tests":
|
|
fc.verify_run_id = ""
|
|
fc.effect_confirmation = {}
|
|
fc.effect_confirmation_form = {}
|
|
fc.code_risk_assessments = {}
|
|
if fc.test_input_ref == "" or needs_effect_confirmation(env, s, fc, turn.actor):
|
|
graph, _hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
card, reply = testdata_form(
|
|
env,
|
|
s,
|
|
fc,
|
|
turn.actor,
|
|
start_schema(graph, env.dify.get_app_mode(s.app_id, turn.actor)),
|
|
saved_test_inputs(env, fc),
|
|
copy_text(BuilderCopyId.REPLIES_PROVIDE_TEST_INPUTS_OR_USE_MOCK_DATA_TO_RUN_THE_AFFECTED_PATH_TEST),
|
|
)
|
|
form_items = append_card(fc, card)
|
|
turn_items = append_assistant(env, s, fc, reply, cards=["form"])
|
|
return StepResult(next=PcState.EDIT_AWAIT_TESTDATA, context=fc, items=[*form_items, *turn_items])
|
|
ti = replace(env.repo.get_test_input(fc.test_input_ref), id="")
|
|
env.repo.save_test_input(ti)
|
|
fc.test_input_ref = ti.id
|
|
items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.ACTIONS_RUN_AFFECTED_PATH_TESTS)))
|
|
return StepResult(next=PcState.EDIT_TEST_AFFECTED_PATHS, context=fc, items=items)
|
|
return StepResult(next=PcState.EDIT_APPLY_CHANGES, context=fc)
|
|
|
|
|
|
def handle_await_testdata(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) Prepare inputs for the affected-path test. mock -> schema-shaped
|
|
generate_mock_inputs; provide/upload -> the payload's inputs dict (may carry
|
|
file refs). Persists a TestInput and advances to edit.test_affected_paths."""
|
|
http_fixtures = stamp_testdata_http_fixtures(env.dify, app_id=s.app_id, turn=turn)
|
|
graph, _hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
schema = start_schema(graph, env.dify.get_app_mode(s.app_id, turn.actor))
|
|
mode, _ = action_string(turn, "mode")
|
|
if mode == "mock":
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
reply_language=fc.reply_language,
|
|
stage_id=str(s.current_state),
|
|
steps=[("edit-generate-test-inputs", copy_text(BuilderCopyId.PROGRESS_GENERATE_AFFECTED_PATH_TEST_INPUTS))],
|
|
)
|
|
progress.activate("edit-generate-test-inputs")
|
|
# An endpoint is left out, never mocked: its missing required key fails
|
|
# the launch as an input, which routes back to this gate.
|
|
inputs = without_endpoint_values(
|
|
graph, env.agent.generate_mock_inputs(schema, {}, business_context=mock_input_business_context(fc))
|
|
)
|
|
progress.finish()
|
|
else:
|
|
inputs = {}
|
|
if turn.action is not None and isinstance(turn.action.payload.get("inputs"), dict):
|
|
inputs = turn.action.payload["inputs"]
|
|
inputs, stale_effects = record_effect_confirmation(env, s, fc, turn.actor, inputs)
|
|
if stale_effects:
|
|
return input_gate_result(
|
|
env,
|
|
s,
|
|
fc,
|
|
schema,
|
|
inputs,
|
|
PcState.EDIT_AWAIT_TESTDATA,
|
|
copy_text(BuilderCopyId.TEST_EFFECTS_CHANGED),
|
|
actor=turn.actor,
|
|
)
|
|
try:
|
|
validate_runtime_inputs(schema, inputs)
|
|
except ValueError as exc:
|
|
return input_gate_result(env, s, fc, schema, inputs, PcState.EDIT_AWAIT_TESTDATA, str(exc), actor=turn.actor)
|
|
confirm_testdata_contract(s, fc, turn.action.payload if turn.action else {}, inputs)
|
|
ti = TestInput(
|
|
session_id=s.id,
|
|
source=mode or "upload",
|
|
inputs=inputs,
|
|
start_schema_hash=schema_hash(schema),
|
|
http_fixtures=http_fixtures,
|
|
)
|
|
env.repo.save_test_input(ti)
|
|
fc.test_input_ref = ti.id
|
|
return StepResult(next=PcState.EDIT_TEST_AFFECTED_PATHS, context=fc)
|
|
|
|
|
|
def _disconnected_draft_guidance(graph: Graph, affected_ids: list[str]) -> str:
|
|
"""Current, affected canvas facts for a failed draft; never a test verdict."""
|
|
incoming = {edge.get("target") for edge in graph.get("edges", [])}
|
|
outgoing = {edge.get("source") for edge in graph.get("edges", [])}
|
|
lines = []
|
|
for node in graph.get("nodes", []):
|
|
node_id = node.get("id")
|
|
data = node.get("data", {})
|
|
if node_id not in affected_ids or node_id in incoming and node_id in outgoing:
|
|
continue
|
|
# Native entry/terminal nodes legitimately have only one direction.
|
|
if data.get("type") in {"start", "end", "answer"}:
|
|
continue
|
|
missing = []
|
|
if node_id not in incoming:
|
|
missing.append("incoming connection")
|
|
if node_id not in outgoing:
|
|
missing.append("outgoing connection")
|
|
if data.get("type") == "tool" and (not data.get("provider_id") or not data.get("tool_name")):
|
|
missing.append("a real Tool action binding and its required parameters")
|
|
lines.append(f"- {node_id} ({data.get('title') or node_id}): missing {', '.join(missing)}.")
|
|
if not lines:
|
|
return ""
|
|
return (
|
|
"\n\nCurrent draft checklist:\n"
|
|
+ "\n".join(lines)
|
|
+ "\nConnect and configure these nodes manually, or remove them. "
|
|
"Keep draft intentionally leaves them untested; revert restores the checkpoint."
|
|
)
|
|
|
|
|
|
def handle_test_affected_paths(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(working, auto) Live affected-path test via run_draft. Success ->
|
|
edit.review; failure -> real diagnose + propose_repair, staged for the
|
|
edit.await_repair approval gate. No auto-apply (human-gated)."""
|
|
if is_confirmed_tool_turn(turn, fc):
|
|
return confirmed_tool_test(env, turn, s, fc, review_state=PcState.EDIT_REVIEW, step_prefix="edit")
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
reply_language=fc.reply_language,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("edit-prepare-test", copy_text(BuilderCopyId.PROGRESS_PREPARE_AFFECTED_PATH_TESTS)),
|
|
("edit-run-test", copy_text(BuilderCopyId.PROGRESS_RUN_AFFECTED_WORKFLOW_PATHS)),
|
|
("edit-evaluate-test", copy_text(BuilderCopyId.PROGRESS_EVALUATE_THE_TEST_RESULT)),
|
|
],
|
|
)
|
|
progress.activate("edit-prepare-test")
|
|
graph, _hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
schema = start_schema(graph, env.dify.get_app_mode(s.app_id, turn.actor))
|
|
if fc.test_input_ref:
|
|
ti = env.repo.get_test_input(fc.test_input_ref)
|
|
inputs = ti.inputs
|
|
else: # defensive: the gate normally prepares inputs first
|
|
inputs = without_endpoint_values(
|
|
graph, env.agent.generate_mock_inputs(schema, {}, business_context=mock_input_business_context(fc))
|
|
)
|
|
ti = TestInput(session_id=s.id, source="mock", inputs=inputs, start_schema_hash=schema_hash(schema))
|
|
env.repo.save_test_input(ti)
|
|
fc.test_input_ref = ti.id
|
|
|
|
progress.activate("edit-run-test")
|
|
|
|
def emit(event: NodeEvent) -> None:
|
|
progress.observe_node("edit-run-test", event)
|
|
|
|
raw = None
|
|
try:
|
|
validate_runtime_inputs(schema, inputs, ti)
|
|
confirmation = fc.effect_confirmation
|
|
fc.effect_confirmation = {}
|
|
raw = env.dify.run_draft(
|
|
s.app_id,
|
|
turn.actor,
|
|
inputs,
|
|
emit,
|
|
session_id=s.id,
|
|
test_input_id=fc.test_input_ref,
|
|
on_workflow_event=env.emit_workflow,
|
|
effect_confirmation=confirmation,
|
|
)
|
|
status, per_node, dify_run_id, run_error = raw.status, raw.per_node, raw.dify_run_id, raw.error
|
|
except Exception as exc:
|
|
# Never crash the advance; capture the launch error (log + store) instead
|
|
# of swallowing it, so diagnose/routing have something to act on.
|
|
logger.exception("dify_builder verify run failed to launch (session=%s, app=%s)", s.id, s.app_id)
|
|
status, per_node, dify_run_id, run_error = "failed", [], "", launch_error_text(exc)
|
|
|
|
if (
|
|
raw is not None
|
|
and raw.execution_refusal is not None
|
|
and raw.execution_refusal.reason_code == "code_risk_assessment_stale"
|
|
):
|
|
return input_gate_result(
|
|
env,
|
|
s,
|
|
fc,
|
|
schema,
|
|
dict(inputs),
|
|
PcState.EDIT_AWAIT_TESTDATA,
|
|
copy_text(BuilderCopyId.TEST_POLICY_REVIEW),
|
|
actor=turn.actor,
|
|
)
|
|
|
|
if status == "succeeded":
|
|
progress.complete("edit-run-test")
|
|
else:
|
|
progress.fail_step("edit-run-test")
|
|
progress.activate("edit-evaluate-test")
|
|
|
|
run = Run(
|
|
execution_refusal=raw.execution_refusal if raw else None,
|
|
id=str(uuid.uuid4()),
|
|
session_id=s.id,
|
|
verification=raw.verification if raw else None,
|
|
kind="verify",
|
|
dify_run_id=dify_run_id,
|
|
status=status,
|
|
per_node=per_node,
|
|
error=run_error,
|
|
inputs_ref=fc.test_input_ref,
|
|
immutable=True,
|
|
)
|
|
|
|
record_run_acceptance(s, fc, run, ti, graph, _hash)
|
|
business_failure = business_failure_context(
|
|
run, s, fc, current_revision=_hash, current_graph_revision=env.dify.graph_revision(graph)
|
|
)
|
|
|
|
fc.verify_run_id = run.id
|
|
|
|
from services.dify_builder.execution_messages import native_service_failure
|
|
|
|
if is_execution_policy_blocker(run) or native_service_failure(run):
|
|
fc.diagnosis = None
|
|
fc.staged_repair = []
|
|
card = result_card(run, reply_language=fc.reply_language)
|
|
items = append_card(fc, card)
|
|
execution = progress.finish()
|
|
items += append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
card.failure_reason or copy_text(BuilderCopyId.TEST_POLICY_REVIEW),
|
|
execution=execution,
|
|
cards=["test_result"],
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(next=PcState.EDIT_AWAIT_REPAIR, context=fc, items=items, run=run, run_id_sink=[run.id])
|
|
|
|
if status != "running":
|
|
fc.unknown_outcome_count = 0
|
|
|
|
if status == "succeeded" and (
|
|
has_output_blocker(run) or (run.verification is None and run_finished_without_output(graph, per_node))
|
|
):
|
|
# Same as Build's: a branch node ran and every one of its arms was
|
|
# skipped, and no End node ran either. Not green, not an engine error
|
|
# to diagnose; wait at the gate with no staged repair.
|
|
fc.verify_run_id = run.id
|
|
fc.diagnosis = None
|
|
fc.staged_repair = []
|
|
run.culprit_node_id = dead_end_branch_node_id(graph, per_node)
|
|
test_items = append_card(
|
|
fc,
|
|
result_card(run, reply_language=fc.reply_language)
|
|
if run.verification
|
|
else TestResultCard(status="failed", failure_reason=NO_OUTPUT_BODY, dify_run_id=run.dify_run_id),
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
(result_card(run, reply_language=fc.reply_language).failure_reason or NO_OUTPUT_BODY),
|
|
execution=execution,
|
|
cards=["test_result"],
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_AWAIT_REPAIR,
|
|
context=fc,
|
|
items=[*test_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
if status == "succeeded" and business_failure is None:
|
|
test_items = append_card(
|
|
fc,
|
|
result_card(run, reply_language=fc.reply_language),
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
text_parts(SUCCESS_REPLY, " ", result_card(run, reply_language=fc.reply_language).review_note or ""),
|
|
execution=execution,
|
|
cards=["test_result"],
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_REVIEW,
|
|
context=fc,
|
|
items=[*test_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
if status == "running":
|
|
# A truncated stream cannot establish failure or justify another repair.
|
|
# The second consecutive unknown outcome stops the re-run loop at the
|
|
# gate (no staged repair) instead of bouncing to edit.apply_changes again.
|
|
if note_unknown_outcome(fc):
|
|
fc.verify_run_id = run.id
|
|
fc.diagnosis = None
|
|
fc.staged_repair = []
|
|
execution = progress.finish()
|
|
stuck_items = append_assistant(
|
|
env, s, fc, UNKNOWN_OUTCOME_STUCK_BODY, execution=execution, turn_id=progress.operation_id
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_AWAIT_REPAIR,
|
|
context=fc,
|
|
items=stuck_items,
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_assistant(
|
|
env, s, fc, UNKNOWN_TEST_OUTCOME_REPLY, execution=execution, turn_id=progress.operation_id
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_APPLY_CHANGES,
|
|
context=fc,
|
|
items=turn_items,
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
# failure: real diagnosis + proposed repair, staged for the approval gate
|
|
run.culprit_node_id = first_failed_node(per_node)
|
|
fc.verify_run_id = run.id
|
|
|
|
if is_input_failure(run):
|
|
# The attempted run failed on its inputs. Keep it as the latest
|
|
# verification evidence while clearing stale inputs for correction.
|
|
fc.test_input_ref = ""
|
|
test_items = append_card(
|
|
fc,
|
|
replace(
|
|
result_card(run, reply_language=fc.reply_language),
|
|
failure_reason=test_failure_reason(run) if business_failure is None else None,
|
|
),
|
|
)
|
|
card, reply = testdata_form(
|
|
env,
|
|
s,
|
|
fc,
|
|
turn.actor,
|
|
schema,
|
|
inputs,
|
|
copy_text(BuilderCopyId.REPLIES_THE_RUN_FAILED_ON_ITS_INPUTS_PROVIDE_TEST_DATA_AND_RETRY),
|
|
)
|
|
form_items = append_card(fc, card)
|
|
execution = progress.finish()
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
reply,
|
|
execution=execution,
|
|
cards=["test_result", "form"],
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_AWAIT_TESTDATA,
|
|
context=fc,
|
|
items=[*test_items, *form_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
model_error = model_config_error_text(run)
|
|
if model_error is not None:
|
|
# Model-config failure: the run references a model that isn't
|
|
# configured/available -- not a workflow-logic bug. Surface it with a clear
|
|
# diagnosis and NO staged repair (diagnosing/repairing the graph thrashes);
|
|
# the user configures the model (update_model) and re-runs.
|
|
root_cause = (
|
|
"The workflow uses a model that isn't configured or available for this workspace, so it can't run. "
|
|
"This is a model-configuration issue, not a workflow-logic problem -- configure the model "
|
|
"(or choose a different one) and re-run the test. "
|
|
f"Details: {model_error}"
|
|
)
|
|
fc.diagnosis = Diagnosis(culprit_node_id=first_failed_node(per_node), root_cause=root_cause, severity="high")
|
|
fc.staged_repair = []
|
|
fc.risk = Risk(level="high", reason="model not configured", has_external_side_effect=False)
|
|
test_items = append_card(
|
|
fc,
|
|
replace(result_card(run, reply_language=fc.reply_language), failure_reason=model_error),
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
copy_text(BuilderCopyId.MODEL_CONFIGURATION_UNAVAILABLE, error=model_error),
|
|
execution=execution,
|
|
cards=["test_result"],
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_AWAIT_REPAIR,
|
|
context=fc,
|
|
items=[*test_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
# config failure: existing diagnose + propose_repair -> EDIT_AWAIT_REPAIR
|
|
progress.add_steps(
|
|
[
|
|
("edit-diagnose-failure", copy_text(BuilderCopyId.PROGRESS_DIAGNOSE_THE_FAILED_PATH)),
|
|
("edit-prepare-repair", copy_text(BuilderCopyId.PROGRESS_PREPARE_A_SAFE_REPAIR)),
|
|
]
|
|
)
|
|
progress.activate("edit-diagnose-failure")
|
|
from services.dify_builder.condition_evidence import diagnose_failure, propose_failure
|
|
|
|
diagnosis = diagnose_failure(env, s.app_id, turn.actor, run, graph, task=(s, fc, _hash))
|
|
guidance = _disconnected_draft_guidance(
|
|
graph, [*fc.edit_target_node_ids, diagnosis.culprit_node_id, first_failed_node(per_node)]
|
|
)
|
|
note_repair_error(fc, run, business_signature=business_failure.signature if business_failure else "", graph=graph)
|
|
if repair_is_repeating(fc):
|
|
# Same breaker as Build's: the identical engine failure has survived
|
|
# MAX_REPEATED_REPAIRS repairs, so another round would aim at the same
|
|
# wrong thing. Stop and hand the decision back (no staged repair).
|
|
fc.diagnosis = diagnosis
|
|
fc.staged_repair = []
|
|
test_items = append_card(
|
|
fc,
|
|
replace(
|
|
result_card(run, reply_language=fc.reply_language),
|
|
failure_reason=test_failure_reason(run) if business_failure is None else None,
|
|
),
|
|
)
|
|
execution = progress.finish()
|
|
stuck_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
f"{diagnosis.root_cause or copy_text(BuilderCopyId.TEST_FAILED_FALLBACK)}\n\n"
|
|
"The same error survived the last repairs, so I've stopped retrying. "
|
|
"Edit the node directly and test again, keep the untested draft, or revert." + guidance,
|
|
execution=execution,
|
|
cards=["test_result"],
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_AWAIT_REPAIR,
|
|
context=fc,
|
|
items=[*test_items, *stuck_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
progress.activate("edit-prepare-repair")
|
|
intents, risk = propose_failure(env, s.app_id, turn.actor, run, diagnosis, graph, task=(s, fc, _hash))
|
|
fc.diagnosis = diagnosis
|
|
fc.staged_repair = list(intents)
|
|
fc.risk = risk
|
|
if intents:
|
|
fc.checkpoint_seq = fc.next_seq
|
|
test_items = append_card(
|
|
fc,
|
|
replace(
|
|
result_card(run, reply_language=fc.reply_language),
|
|
failure_reason=test_failure_reason(run) if business_failure is None else None,
|
|
),
|
|
)
|
|
proposed = [f"{i.op} {i.args.get('node_id', '')}".strip() for i in intents]
|
|
execution = progress.finish()
|
|
proposal_text = "\n".join(f"- {change}" for change in proposed)
|
|
reply_text = diagnosis.root_cause or copy_text(BuilderCopyId.TEST_FAILED_FALLBACK)
|
|
reply_text = text_parts(
|
|
reply_text,
|
|
(
|
|
text_parts("\n\n", copy_text(BuilderCopyId.REPAIR_PROPOSED_FIX), "\n", proposal_text)
|
|
if intents
|
|
else text_parts("\n\n", copy_text(BuilderCopyId.REPAIR_NO_SAFE_FOUND))
|
|
),
|
|
)
|
|
if not intents and risk.reason and risk.reason != NO_REPAIR_REASON:
|
|
reply_text = text_parts(reply_text, "\n", copy_text(BuilderCopyId.REPAIR_REASON, reason=risk.reason))
|
|
reply_text = text_parts(reply_text, guidance)
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
reply_text,
|
|
execution=execution,
|
|
cards=["test_result"],
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(
|
|
next=PcState.EDIT_AWAIT_REPAIR,
|
|
context=fc,
|
|
items=[*test_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
checkpoint=Snapshot(session_id=s.id, hash=_hash, graph=graph) if intents else None,
|
|
)
|
|
|
|
|
|
def handle_await_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) Post-failure gate for Edit, mirroring Build's await_repair.
|
|
approve_repair applies the staged repair and waits at edit.apply_changes
|
|
for the client's canvas refresh before testing; keep_draft -> edit.complete; undo ->
|
|
edit.reverted. apply_repair runs ONLY here, only on approve."""
|
|
kind = action_kind(turn)
|
|
if kind == "approve_repair":
|
|
if not fc.staged_repair:
|
|
# Nothing staged: applying would change nothing and the retest
|
|
# would fail identically. Don't spend a run on it (ESQ1-291).
|
|
# Reachable on the most common Edit failure there is -- the
|
|
# model-config branch above stages NO repair and still routes
|
|
# here.
|
|
items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
copy_text(BuilderCopyId.REPLIES_NO_FIX_IS_STAGED_FOR_THIS_FAILURE_KEEP_THE_DRAFT_OR_REVERT),
|
|
)
|
|
return StepResult(next=PcState.EDIT_AWAIT_REPAIR, context=fc, items=items)
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
reply_language=fc.reply_language,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("edit-apply-repair", copy_text(BuilderCopyId.PROGRESS_APPLY_THE_APPROVED_REPAIR)),
|
|
("edit-prepare-retest", copy_text(BuilderCopyId.PROGRESS_PREPARE_TO_RETEST_AFFECTED_PATHS)),
|
|
],
|
|
)
|
|
progress.activate("edit-apply-repair")
|
|
try:
|
|
result = env.dify.apply_repair(
|
|
s.app_id,
|
|
turn.actor,
|
|
list(fc.staged_repair),
|
|
on_canvas=env.emit_canvas,
|
|
expected_revision=fc.last_snapshot_hash,
|
|
failed_run_id=(
|
|
env.repo.get_run(fc.verify_run_id).dify_run_id
|
|
if fc.verify_run_id
|
|
and not (
|
|
fc.business_acceptance_contract and env.repo.get_run(fc.verify_run_id).status == "succeeded"
|
|
)
|
|
else None
|
|
),
|
|
business_failure_run_id=(
|
|
fc.verify_run_id
|
|
if fc.verify_run_id
|
|
and fc.business_acceptance_contract
|
|
and env.repo.get_run(fc.verify_run_id).status == "succeeded"
|
|
else None
|
|
),
|
|
)
|
|
except DraftWouldNotStartError as exc:
|
|
# The fix applied, but apply_repair's preflight found the result
|
|
# would fail at Graph.init, so nothing was written. Not a stale
|
|
# fix: say so, then the same surface as below.
|
|
logger.warning(
|
|
"Dify Builder: staged repair would leave a draft that cannot start for app %s: %s", s.app_id, exc
|
|
)
|
|
items = drop_unapplied_repair(
|
|
env,
|
|
s,
|
|
fc,
|
|
progress,
|
|
title=copy_text(BuilderCopyId.LABELS_THE_WORKFLOW_CAN_T_START),
|
|
body=copy_text(BuilderCopyId.REPAIR_INVALID_GRAPH, value=str(exc)),
|
|
)
|
|
return StepResult(next=PcState.EDIT_AWAIT_REPAIR, context=fc, items=items)
|
|
except ValueError as exc:
|
|
# Same stale-intent window as Build's gate: apply_repair
|
|
# re-validates against the draft as it is NOW, and a bad intent
|
|
# must not kill the session (ESQ1-271).
|
|
logger.warning("Dify Builder: staged repair no longer applies for app %s: %s", s.app_id, exc)
|
|
items = drop_unapplied_repair(
|
|
env,
|
|
s,
|
|
fc,
|
|
progress,
|
|
title=copy_text(BuilderCopyId.LABELS_COULDN_T_APPLY_THE_FIX),
|
|
body=copy_text(BuilderCopyId.REPAIR_STALE_PROPOSAL, value=str(exc)),
|
|
)
|
|
return StepResult(next=PcState.EDIT_AWAIT_REPAIR, context=fc, items=items)
|
|
fc.last_snapshot_hash = result.new_hash
|
|
fc.last_structure_fingerprint = result.structure_fingerprint
|
|
fc.staged_repair = []
|
|
changes, scope, fc.change_set = build_change_set(result, default_scope="configuration", fallback_diff="repair")
|
|
progress.activate("edit-prepare-retest")
|
|
decision_items = append_card(
|
|
fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_APPLIED_THE_FIX_READY_TO_RETEST))
|
|
)
|
|
execution = progress.finish()
|
|
change_lines = "\n".join(f"- {change}" for change in changes) or text_parts(
|
|
"- ", copy_text(BuilderCopyId.REPAIR_APPLIED_FALLBACK)
|
|
)
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
text_parts(
|
|
copy_text(
|
|
BuilderCopyId.REPAIR_APPLIED, count=len(changes), scope=change_scope_label(scope, fc.reply_language)
|
|
),
|
|
"\n",
|
|
change_lines,
|
|
),
|
|
execution=execution,
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(next=PcState.EDIT_APPLY_CHANGES, context=fc, items=[*decision_items, *turn_items])
|
|
if kind == "keep_draft":
|
|
# Reuse the final draft decision: preserving a failed draft must not
|
|
# require a second identically named confirmation at review.
|
|
return handle_review(env, turn, s, fc)
|
|
if kind == "undo":
|
|
perform_revert(env, turn, s, fc)
|
|
items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_REQUESTED_A_REVERT)))
|
|
return StepResult(next=PcState.EDIT_REVERTED, context=fc, items=items)
|
|
return StepResult(next=PcState.EDIT_AWAIT_REPAIR, context=fc)
|
|
|
|
|
|
def handle_review(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) Final user decision before publish or completion."""
|
|
kind = action_kind(turn)
|
|
if kind == "run_affected_tests":
|
|
fc.verify_run_id = ""
|
|
return handle_apply_changes(env, turn, s, fc)
|
|
if kind == "publish_workflow":
|
|
decision = publication_for_context(env, turn, s, fc)
|
|
if not decision.allowed:
|
|
return publication_denied(env, s, fc, PcState.EDIT_REVIEW, decision.reason)
|
|
return StepResult(
|
|
next=PcState.EDIT_PUBLISH,
|
|
context=fc,
|
|
items=append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_CHOSE_TO_PUBLISH))),
|
|
)
|
|
if kind == "keep_draft":
|
|
decision_items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_KEPT_THE_DRAFT)))
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
text_parts(
|
|
copy_text(
|
|
BuilderCopyId.EDIT_COMPLETED_DRAFT,
|
|
changes=fc.change_set.diff,
|
|
)
|
|
if fc.change_set and fc.change_set.diff
|
|
else text_parts(
|
|
copy_text(BuilderCopyId.LABELS_KEPT_THE_DRAFT),
|
|
"\n",
|
|
copy_text(BuilderCopyId.EDIT_NO_EFFECTIVE_CHANGES),
|
|
)
|
|
),
|
|
)
|
|
return StepResult(next=PcState.EDIT_COMPLETE, context=fc, items=[*decision_items, *turn_items])
|
|
if kind == "re_fix": # continue_adjusting -> re-analyze impact
|
|
return _resume_adjusting(env, turn, s, fc)
|
|
if kind == "undo": # revert
|
|
perform_revert(env, turn, s, fc)
|
|
items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_REQUESTED_A_REVERT)))
|
|
return StepResult(next=PcState.EDIT_REVERTED, context=fc, items=items)
|
|
return StepResult(next=PcState.EDIT_REVIEW, context=fc)
|
|
|
|
|
|
def handle_publish(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(working, auto) Publish the updated workflow and close the edit flow."""
|
|
decision = publication_for_context(env, turn, s, fc)
|
|
if not decision.allowed:
|
|
return publication_denied(env, s, fc, PcState.EDIT_REVIEW, decision.reason)
|
|
verified_run = env.repo.get_run(fc.verify_run_id)
|
|
evidence = verified_run.verification
|
|
assert evidence is not None
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
reply_language=fc.reply_language,
|
|
stage_id=str(s.current_state),
|
|
steps=[("edit-publish-workflow", copy_text(BuilderCopyId.PROGRESS_PUBLISH_THE_UPDATED_WORKFLOW))],
|
|
)
|
|
progress.activate("edit-publish-workflow")
|
|
try:
|
|
published = env.dify.publish(
|
|
s.app_id,
|
|
turn.actor,
|
|
expected_revision=evidence.execution_revision,
|
|
expected_graph_revision=evidence.executed_graph_revision,
|
|
)
|
|
except HashMismatchError:
|
|
progress.fail_step("edit-publish-workflow")
|
|
progress.finish()
|
|
return publication_denied(env, s, fc, PcState.EDIT_REVIEW, "stale_revision")
|
|
emit_canvas(env, "publish_workflow")
|
|
execution = progress.finish()
|
|
items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
copy_text(BuilderCopyId.PUBLISH_EDIT_COMPLETE, version=published.version_name, status=published.status),
|
|
execution=execution,
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(next=PcState.EDIT_COMPLETE, context=fc, items=items)
|
|
|
|
|
|
def handle_reverted(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) After a revert. ``retry_after_revert`` (resolved to re_fix)
|
|
re-proposes the change plan, self-mints a fresh pre-edit checkpoint, and
|
|
returns to edit.plan_approval (spec §7.2) only with a complete new plan.
|
|
Failed planning leaves the prior plan, checkpoint and verification intact.
|
|
|
|
``last_edit_rejection`` is deliberately carried through untouched. A revert
|
|
taken at the plan gate wrote nothing, so the restored draft is the refused
|
|
draft and the rules are unchanged -- and Retry is the only action offered
|
|
here, so forgetting the engine's text would re-propose the identical plan
|
|
blind and earn the identical refusal."""
|
|
kind = action_kind(turn)
|
|
if kind != "re_fix":
|
|
return StepResult(next=PcState.EDIT_REVERTED, context=fc)
|
|
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
reply_language=fc.reply_language,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("edit-rebuild-plan", copy_text(BuilderCopyId.PROGRESS_REBUILD_THE_CHANGE_PLAN)),
|
|
("edit-create-checkpoint", copy_text(BuilderCopyId.PROGRESS_CREATE_A_NEW_PRE_EDIT_CHECKPOINT)),
|
|
],
|
|
)
|
|
progress.activate("edit-rebuild-plan")
|
|
graph, graph_hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
try:
|
|
plan = env.agent.propose_edit_plan(dict(fc.edit_rules), graph, reply_language=fc.reply_language or None)
|
|
except EditPlanNotActionableError as exc:
|
|
return edit_planning_failed(env, s, fc, progress, "edit-rebuild-plan", guidance=str(exc))
|
|
if plan is None:
|
|
return edit_planning_failed(env, s, fc, progress, "edit-rebuild-plan")
|
|
store_plan(fc, plan)
|
|
fc.checkpoint_seq = fc.next_seq
|
|
fc.test_input_ref = ""
|
|
fc.verify_run_id = ""
|
|
fc.unknown_outcome_count = 0
|
|
progress.activate("edit-create-checkpoint")
|
|
mint_checkpoint(env, s, fc, graph, graph_hash, PcState.EDIT_PLAN_APPROVAL)
|
|
plan_items = append_card(fc, make_plan_card(fc))
|
|
execution = progress.finish()
|
|
turn_items = append_assistant(
|
|
env,
|
|
s,
|
|
fc,
|
|
copy_text(BuilderCopyId.LABELS_RE_APPROVE_TO_APPLY_THE_CHANGE),
|
|
execution=execution,
|
|
cards=["plan"],
|
|
turn_id=progress.operation_id,
|
|
)
|
|
return StepResult(next=PcState.EDIT_PLAN_APPROVAL, context=fc, items=[*plan_items, *turn_items])
|
|
|
|
|
|
def edit_registry() -> dict[PcState, Handler]:
|
|
"""The Edit handler table; ``edit.complete`` is the terminal state."""
|
|
return {
|
|
PcState.EDIT_CAPABILITY_CHECK: handle_capability_check,
|
|
PcState.EDIT_IMPACT_ANALYSIS: handle_impact_analysis,
|
|
PcState.EDIT_PLAN_APPROVAL: handle_plan_approval,
|
|
PcState.EDIT_APPLY_CHANGES: handle_apply_changes,
|
|
PcState.EDIT_AWAIT_TESTDATA: handle_await_testdata,
|
|
PcState.EDIT_TEST_AFFECTED_PATHS: handle_test_affected_paths,
|
|
PcState.EDIT_AWAIT_REPAIR: handle_await_repair,
|
|
PcState.EDIT_REVIEW: handle_review,
|
|
PcState.EDIT_PUBLISH: handle_publish,
|
|
PcState.EDIT_REVERTED: handle_reverted,
|
|
}
|