mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
1862 lines
76 KiB
Python
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,
|
|
}
|