Files
dify/api/services/dify_builder/application/handlers_fix.py
T

1862 lines
76 KiB
Python

"""Fix-flow handlers: pure ``(env, turn, session, fc) -> StepResult`` steps.
Port of dify-enterprise/server/pkg/enterprise/biz/dify_builder/handlers_fix.go —
both the run-fix subset (``handlers_fix.go:9-270,316-347``, Task 6) and the
checklist subset + merged registry (``handlers_fix.go:18-36,72,272,303``,
Task 7). ``checklist.propose`` deliberately reuses ``handle_propose`` (as Go
does) rather than defining a duplicate handler.
Deltas from the Go source (per the P1 port plan's Global Constraints / ADR):
- ``turn.Auth`` (Go's ``ForwardAuth``) becomes ``turn.actor``; the app id
comes from ``session.app_id`` (``s.AppID`` in Go — same field, just
``snake_case``).
- Go's ``*string`` run-id sink has no Python equivalent (see
``runner.py``'s module docstring): a plain ``str`` field can't be mutated
in place through a pointer. ``handle_verify`` therefore mints the verify
run's id itself (so it can set ``fc.verify_run_id`` directly, matching
what Go achieves via ``runIDSink: &fc.VerifyRunID`` aliasing the same
struct field) and *also* reports it via ``StepResult.run_id_sink`` so the
generic runner's write-back (``run_id_sink[0] = run.id``) stays a
harmless no-op rather than silently doing nothing.
- Go returns ``(*StepResult, error)``; Python raises instead (handlers let
``NotFoundError`` / other port errors propagate uncaught, mirroring how
Go's ``if err != nil { return nil, err }`` guards were a straight
passthrough).
"""
import logging
import re
import uuid
from contextlib import suppress
from dataclasses import replace
from typing import Any
from core.dify_builder import credentials, urls
from core.dify_builder.contract import (
DecisionItem,
ExecutionProgress,
FormCard,
FormField,
TestResultCard,
)
from core.dify_builder.errors import ConflictError, DraftWouldNotStartError, HashMismatchError, NotFoundError
from core.dify_builder.input_schema import SYSTEM_QUERY, schema_hash, start_schema, validate_runtime_inputs
from core.dify_builder.models import (
CONFIRMED_TOOL_ACTION,
Actor,
ApplyResult,
ChangeSet,
ChecklistError,
Checkpoint,
ConversationItem,
DifyBuilderContext,
Graph,
Inputs,
NodeEvent,
NodeOutput,
PublicationDecision,
Risk,
Run,
Session,
Snapshot,
StartSchema,
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 BuilderPresentation, BuilderText, copy_text, stored_text, text_parts
from services.dify_builder.application.execution_policy import BuilderExecutionRefusal
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,
confirm_testdata_contract,
record_run_acceptance,
)
from services.dify_builder.effect_disclosure import split_confirmations
from services.dify_builder.effect_presentation import (
effect_confirmation_fields,
effect_confirmation_values,
effects_note,
with_effects_note,
)
from services.dify_builder.execution_messages import change_scope_label
logger = logging.getLogger(__name__)
__all__ = [
"MAX_REPEATED_REPAIRS",
"MAX_UNKNOWN_OUTCOMES",
"NO_OUTPUT_BODY",
"UNKNOWN_OUTCOME_STUCK_BODY",
"UNKNOWN_TEST_OUTCOME_REPLY",
"action_kind",
"action_string",
"append_assistant",
"append_card",
"append_item",
"build_change_set",
"build_form_fields",
"confirmed_tool_test",
"dead_end_branch_node_id",
"decode_checklist_errors",
"drop_unapplied_repair",
"emit_canvas",
"endpoint_variable_names",
"failure_signature",
"first_failed_node",
"fix_registry",
"handle_apply",
"handle_await_approval",
"handle_await_decision",
"handle_await_recheck",
"handle_await_testdata",
"handle_await_verify",
"handle_checklist_diagnose",
"handle_diagnose",
"handle_propose",
"handle_publish",
"handle_verify",
"is_confirmed_tool_turn",
"is_input_failure",
"merge_known_keys",
"mint_checkpoint",
"model_config_error_text",
"needs_effect_confirmation",
"needs_upload_inputs",
"note_repair_error",
"note_unknown_outcome",
"perform_revert",
"record_effect_confirmation",
"repair_is_repeating",
"run_finished_without_output",
"start_schema",
"test_failure_reason",
"testdata_form",
"testdata_form_fields",
"upload_variable_names",
"without_endpoint_values",
"without_upload_values",
]
# Shared by handle_verify (below) and handle_test_and_repair (handlers_build.py,
# which imports it): the "running"/unknown-outcome branch. A truncated stream
# means the run's actual pass/fail is genuinely unknown -- not a failure to
# diagnose or repair against.
UNKNOWN_TEST_OUTCOME_REPLY = copy_text(BuilderCopyId.REPLIES_THE_TEST_S_OUTCOME_COULDN_T)
# A run that reports ``succeeded`` after taking a branch node and reaching no
# End node produced nothing (ESQ1-303: both if-else edges used undeclared
# handles, so the engine skipped both arms and finished with outputs={}).
# Shown instead of "All checks passed"; nothing is diagnosed or repaired
# because the engine reported no error to diagnose.
NO_OUTPUT_BODY = copy_text(BuilderCopyId.REPLIES_THE_RUN_FINISHED_BUT_REACHED_NO)
# How many CONSECUTIVE unknown outcomes a flow tolerates before it stops
# offering a re-run. The first is genuinely re-runnable (a transient early end
# of the stream); the second in a row means something structural and the user
# must look at the run itself.
MAX_UNKNOWN_OUTCOMES = 2
UNKNOWN_OUTCOME_STUCK_BODY = copy_text(BuilderCopyId.REPLIES_THE_TEST_S_OUTCOME_COULDN_T2)
# Deterministic, model-free copy for a confirmed Tool test outcome.
CONFIRMED_COMPLETED_REPLY = copy_text(BuilderCopyId.CONFIRMED_COMPLETED)
_DUPLICATE_LAUNCH_REASONS = frozenset({"execution_already_launched", "execution_already_claimed"})
CONFIRMED_NOT_COMPLETED_REPLY = copy_text(BuilderCopyId.CONFIRMED_NOT_COMPLETED)
def is_confirmed_tool_turn(turn: Turn, fc: DifyBuilderContext) -> bool:
"""True only for the server-reserved confirmed command of this session; any other refuses.
Model text, plan approval and resource binding cannot create this action;
the default path never passes a consent reference to the port.
"""
action = turn.action
if action is None or action.kind != CONFIRMED_TOOL_ACTION:
return False
if not fc.confirmed_consent_id or not fc.confirmed_command_id or action.command_id != fc.confirmed_command_id:
raise ConflictError("dify_builder: confirmed command is not reserved for this session")
return True
def confirmed_tool_test(
env: Env, turn: Turn, s: Session, fc: DifyBuilderContext, *, review_state: PcState, step_prefix: str
) -> StepResult:
"""(working) Run the reserved confirmed Tool test once with the saved approved input.
No mock inputs, no repair, no retry and no post-run diagnosis or model
call: the outcome is deterministic status metadata and returns to the
mode's existing review/decision state with the new Run. The pending
consent/command reference is cleared in this terminal commit; the durable
consent and Run records keep the IDs.
"""
run_step = f"{step_prefix}-run-confirmed-test"
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=[
(
f"{step_prefix}-prepare-confirmed-test",
copy_text(BuilderCopyId.PROGRESS_PREPARE_THE_CONFIRMED_TOOL_TEST),
),
(run_step, copy_text(BuilderCopyId.PROGRESS_RUN_CONFIRMED_TOOL_TEST)),
(
f"{step_prefix}-record-confirmed-test",
copy_text(BuilderCopyId.PROGRESS_RECORD_THE_CONFIRMED_TEST_RESULT),
),
],
)
progress.activate(f"{step_prefix}-prepare-confirmed-test")
consent_id, command_id = fc.confirmed_consent_id, fc.confirmed_command_id
fc.confirmed_consent_id = ""
fc.confirmed_command_id = ""
fc.diagnosis = None
fc.staged_repair = []
saved: TestInput | None = None
if fc.test_input_ref:
try:
saved = env.repo.get_test_input(fc.test_input_ref)
except NotFoundError:
saved = None
progress.activate(run_step)
def emit(event: NodeEvent) -> None:
progress.observe_node(run_step, NodeEvent(node_id=event.node_id, status=event.status, run_id=event.run_id))
if saved is None:
raw = Run(
kind="verify",
status="failed",
execution_refusal=BuilderExecutionRefusal(reason_code="test_input_binding_mismatch"),
)
else:
try:
raw = env.dify.run_draft(
s.app_id,
turn.actor,
dict(saved.inputs),
emit,
session_id=s.id,
test_input_id=saved.id,
on_workflow_event=env.emit_workflow,
confirmed_consent_id=consent_id,
command_id=command_id,
)
except Exception as exc:
# The type name only: provider and credential text never reach logs or cards.
logger.warning(
"dify_builder confirmed tool test returned no run (session=%s, command=%s, error=%s)",
s.id,
command_id,
type(exc).__name__,
)
raw = Run(kind="verify", status="running")
refusal = raw.execution_refusal
if refusal is not None and refusal.reason_code in _DUPLICATE_LAUNCH_REASONS:
# Another delivery owns this command's launch: commit nothing for this one.
raise ConflictError("dify_builder: confirmed command was already launched")
verification = raw.verification
if verification is not None:
verification = replace(verification, terminal_outputs=None)
run = Run(
execution_refusal=raw.execution_refusal,
id=str(uuid.uuid4()),
session_id=s.id,
verification=verification,
kind="verify",
dify_run_id=raw.dify_run_id,
status=raw.status,
per_node=[NodeOutput(node_id=node.node_id, status=node.status) for node in raw.per_node],
error="",
inputs_ref=saved.id if saved is not None else fc.test_input_ref,
immutable=True,
)
fc.verify_run_id = run.id
if fc.business_acceptance_contract:
# Confirmed content is already redacted. Annotation cannot justify any
# new graph I/O after the effect or discard the completed native Run.
revision = run.verification.execution_revision if run.verification else ""
record_run_acceptance(s, fc, run, saved, None, revision)
card = result_card(run, reply_language=fc.reply_language)
if card.outcome == "execution_succeeded_needs_review":
progress.complete(run_step)
reply = CONFIRMED_COMPLETED_REPLY
else:
progress.fail_step(run_step)
reply = CONFIRMED_NOT_COMPLETED_REPLY
progress.activate(f"{step_prefix}-record-confirmed-test")
items = append_card(fc, card)
items += append_assistant(
env,
s,
fc,
reply,
execution=progress.finish(),
cards=["test_result"],
turn_id=progress.operation_id,
)
return StepResult(next=review_state, context=fc, items=items, run=run, run_id_sink=[run.id])
def note_unknown_outcome(fc: DifyBuilderContext) -> bool:
"""Count one more unknown outcome; True when the cap is reached."""
fc.unknown_outcome_count += 1
return fc.unknown_outcome_count >= MAX_UNKNOWN_OUTCOMES
# Node statuses that mean "this node ran to completion": the engine's
# ``WorkflowNodeExecutionStatus.SUCCEEDED`` and the fakes' ``success``.
_RAN_OK = frozenset({"succeeded", "success"})
_BRANCH_NODE_TYPES = frozenset({"if-else", "question-classifier", "human-input"})
def _branch_node_ids(graph: Graph) -> set[str]:
"""Nodes that pick one of several outgoing edges at run time: the branch
node types. (Not ``error_strategy == "fail-branch"``: a node that actually
took its fail branch reports status ``"exception"``, outside ``_RAN_OK``,
so that clause never protected anything -- it only misclassified an
ordinary succeeded fail-branch node as a router and false-flagged a
perfectly linear success.)"""
return {
str(node.get("id") or "")
for node in graph.get("nodes", [])
if (node.get("data") or {}).get("type") in _BRANCH_NODE_TYPES
}
def dead_end_branch_node_id(graph: Graph, per_node: list[NodeOutput]) -> str:
"""The LAST branch node that ran OK where none of its outgoing edges'
targets show up in ``per_node`` at all (any status) -- i.e. the engine
skipped every arm (the ESQ1-303 mechanism). Returns "" when no such
dead end exists.
A target that appears in ``per_node`` with ANY status -- even
``"failed"`` -- means the engine did emit a row for that arm, so the
branch routed somewhere; only a target with NO row at all means its arm
was never entered. A branch node with no outgoing edges counts as
routing nowhere (there is nothing it could have reached).
"""
branch_ids = _branch_node_ids(graph)
ran_ids = {n.node_id for n in per_node}
targets_by_source: dict[str, list[str]] = {}
for edge in graph.get("edges", []):
source = str(edge.get("source") or "")
targets_by_source.setdefault(source, []).append(str(edge.get("target") or ""))
for n in reversed(per_node):
if n.node_id not in branch_ids or n.status not in _RAN_OK:
continue
targets = targets_by_source.get(n.node_id, [])
if not any(target in ran_ids for target in targets):
return n.node_id
return ""
def run_finished_without_output(graph: Graph, per_node: list[NodeOutput]) -> bool:
"""True only when: the graph has at least one End node; no End node ran
OK; and some branch node ran OK and routed nowhere (see
``dead_end_branch_node_id``) -- the ESQ1-303 mechanism itself, not merely
"a branch ran and no End did".
This is what tells the defect (every arm skipped) apart from a legitimate
side-effect-only arm that ran to completion with no End node of its own:
that arm's nodes DO show up in ``per_node``, so ``dead_end_branch_node_id``
finds no dead end and this stays green. A graph with no End node has
nothing to require, so that also stays green.
"""
end_ids = {str(n.get("id") or "") for n in graph.get("nodes", []) if (n.get("data") or {}).get("type") == "end"}
if not end_ids:
return False
if any(n.node_id in end_ids and n.status in _RAN_OK for n in per_node):
return False
return dead_end_branch_node_id(graph, per_node) != ""
# ---- helpers ---------------------------------------------------------------
MAX_REPEATED_REPAIRS = 2
def failure_signature(run: Run) -> str:
"""A STABLE key for "this is the same failure again", built from the
ENGINE's own output: the first failed node's id plus its error text, or the
launch error when the run threw before any node ran. Whitespace is
collapsed so formatting alone cannot look like a new failure. Returns ""
when the run carries nothing identifying -- an unknown failure must never
compare equal to another unknown one.
Deliberately NOT ``diagnosis.root_cause``. That is LLM prose, regenerated
on every diagnosis call: in one observed session it was reworded on each of
four turns and switched to Chinese on one of them. Keying on it meant
``error == fc.last_repair_error`` could never hold, so ``repair_attempts``
reset forever and ``repair_is_repeating`` was unreachable -- that session
burned 9 failed runs and 8 repair approvals with this guard in place.
"""
for node in run.per_node:
if node.status == "failed":
node_error = " ".join((node.error or "").split())
if node.node_id or node_error:
return f"{node.node_id}|{node_error}"
launch_error = " ".join((run.error or "").split())
return f"|{launch_error}" if launch_error else ""
def note_repair_error(
fc: DifyBuilderContext, run: Run, *, business_signature: str = "", graph: Graph | None = None
) -> None:
"""Record this failure's signature and count CONSECUTIVE repeats of it.
``repair_attempts`` is not a global budget: a different failure means the
loop is still making progress, however slowly, so the count restarts.
The same failure again means the repair that just ran did not address the
cause, so the count advances. Call this once per diagnosis, before
``repair_is_repeating``.
Native failures use the Run's engine evidence. A caller may instead pass
the signature of a validated current explicit business mismatch; that key
comes from its contract/checks, never from an LLM's diagnosis prose.
``graph`` binds the failure to executable progress across recovery; legacy
callers without a graph retain failure-only convergence. New Tasks clear
the stored evidence, while same-goal flow restarts preserve it.
"""
from services.dify_builder.revision import executable_graph_revision
# The native revision owner removes canvas presentation. Titles/descriptions
# also cannot fix a failure, even though they participate in draft revision.
revision = ""
if graph is not None:
executable = {
**graph,
"nodes": [
{**node, "data": {k: v for k, v in node.get("data", {}).items() if k not in {"title", "desc"}}}
for node in graph.get("nodes", [])
],
}
revision = executable_graph_revision(executable)
signature = business_signature or failure_signature(run)
same_graph = not revision or not fc.last_repair_graph_revision or revision == fc.last_repair_graph_revision
if signature and signature == fc.last_repair_error and same_graph:
fc.repair_attempts += 1
else:
fc.repair_attempts = 0
fc.last_repair_error = signature
fc.last_repair_graph_revision = revision
def repair_is_repeating(fc: DifyBuilderContext) -> bool:
"""Has the SAME error now survived ``MAX_REPEATED_REPAIRS`` repairs?
Reads the counter ``note_repair_error`` maintains; the identical error
that many times over means the repair agent is aiming at something that
isn't the cause, and another round will not find it.
"""
return fc.repair_attempts >= MAX_REPEATED_REPAIRS
def append_item(fc: DifyBuilderContext, kind: str, payload: dict[str, Any]) -> list[ConversationItem]:
"""Append a conversation item stamped with the next sequence number.
``fc.next_seq`` is the seq authority (port of ``handlers_fix.go:9-13``):
every call stamps the current value then increments it, so a caller that
forgets to route through here will collide with
``InMemoryRepository.compare_and_advance``'s ``(session_id, seq)``
uniqueness guard.
"""
item = ConversationItem(seq=fc.next_seq, kind=kind, payload=payload)
fc.next_seq += 1
return [item]
def append_card(fc: DifyBuilderContext, card) -> list[ConversationItem]:
"""Stamp a typed card into the conversation at the next seq (mirrors
append_item's seq authority). The runner stamps the winning CAS version.
"""
if isinstance(card, BuilderPresentation):
card.reply_language = fc.reply_language
item = card.to_item(seq=fc.next_seq, at_version=0)
fc.next_seq += 1
return [item]
def append_assistant(
env: Env,
s: Session,
fc: DifyBuilderContext,
reply_text: BuilderText,
*,
execution: ExecutionProgress | None = None,
cards: list[str] | None = None,
turn_id: str | None = None,
) -> list[ConversationItem]:
"""Prepare complete handler presentation for emission after commit."""
return env.append_assistant_turn(
s,
fc,
reply_text=reply_text,
execution=execution or ExecutionProgress(status="completed"),
cards=cards,
turn_id=turn_id,
)
def test_failure_reason(run: Run) -> BuilderText:
"""Return the most specific user-facing reason available for a failed run."""
for output in run.per_node:
if output.status == "failed" and output.error:
return output.error
if run.error:
return run.error
return copy_text(BuilderCopyId.TEST_NO_DETAILED_ERROR)
_FORM_FIELD_TYPES = {"bool", "json", "json_object", "number", "select", "text", "textarea"}
def build_form_fields(specs: list[dict]) -> list[FormField]:
"""Build FormField cards from agent-provided field specs, whitelisting
keys and clamping type to the supported scalar/JSON kinds (unknown -> text)."""
fields: list[FormField] = []
for spec in specs:
if not isinstance(spec, dict) or not spec.get("key"):
continue
ftype = spec.get("type") if spec.get("type") in _FORM_FIELD_TYPES else "text"
options = spec.get("options") if isinstance(spec.get("options"), list) else []
hint = spec.get("hint")
fields.append(
FormField(
key=str(spec["key"]),
label=stored_text(spec.get("label_copy") or str(spec.get("label", spec["key"]))),
type=ftype,
required=spec.get("required") is True,
options=[str(o) for o in options],
# A field left blank on purpose has to say why, or it reads as a
# box the user forgot to fill (PM report 2026-09-29).
hint=stored_text(spec["hint_copy"])
if spec.get("hint_copy")
else hint
if isinstance(hint, str) and hint
else None,
)
)
return fields
def action_string(turn: Turn, key: str) -> tuple[str, bool]:
"""Read a string field out of ``turn.action.payload``, Go-style ``(v, ok)``."""
if turn.action is None:
return "", False
value = turn.action.payload.get(key)
if isinstance(value, str):
return value, True
return "", False
def action_kind(turn: Turn) -> str:
"""The current turn's action kind, or ``""`` if no action was sent."""
return turn.action.kind if turn.action is not None else ""
def emit_canvas(env: Env, event: str, **extra) -> None:
"""Fire a granular canvas event iff the caller wired ``env.emit_canvas``
(opt-in; None is a no-op, mirroring how apply_repair treats on_canvas)."""
if env.emit_canvas is not None:
env.emit_canvas({"event": event, **extra})
def build_change_set(
result: ApplyResult, *, default_scope: str, fallback_diff: str
) -> tuple[list[str], str, ChangeSet]:
"""Derive ``(changes, scope, ChangeSet)`` from an ``apply_repair`` result.
Adapters that don't compute a real diff (e.g. FakeDifyPort in tests) leave
``changes``/``scope`` empty -- fall back to the old changed_nodes/
``default_scope`` behavior so those callers stay green. ``fallback_diff``
is used when the joined changes string is empty.
"""
# str() defensively: a non-string id must never crash the session on the
# join below. validate_intent_args rejects these at the boundary now, but
# this is the line that took the whole session down (ESQ1-271).
changes = list(result.changes) if result.changes else [str(node) for node in result.changed_nodes]
scope = result.scope or default_scope
change_set = ChangeSet(changed_nodes=result.changed_nodes, diff="; ".join(changes) or fallback_diff)
return changes, scope, change_set
def mint_checkpoint(env: Env, s: Session, fc: DifyBuilderContext, graph: Graph, graph_hash: str, state: PcState) -> str:
"""Mint + persist a checkpoint from an already-read ``graph``/``graph_hash``,
and stamp ``fc``'s restore-point id / last snapshot hash / structure
fingerprint. Returns the new checkpoint id."""
checkpoint = Checkpoint(id=str(uuid.uuid4()), session_id=s.id, state=state)
env.repo.create_checkpoint(checkpoint, Snapshot(session_id=s.id, hash=graph_hash, graph=graph))
fc.checkpoint_id = checkpoint.id
fc.last_snapshot_hash = graph_hash
fc.last_structure_fingerprint = env.dify.structural_fingerprint(graph)
return checkpoint.id
def merge_known_keys(existing: dict, payload: dict, keys) -> dict:
"""Return a copy of ``existing`` with any of ``keys`` present in
``payload`` overlaid on top."""
merged = dict(existing)
for key in keys:
if key in payload:
merged[key] = payload[key]
return merged
def perform_revert(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> None:
"""Shared revert primitive (Slice 4). Restore the pre-change draft before
emitting the revert_checkpoint canvas signal, invalidating approval cards
emitted since the checkpoint (assistant_turns with seq >= fc.checkpoint_seq),
and clearing the active restore point. A refused restore changes none of
those UI/checkpoint facts. A revert with no checkpoint (fc.checkpoint_id == "")
retains the signal-only pre-Slice-4 behavior and changes no draft."""
if not fc.checkpoint_id:
if env.emit_canvas is not None:
env.emit_canvas({"event": "revert_checkpoint"})
return
_cp, snap = env.repo.get_checkpoint(fc.checkpoint_id)
fc.last_snapshot_hash = env.dify.restore_graph(
s.app_id, turn.actor, snap.graph, expected_revision=fc.last_snapshot_hash
)
if env.emit_canvas is not None:
env.emit_canvas({"event": "revert_checkpoint"})
fc.last_structure_fingerprint = env.dify.structural_fingerprint(snap.graph)
env.repo.invalidate_conversation_items(s.id, fc.checkpoint_seq)
fc.checkpoint_id = ""
def drop_unapplied_repair(
env: Env, s: Session, fc: DifyBuilderContext, progress: ProgressReporter, *, title: BuilderText, body: BuilderText
) -> list[ConversationItem]:
"""The staged repair was refused before anything was written: drop it
(so the empty-repair guard stops a re-approve of the same intents), close
the progress, and say why in the assistant reply. Shared by every repair-apply
gate (Fix's apply, Build's and Edit's await_repair)."""
fc.staged_repair = []
execution = progress.finish(status="error")
return append_assistant(
env, s, fc, text_parts(title, ". ", body), execution=execution, turn_id=progress.operation_id
)
def first_failed_node(nodes: list[NodeOutput]) -> str:
for n in nodes:
if n.status == "failed":
return n.node_id
return ""
_UPLOAD_VARIABLE_TYPES = frozenset({"file", "file-list"})
def upload_variable_names(schema: StartSchema) -> set[str]:
"""Names of the start variables that take a file.
Nothing can invent an upload, so these are the values a human still has to
supply even when every other field is mocked for them.
"""
return {
str(v["variable"])
for v in schema.get("variables", [])
if isinstance(v, dict) and v.get("variable") and str(v.get("type") or "") in _UPLOAD_VARIABLE_TYPES
}
# ``{{#<node_id>.<var>#}}`` -- the Dify template reference syntax -- may sit
# anywhere inside a string (e.g. ``https://x.com/{{#s.path#}}``), so this
# searches the whole string rather than anchoring to it, except where a
# caller below deliberately anchors it with ``.match``.
_ENDPOINT_TEMPLATE_RE = re.compile(r"\{\{#([^.#{}]+)\.([^.#{}]+)#\}\}")
def _start_var_names(text: str, start_id: str) -> set[str]:
"""Every start-node template's variable name occurring ANYWHERE in
``text`` -- used where the field IS the credential/endpoint itself (an
``authorization.config.api_key``, or the value half of a credential-keyed
header/param line), so position within it doesn't matter."""
return {var_name for node_id, var_name in _ENDPOINT_TEMPLATE_RE.findall(text or "") if node_id == start_id}
def _leading_start_var_name(text: str, start_id: str) -> str | None:
"""The variable name of a start-node template occupying the HOST
position: ``text``, stripped of whitespace, STARTS with the template --
not merely containing one later, e.g. in a path or query
(``https://wttr.in/{{#s.city#}}``'s ``city`` is an ordinary data input,
not an endpoint). Returns None when there is no such leading template.
"""
match = _ENDPOINT_TEMPLATE_RE.match((text or "").strip())
if match and match.group(1) == start_id:
return match.group(2)
return None
def _host_start_var_names(url: str, start_id: str) -> set[str]:
"""Start-node template variable names occupying ``url``'s HOST position
(``urls.host_is_templated``): the leading template of a url that starts
with one (``{{#s.h_url#}}/v1/render`` -- what grounding writes for an
invented host with a templated path/query), plus any template inside the
parsed host (``https://{{#s.host#}}/v1``). A template in the path or
query is never collected."""
names: set[str] = set()
leading = _leading_start_var_name(url, start_id)
if leading:
names.add(leading)
parts = urls.split_origin(url)
if parts is not None:
names |= _start_var_names(parts.host, start_id)
return names
def _credential_line_var_names(text: str, start_id: str, key_is_credential) -> set[str]:
"""Start-variable templates in the VALUE half of each ``Key: Value`` line
of a ``headers``/``params`` text block whose KEY names a credential per
``key_is_credential`` -- an ordinary data field read via a header/param
(a search query, an ``Accept-Language``) must stay mockable, so only a
credential-keyed line's value counts."""
names: set[str] = set()
for line in (text or "").split("\n"):
if ":" not in line:
continue
key, _sep, value = line.partition(":")
if key_is_credential(key.strip()):
names |= _start_var_names(value, start_id)
return names
def endpoint_variable_names(graph: Graph) -> set[str]:
"""Names of the start variables an http-request node reads a real
endpoint or credential from: the url's HOST position, a credential-keyed
``headers``/``params`` line, or an ``api-key`` authorization's
``config.api_key``.
Nothing can invent a real endpoint or credential any more than it can
invent an upload (ESQ1-302 / build.py's ``_ground_placeholder_endpoints``
and Task 3's credential grounding), so these are values a human still has
to supply even when every other field is mocked for them. An ORDINARY
data input the workflow happens to read via the url path/query, or via a
non-credential header/param (a search query, an ``Accept-Language``), is
NOT collected -- it stays mockable, same as any other start variable.
Only a reference to the graph's own START node counts at all -- a
reference to some other node's output is an ordinary wired value.
"""
start_id = ""
for node in graph.get("nodes", []):
data = node.get("data") or {}
if data.get("type") == "start":
start_id = str(node.get("id") or "")
break
if not start_id:
return set()
names: set[str] = set()
for node in graph.get("nodes", []):
data = node.get("data") or {}
if data.get("type") != "http-request":
continue
names |= _host_start_var_names(str(data.get("url") or ""), start_id)
names |= _credential_line_var_names(str(data.get("headers") or ""), start_id, credentials.is_credential_key)
names |= _credential_line_var_names(
str(data.get("params") or ""), start_id, credentials.is_credential_param_key
)
authorization = data.get("authorization")
auth_config = authorization.get("config") if isinstance(authorization, dict) else None
if isinstance(authorization, dict) and authorization.get("type") == "api-key" and isinstance(auth_config, dict):
names |= _start_var_names(str(auth_config.get("api_key") or ""), start_id)
return names
def needs_upload_inputs(schema: StartSchema) -> bool:
"""Whether any declared start variable takes a file."""
return bool(upload_variable_names(schema))
def without_upload_values(schema: StartSchema, inputs: Inputs) -> Inputs:
"""``inputs`` minus anything bound to an upload variable.
The mock generator falls back to a plain string for a type it does not
know, so it cheerfully produces ``"test"`` for a ``file`` -- pre-filling a
file field with a value the run cannot use. Drop those and let the form
ask for them instead.
"""
dropped = upload_variable_names(schema)
return {key: value for key, value in inputs.items() if key not in dropped}
def without_endpoint_values(graph: Graph, inputs: Inputs) -> Inputs:
"""``inputs`` minus anything bound to an endpoint variable
(``endpoint_variable_names``).
A mocked URL is just another invented endpoint, and unlike a mocked file
the engine ACCEPTS it at launch: the http-request node then fails on
connection, which ``is_input_failure`` rightly reads as a config failure,
so the flow thrashes in config repair (ESQ1-302). Without the key, the
launch fails with "<var> is required in input form" -- an input failure
that routes back to the test-data gate, where the user supplies the real
endpoint.
"""
dropped = endpoint_variable_names(graph)
return {key: value for key, value in inputs.items() if key not in dropped}
def _string_list(value: object) -> list[str]:
"""Copy only string members from a JSON-style list."""
return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
def launch_error_text(exc: BaseException) -> str:
"""The exception message for a launch-failed verify run, or the type name
when it's empty (so the stored error is never blank)."""
return str(exc).strip() or type(exc).__name__
# "in input form" matches base_app_generator._validate_inputs rejections.
_INPUT_FAILURE_SIGNALS = ("file variable", "not provided", "missing input", "in input form")
def is_input_failure(run: Run) -> bool:
"""True if the failure looks like a missing/invalid test INPUT (vs a config
bug), so the flow routes back to the testdata gate. Substring-matches the
signals against failed-node errors and, for a launch failure (empty
``per_node``), against ``run.error``.
Final-review fix (Important #1): the bare ``"required"`` and generic
``"variable not found"`` signals were dropped -- both also appear in
config/runtime faults unrelated to test inputs (e.g. "field 'timeout' is
required"), which misrouted those failures to the testdata gate, where
the diagnosed auto-repair is never offered. The spec safe default for an
ambiguous failure is the config-repair path, so this tuple favors
PRECISION: exact native query-argument errors and signals specific enough
to input/file problems remain --
``"file variable"`` alone still matches the real file-input E2E error,
"File variable not found for selector: [...]".
"""
texts = [n.error for n in run.per_node if n.status == "failed" and n.error]
if run.error:
texts.append(run.error)
for text in texts:
low = text.lower().strip()
if re.fullmatch(r"(?:sys\.)?query (?:is required|must be a string)(?: \[invalid_param\])?", low):
return True
if any(sig in low for sig in _INPUT_FAILURE_SIGNALS):
return True
return False
def model_config_error_text(run: Run) -> str | None:
"""The first failed-node / launch error that looks like a MODEL-CONFIGURATION
failure -- the workflow references a model that isn't configured or available
for the workspace (e.g. ``ModelNotExistError``, "Model X does not exist",
"Provider X does not exist", a provider/credentials error). Returns the
offending error text, or None.
Such a failure is NOT a workflow-logic bug: diagnosing/repairing the graph
just thrashes (proposing if-else / extra nodes that can't help), so the flow
surfaces it to the human instead. Matching pairs an existence/credential
signal with a model/provider/credential token, so an unrelated
"<node> does not exist" fault is not swallowed.
"""
texts = [n.error for n in run.per_node if n.status == "failed" and n.error]
if run.error:
texts.append(run.error)
for text in texts:
low = text.lower()
if "modelnotexist" in low: # the ModelNotExistError class name
return text
has_model_ctx = "model" in low or "provider" in low or "credential" in low
if not has_model_ctx:
continue
if "does not exist" in low or "not exist" in low or "not found" in low or "not configured" in low:
return text
if "credential" in low: # missing / invalid model credentials
return text
return None
def testdata_form_fields(schema: StartSchema) -> list[FormField]:
"""Map a StartSchema's declared variables to FormFields for the testdata
gate, preserving the declared type and UI/input constraints. Malformed
optional values are dropped instead of leaking non-contract values into
the conversation payload."""
fields: list[FormField] = []
for v in schema.get("variables", []):
if not isinstance(v, dict) or not v.get("variable"):
continue
variable_type = str(v.get("type") or "text")
raw_default = v.get("default")
default = raw_default if isinstance(raw_default, (str, int, float, bool)) else None
raw_max_length = v.get("max_length")
max_length = (
raw_max_length
if isinstance(raw_max_length, int) and not isinstance(raw_max_length, bool) and raw_max_length > 0
else None
)
raw_number_limits = v.get("number_limits")
number_limits = (
raw_number_limits
if isinstance(raw_number_limits, int) and not isinstance(raw_number_limits, bool) and raw_number_limits > 0
else None
)
if number_limits is None and variable_type in {"file", "file-list"}:
number_limits = max_length
upload_methods = v.get("allowed_file_upload_methods")
if upload_methods is None:
upload_methods = v.get("allowed_upload_methods")
raw_json_schema = v.get("json_schema")
json_schema = (
dict(raw_json_schema)
if isinstance(raw_json_schema, dict)
else raw_json_schema
if isinstance(raw_json_schema, str)
else None
)
fields.append(
FormField(
key=str(v["variable"]),
label=copy_text(BuilderCopyId.FORMS_USER_MESSAGE)
if schema.get("system_query") == v["variable"]
else str(v.get("label") or v["variable"]),
type=variable_type,
options=_string_list(v.get("options")),
required=v.get("required") if isinstance(v.get("required"), bool) else False,
default=default,
max_length=max_length,
allowed_file_types=_string_list(v.get("allowed_file_types")),
allowed_file_extensions=_string_list(v.get("allowed_file_extensions")),
allowed_file_upload_methods=_string_list(upload_methods),
placeholder=v.get("placeholder") if isinstance(v.get("placeholder"), str) else None,
hint=v.get("hint") if isinstance(v.get("hint"), str) else None,
unit=v.get("unit") if isinstance(v.get("unit"), str) else None,
json_schema=json_schema,
number_limits=number_limits,
)
)
return fields
def _test_effects(env: Env, s: Session, fc: DifyBuilderContext, actor: Actor, inputs: dict[str, Any]):
# HTTP/Tool direct-input approval semantics remain unchanged. Code's
# explanation additionally binds typed inputs plus trusted task/model facts.
if fc.code_risk_assessments:
return env.dify.disclose_effects(s.app_id, actor, inputs=inputs, code_risk_context=fc)
return env.dify.disclose_effects(s.app_id, actor, inputs=inputs)
def testdata_form(
env: Env,
s: Session,
fc: DifyBuilderContext,
actor: Actor,
schema: StartSchema,
values: dict[str, Any],
reply: BuilderText,
) -> tuple[FormCard, BuilderText]:
"""The test-data form plus its reply, carrying the Test Run effect
disclosure (PRD §9.1): one unticked checkbox per side effect, so Mock stays
the default, and the reply lists what else the run reaches."""
# Use prepared values to show direct Start-based targets. Typing inputs in
# this form is intentional; submission binds those values after checking
# that the configuration itself still matches the shown disclosure.
fc.code_risk_assessments = env.dify.prepare_code_risks(
s.app_id,
actor,
session_id=s.id,
inputs=values,
context=fc,
)
disclosure = _test_effects(env, s, fc, actor, values)
fc.effect_confirmation_form = {
item.node_id: item.config_signature or item.signature for item in disclosure.confirmable
}
card = FormCard(
variant="testdata",
title=copy_text(BuilderCopyId.FORMS_TEST_DATA_TITLE),
description=copy_text(BuilderCopyId.FORMS_TEST_DATA_DESCRIPTION),
fields=[*testdata_form_fields(schema), *effect_confirmation_fields(disclosure)],
values={**values, **effect_confirmation_values(disclosure, fc.effect_confirmation)},
frozen=False,
)
return card, with_effects_note(reply, effects_note(disclosure, reply_language=fc.reply_language))
def record_effect_confirmation(
env: Env, s: Session, fc: DifyBuilderContext, actor: Actor, inputs: dict[str, Any]
) -> tuple[dict[str, Any], bool]:
"""Record only checkboxes for configuration actually shown by the server.
Return Start inputs and whether a selected effect needs fresh disclosure.
HTTP/Tool direct Start edits bind approval after the static comparison.
Code input changes require a fresh explanation before accepting its choice.
"""
plain, confirmed = split_confirmations(inputs)
current = _test_effects(env, s, fc, actor, plain)
selected = {
item.node_id: item.config_signature or item.signature
for item in current.confirmable
if item.node_id in confirmed
}
accepted = [
node_id for node_id, signature in selected.items() if fc.effect_confirmation_form.get(node_id) == signature
]
stale = len(accepted) != len(selected) or any(
item.node_type == "code" and not item.assessment_digest for item in current.items
)
fc.effect_confirmation = current.confirmation(accepted)
fc.effect_confirmation_form = {}
return plain, stale
def needs_effect_confirmation(env: Env, s: Session, fc: DifyBuilderContext, actor: Actor) -> bool:
"""True while a side effect has no confirmation for its current target, so
Run test shows the form again instead of silently mocking it."""
disclosure = _test_effects(env, s, fc, actor, saved_test_inputs(env, fc))
return any(fc.effect_confirmation.get(item.node_id) != item.signature for item in disclosure.confirmable) or any(
item.node_type == "code" and not item.assessment_digest for item in disclosure.items
)
def saved_test_inputs(env: Env, fc: DifyBuilderContext) -> dict[str, Any]:
"""The prepared inputs to prefill the form with, or none."""
ref = fc.test_input_ref or fc.retained_test_input_ref
if not ref:
return {}
try:
return dict(env.repo.get_test_input(ref).inputs)
except NotFoundError:
return {}
def input_gate_result(
env: Env,
s: Session,
fc: DifyBuilderContext,
schema: StartSchema,
inputs: dict[str, Any],
state: PcState,
reason: BuilderText,
*,
actor: Actor,
) -> StepResult:
"""Return invalid runtime test data for correction without losing uploads."""
fc.test_input_ref = ""
fc.verify_run_id = ""
card, reply = testdata_form(
env,
s,
fc,
actor,
schema,
inputs,
copy_text(BuilderCopyId.TEST_INVALID_INPUTS, reason=reason)
if isinstance(reason, str)
else text_parts(copy_text(BuilderCopyId.TEST_INVALID_INPUTS, reason=""), reason),
)
form_items = append_card(fc, card)
items = append_assistant(env, s, fc, reply, cards=["form"])
return StepResult(next=state, context=fc, items=[*form_items, *items])
def _mode_or_default(mode: str) -> str:
return mode or "upload"
# ---- handlers ---------------------------------------------------------------
def handle_diagnose(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(working) Read the failed run's node outputs, diagnose, capture the
pre-repair checkpoint. Port of ``handlers_fix.go:38``."""
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=[
("fix-load-failure", copy_text(BuilderCopyId.PROGRESS_LOAD_THE_FAILED_RUN)),
("fix-inspect-workflow", copy_text(BuilderCopyId.PROGRESS_INSPECT_THE_WORKFLOW_AND_NODE_OUTPUTS)),
("fix-diagnose-cause", copy_text(BuilderCopyId.PROGRESS_DIAGNOSE_THE_ROOT_CAUSE)),
],
)
progress.activate("fix-load-failure")
fc.checkpoint_seq = fc.next_seq
failed = env.repo.get_run(fc.failed_run_id)
if is_execution_policy_blocker(failed):
fc.diagnosis = None
fc.staged_repair = []
card = result_card(failed, reply_language=fc.reply_language)
items = append_card(fc, card)
items += append_assistant(
env,
s,
fc,
card.failure_reason or copy_text(BuilderCopyId.TEST_POLICY_REVIEW),
execution=progress.finish(),
cards=["test_result"],
)
return StepResult(next=PcState.FIX_AWAIT_DECISION, context=fc, items=items)
progress.activate("fix-inspect-workflow")
graph, graph_hash = env.dify.read_graph(s.app_id, turn.actor)
from services.dify_builder.condition_evidence import diagnose_failure
progress.activate("fix-diagnose-cause")
diagnosis = diagnose_failure(env, s.app_id, turn.actor, failed, graph, task=(s, fc, graph_hash))
fc.diagnosis = diagnosis
fc.last_snapshot_hash = graph_hash
fc.last_structure_fingerprint = env.dify.structural_fingerprint(graph)
business = business_failure_context(
failed, s, fc, current_revision=graph_hash, current_graph_revision=env.dify.graph_revision(graph)
)
repeating_business = False
if business is not None:
note_repair_error(fc, failed, business_signature=business.signature, graph=graph)
repeating_business = repair_is_repeating(fc)
if repeating_business:
fc.staged_repair = []
fc.risk = Risk(level="high", has_external_side_effect=False)
execution = progress.finish()
items = append_assistant(
env,
s,
fc,
copy_text(
BuilderCopyId.DIAGNOSIS_COMPLETE,
cause=diagnosis.root_cause,
node=diagnosis.culprit_node_id,
severity=diagnosis.severity,
),
execution=execution,
)
return StepResult(
next=PcState.FIX_AWAIT_APPROVAL if repeating_business else PcState.FIX_PROPOSE,
context=fc,
items=items,
checkpoint=Snapshot(session_id=s.id, hash=graph_hash, graph=graph),
)
def handle_propose(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""Delegate Fix/checklist proposal staging to the service-layer owner."""
from services.dify_builder.repair_gate import handle_propose as propose
return propose(env, turn, s, fc)
def handle_await_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""Delegate repair approval and empty-proposal choices to their owner."""
from services.dify_builder.repair_gate import handle_await_approval as await_approval
return await_approval(env, turn, s, fc)
def handle_apply(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(working) Apply the staged repair to the real draft. The next state
branches on ``fc.source``: the run-fix path always lands at
``fix.await_verify``; the checklist-fix path lands at
``checklist.await_recheck``. Port of ``handlers_fix.go:146``."""
if not fc.staged_repair:
# Nothing staged: applying would change nothing and the verify run
# would fail identically. Don't spend a run on it (ESQ1-291) --
# hand the decision back instead. handle_propose reaches here with
# an empty staged_repair whenever the agent found no safe fix.
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.FIX_AWAIT_DECISION, 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=[
("fix-apply-repair", copy_text(BuilderCopyId.PROGRESS_APPLY_THE_REPAIR_TO_THE_CANVAS)),
("fix-summarize-changes", copy_text(BuilderCopyId.PROGRESS_SUMMARIZE_THE_APPLIED_CHANGES)),
],
)
progress.activate("fix-apply-repair")
try:
result = env.dify.apply_repair(
s.app_id,
turn.actor,
fc.staged_repair,
on_canvas=env.emit_canvas,
expected_revision=fc.last_snapshot_hash,
**dict[str, Any](
{"confirmed_resource_ids": list(fc.resource_selection["resource_ids"])}
if any(item.startswith("agent:") for item in fc.resource_selection.get("resource_ids", []))
else {}
),
failed_run_id=(
env.repo.get_run(fc.failed_run_id).dify_run_id
if fc.source != "checklist"
and fc.failed_run_id
and not (fc.business_acceptance_contract and env.repo.get_run(fc.failed_run_id).status == "succeeded")
else None
),
business_failure_run_id=(
fc.failed_run_id
if fc.source != "checklist"
and fc.failed_run_id
and fc.business_acceptance_contract
and env.repo.get_run(fc.failed_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.
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.FIX_AWAIT_DECISION, context=fc, items=items)
except ValueError as exc:
# Same stale-intent window as Build's/Edit'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.FIX_AWAIT_DECISION, context=fc, items=items)
fc.last_snapshot_hash = result.new_hash
fc.last_structure_fingerprint = result.structure_fingerprint
progress.activate("fix-summarize-changes")
changes, scope, fc.change_set = build_change_set(result, default_scope="configuration", fallback_diff="config edit")
execution = progress.finish()
change_lines = "\n".join(f"- {change}" for change in changes) or text_parts(
"- ", copy_text(BuilderCopyId.REPAIR_CONFIGURATION_UPDATED)
)
items = append_assistant(
env,
s,
fc,
text_parts(
copy_text(
BuilderCopyId.REPAIR_CHANGES_APPLIED,
count=len(changes),
scope=change_scope_label(scope, fc.reply_language),
),
"\n",
change_lines,
),
execution=execution,
)
next_state = PcState.FIX_AWAIT_VERIFY
if fc.source == "checklist":
next_state = PcState.CHECKLIST_AWAIT_RECHECK
return StepResult(next=next_state, context=fc, items=items)
def handle_await_verify(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(waiting) The user runs verification or undoes. Port of
``handlers_fix.go:163``."""
if turn.action is not None and turn.action.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.FIX_AWAIT_DECISION, context=fc, items=items)
# run_verify: if we have prepared inputs and every side effect is
# confirmed for its current target, go verify; else prepare test data.
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_VALIDATION),
)
form_items = append_card(fc, card)
turn_items = append_assistant(env, s, fc, reply, cards=["form"])
return StepResult(
next=PcState.FIX_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
return StepResult(next=PcState.FIX_VERIFY, context=fc)
def handle_await_testdata(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(waiting) Prepare inputs for the verify run. Port of
``handlers_fix.go:176``."""
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")
inputs: dict[str, Any] = {}
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=[("fix-generate-test-inputs", copy_text(BuilderCopyId.PROGRESS_GENERATE_VALIDATION_INPUTS))],
)
progress.activate("fix-generate-test-inputs")
inputs = without_endpoint_values(
graph, env.agent.generate_mock_inputs(schema, {}, business_context=mock_input_business_context(fc))
)
progress.finish()
else:
# upload / reuse: payload carries the inputs directly for the slice.
if turn.action is not None:
raw = turn.action.payload.get("inputs")
if isinstance(raw, dict):
inputs = raw
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.FIX_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.FIX_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_default(mode),
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.FIX_VERIFY, context=fc)
def handle_verify(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(working) Run the repaired draft, mint a NEW immutable Run. Advances
to ``fix.await_decision`` on pass or graph failure; Chatflow input
failures return to ``fix.await_testdata``. Never touches the original
failed run. Port of ``handlers_fix.go:206``."""
if is_confirmed_tool_turn(turn, fc):
return confirmed_tool_test(env, turn, s, fc, review_state=PcState.FIX_AWAIT_DECISION, step_prefix="fix")
if not fc.test_input_ref:
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))
return input_gate_result(
env,
s,
fc,
schema,
{},
PcState.FIX_AWAIT_TESTDATA,
"Provide test data before validation",
actor=turn.actor,
)
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=[
("fix-prepare-validation", copy_text(BuilderCopyId.PROGRESS_PREPARE_VALIDATION_INPUTS)),
("fix-run-validation", copy_text(BuilderCopyId.PROGRESS_RUN_THE_REPAIRED_WORKFLOW)),
("fix-evaluate-validation", copy_text(BuilderCopyId.PROGRESS_EVALUATE_VALIDATION_RESULTS)),
],
)
progress.activate("fix-prepare-validation")
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))
ti = None
inputs: dict[str, Any] = {}
if fc.test_input_ref != "":
ti = env.repo.get_test_input(fc.test_input_ref)
inputs = ti.inputs
progress.activate("fix-run-validation")
def emit(event: NodeEvent) -> None:
progress.observe_node("fix-run-validation", event)
try:
validate_runtime_inputs(schema, inputs, ti)
confirmation = fc.effect_confirmation
fc.effect_confirmation = {}
result = 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,
)
except Exception as exc:
# Same degrade as Build/Edit: never crash the advance; the launch error
# is captured on the Run (log + store) so the decision gate can show it.
logger.exception("dify_builder verify run failed to launch (session=%s, app=%s)", s.id, s.app_id)
result = Run(kind="verify", status="failed", per_node=[], error=launch_error_text(exc))
if result.status == "succeeded":
progress.complete("fix-run-validation")
else:
progress.fail_step("fix-run-validation")
progress.activate("fix-evaluate-validation")
# Python has no equivalent to Go's `runIDSink: &fc.VerifyRunID` pointer
# aliasing (see runner.py's module docstring), so this handler mints the
# run id itself and threads the SAME value into both the Run and
# fc.verify_run_id directly; run_id_sink is still populated so the
# runner's generic write-back stays consistent (a no-op, since the id
# already matches).
if result.execution_refusal is not None and result.execution_refusal.reason_code == "code_risk_assessment_stale":
return input_gate_result(
env,
s,
fc,
schema,
dict(inputs),
PcState.FIX_AWAIT_TESTDATA,
copy_text(BuilderCopyId.TEST_POLICY_REVIEW),
actor=turn.actor,
)
run = Run(
execution_refusal=result.execution_refusal if result else None,
id=str(uuid.uuid4()),
session_id=s.id,
verification=result.verification,
kind="verify",
dify_run_id=result.dify_run_id,
status=result.status,
per_node=result.per_node,
error=result.error,
inputs_ref=fc.test_input_ref,
immutable=True,
)
record_run_acceptance(s, fc, run, ti, graph, _hash)
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.FIX_AWAIT_DECISION, context=fc, items=items, run=run, run_id_sink=[run.id])
if result.status == "running":
# Stream truncated: the run's outcome is genuinely unknown, NOT a
# failure. Advancing to fix.await_decision here would let the user
# publish (or re-fix) against a run that may still be executing --
# explain the unknown result and return to fix.await_testdata
# (re-runnable), never diagnosing or staging a repair against an
# unknown result. The second consecutive unknown outcome stops the
# re-run loop at the decision gate instead.
fc.verify_run_id = run.id
if note_unknown_outcome(fc):
execution = progress.finish()
items = append_assistant(
env, s, fc, UNKNOWN_OUTCOME_STUCK_BODY, execution=execution, turn_id=progress.operation_id
)
return StepResult(
next=PcState.FIX_AWAIT_DECISION,
context=fc,
items=items,
run=run,
run_id_sink=[run.id],
)
items = append_assistant(
env, s, fc, UNKNOWN_TEST_OUTCOME_REPLY, execution=progress.finish(), turn_id=progress.operation_id
)
return StepResult(
next=PcState.FIX_AWAIT_TESTDATA,
context=fc,
items=items,
run=run,
run_id_sink=[run.id],
)
fc.unknown_outcome_count = 0
if result.status != "succeeded":
run.culprit_node_id = first_failed_node(result.per_node)
fc.verify_run_id = run.id
if result.status == "succeeded" and (
has_output_blocker(run) or (run.verification is None and run_finished_without_output(graph, result.per_node))
):
# A branch node ran and every one of its arms was skipped, and no End
# node ran either: not a pass, and not an engine error to diagnose.
# Point at the branch node and let the user decide.
run.culprit_node_id = dead_end_branch_node_id(graph, result.per_node)
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()
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.FIX_AWAIT_DECISION,
context=fc,
items=items,
run=run,
run_id_sink=[run.id],
)
if result.status == "failed" and schema.get("system_query") == SYSTEM_QUERY and is_input_failure(run):
progress.finish()
gated = input_gate_result(
env, s, fc, schema, inputs, PcState.FIX_AWAIT_TESTDATA, test_failure_reason(run), actor=turn.actor
)
# The shared prelaunch gate clears older evidence. This post-run gate
# must retain the failed attempt that Runner persists before the CAS.
fc.verify_run_id = run.id
gated.run = run
gated.run_id_sink = [run.id]
return gated
items = append_card(
fc,
result_card(run, reply_language=fc.reply_language),
)
progress.finish()
if run.status == "succeeded":
items += append_assistant(
env,
s,
fc,
text_parts(SUCCESS_REPLY, " ", result_card(run, reply_language=fc.reply_language).review_note or ""),
cards=["test_result"],
)
return StepResult(
next=PcState.FIX_AWAIT_DECISION,
context=fc,
items=items,
run=run,
run_id_sink=[run.id],
)
def publication_for_context(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> PublicationDecision:
run = None
if fc.verify_run_id:
with suppress(NotFoundError):
run = env.repo.get_run(fc.verify_run_id)
graph, revision = env.dify.read_graph(s.app_id, turn.actor)
from services.dify_builder.business_acceptance import context_publication_decision
return context_publication_decision(
run,
s,
fc,
current_revision=revision,
current_graph_revision=env.dify.graph_revision(graph),
load_run=env.repo.get_run,
)
def publication_denied(env: Env, s: Session, fc: DifyBuilderContext, state: PcState, reason: str) -> StepResult:
"""Keep the publication gate and explain the refusal in a complete reply."""
return StepResult(
next=state,
context=fc,
items=append_assistant(
env,
s,
fc,
copy_text(BuilderCopyId.PUBLISH_VERIFICATION_REQUIRED, reason=str(reason)),
),
)
def handle_await_decision(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(waiting) Terminal choice. Port of ``handlers_fix.go:239``."""
kind = action_kind(turn)
if kind == "run_verify":
fc.verify_run_id = ""
return handle_await_verify(env, turn, s, fc)
if kind == "publish":
decision = publication_for_context(env, turn, s, fc)
if not decision.allowed:
return publication_denied(env, s, fc, PcState.FIX_AWAIT_DECISION, decision.reason)
return StepResult(next=PcState.FIX_PUBLISH, context=fc)
if kind == "re_fix":
if fc.verify_run_id and fc.business_acceptance_contract:
latest = env.repo.get_run(fc.verify_run_id)
graph, revision = env.dify.read_graph(s.app_id, turn.actor)
if (
business_failure_context(
latest, s, fc, current_revision=revision, current_graph_revision=env.dify.graph_revision(graph)
)
is not None
):
fc.failed_run_id = latest.id
fc.retained_test_input_ref = latest.inputs_ref
fc.diagnosis = None
fc.staged_repair = []
fc.risk = None
fc.change_set = None
fc.test_input_ref = ""
fc.verify_run_id = ""
fc.unknown_outcome_count = 0
return StepResult(next=PcState.FIX_DIAGNOSE, context=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.SUCCESS, context=fc, items=items)
# default: keep_draft
items = append_card(fc, DecisionItem(text=copy_text(BuilderCopyId.LABELS_KEPT_THE_DRAFT)))
return StepResult(next=PcState.SUCCESS, context=fc, items=items)
def handle_publish(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(working) Publish the repaired workflow. Port of
``handlers_fix.go:316``."""
decision = publication_for_context(env, turn, s, fc)
if not decision.allowed:
return publication_denied(env, s, fc, PcState.FIX_AWAIT_DECISION, 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=[("fix-publish-workflow", copy_text(BuilderCopyId.PROGRESS_PUBLISH_THE_REPAIRED_WORKFLOW))],
)
progress.activate("fix-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("fix-publish-workflow")
progress.finish()
return publication_denied(env, s, fc, PcState.FIX_AWAIT_DECISION, "stale_revision")
execution = progress.finish()
items = append_assistant(
env,
s,
fc,
copy_text(BuilderCopyId.PUBLISH_COMPLETE, version=published.version_name, status=published.status),
execution=execution,
)
return StepResult(next=PcState.SUCCESS, context=fc, items=items)
# ---- checklist handlers -----------------------------------------------------
def handle_checklist_diagnose(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(working) Mirrors ``handle_diagnose`` but diagnoses from the
pre-publish checklist's config errors instead of a failed run's node
outputs — there is no ``fc.failed_run_id`` on this path, so no run
lookup. Port of ``handlers_fix.go:72``."""
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=[
("checklist-inspect-workflow", copy_text(BuilderCopyId.PROGRESS_INSPECT_CHECKLIST_FINDINGS)),
("checklist-diagnose-cause", copy_text(BuilderCopyId.PROGRESS_DIAGNOSE_THE_CONFIGURATION_ISSUE)),
],
)
progress.activate("checklist-inspect-workflow")
fc.checkpoint_seq = fc.next_seq
graph, graph_hash = env.dify.read_graph(s.app_id, turn.actor)
progress.activate("checklist-diagnose-cause")
diagnosis = env.agent.diagnose_checklist(fc.checklist_errors, graph, reply_language=fc.reply_language or None)
fc.diagnosis = diagnosis
fc.last_snapshot_hash = graph_hash
fc.last_structure_fingerprint = env.dify.structural_fingerprint(graph)
execution = progress.finish()
items = append_assistant(
env,
s,
fc,
copy_text(
BuilderCopyId.DIAGNOSIS_CHECKLIST_COMPLETE, cause=diagnosis.root_cause, node=diagnosis.culprit_node_id
),
execution=execution,
)
return StepResult(
next=PcState.CHECKLIST_PROPOSE,
context=fc,
items=items,
checkpoint=Snapshot(session_id=s.id, hash=graph_hash, graph=graph),
)
def handle_await_recheck(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(waiting) The checklist-fix counterpart of ``handle_await_verify``:
instead of running the workflow, the caller re-runs the pre-publish
checklist and reports the result via a ``"recheck"`` action. On ``undo``
(resolved to revert) restores the pre-change draft from the checkpoint and
invalidates the approvals made since it (via perform_revert), then ends the
flow. If the checklist now passes, hand off to the shared
``fix.await_decision`` gate (publish / keep / re-fix / undo). Otherwise
the caller supplies the still-failing entries in
``payload["remaining"]``, which replace ``fc.checklist_errors``, and the
flow loops back to ``checklist.diagnose`` to re-fix. Port of
``handlers_fix.go:272``."""
if turn.action is not None and turn.action.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.SUCCESS, context=fc, items=items)
passed = False
if turn.action is not None:
value = turn.action.payload.get("passed")
if isinstance(value, bool):
passed = value
if passed:
items = append_card(fc, TestResultCard(status="succeeded"))
return StepResult(next=PcState.FIX_AWAIT_DECISION, context=fc, items=items)
# residual checklist errors: reload them and loop back to re-diagnose.
if turn.action is not None:
raw = turn.action.payload.get("remaining")
if isinstance(raw, list):
fc.checklist_errors = decode_checklist_errors(raw)
fc.diagnosis, fc.staged_repair, fc.risk, fc.change_set = None, [], None, None
return StepResult(next=PcState.CHECKLIST_DIAGNOSE, context=fc)
def decode_checklist_errors(raw: list) -> list[ChecklistError]:
"""Decode a checklist-recheck action's loosely-typed ``remaining``
payload (JSON objects decoded as ``list[dict]``, mirroring Go's
``[]any``/``map[string]any``) into ``list[ChecklistError]`` — the same
JSON round-trip Go's ``decodeChecklistErrors`` (``handlers_fix.go:303``)
does via ``json.Marshal``/``json.Unmarshal``, mirroring how the ent layer
maps JSON columns onto typed structs. A checklist recheck action is
best-effort user input, not a persisted commit: like Go's
``json.Unmarshal`` failing the whole decode on the first type mismatch
it hits, any entry that isn't a well-typed ``ChecklistError`` drops the
ENTIRE batch (returns ``[]``) rather than raising or partially decoding.
"""
out: list[ChecklistError] = []
for entry in raw:
if not isinstance(entry, dict):
return []
node_id = entry.get("node_id", "")
node_type = entry.get("node_type", "")
title = entry.get("title", "")
messages = entry.get("messages", [])
unconnected = entry.get("unconnected", False)
plugin_missing = entry.get("plugin_missing", False)
if (
not isinstance(node_id, str)
or not isinstance(node_type, str)
or not isinstance(title, str)
or not isinstance(messages, list)
or not all(isinstance(m, str) for m in messages)
or not isinstance(unconnected, bool)
or not isinstance(plugin_missing, bool)
):
return []
out.append(
ChecklistError(
node_id=node_id,
node_type=node_type,
title=title,
messages=list(messages),
unconnected=unconnected,
plugin_missing=plugin_missing,
)
)
return out
def fix_registry() -> dict[PcState, Handler]:
"""The handler table for the Fix flow — the nine Fix states plus the
checklist-fix states, which feed into the same
propose/apply/verify/publish flow via a shared ``fc.source`` branch in
``handle_apply``. Port of Go ``FixRegistry`` (``handlers_fix.go:18-36``).
"""
return {
PcState.FIX_DIAGNOSE: handle_diagnose,
PcState.FIX_PROPOSE: handle_propose,
PcState.FIX_AWAIT_APPROVAL: handle_await_approval,
PcState.FIX_APPLY: handle_apply,
PcState.FIX_AWAIT_VERIFY: handle_await_verify,
PcState.FIX_AWAIT_TESTDATA: handle_await_testdata,
PcState.FIX_VERIFY: handle_verify,
PcState.FIX_AWAIT_DECISION: handle_await_decision,
PcState.FIX_PUBLISH: handle_publish,
PcState.CHECKLIST_DIAGNOSE: handle_checklist_diagnose,
PcState.CHECKLIST_PROPOSE: handle_propose,
PcState.CHECKLIST_AWAIT_RECHECK: handle_await_recheck,
}