mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
Run workflow streaming inside the Builder worker to avoid queue deadlocks. Forward native events to shared editor callbacks and wait for graph refresh before retesting repairs. Keep command cleanup stable, preserve batched canvas events, and allow failed or unfinished historical runs to refresh again.
1379 lines
58 KiB
Python
1379 lines
58 KiB
Python
"""Build-flow handlers: pure ``(env, turn, session, fc) -> StepResult`` steps.
|
|
|
|
Mirrors ``handlers_fix.py`` for the Build entry mode (spec: docs/superpowers/
|
|
specs/2026-08-22-dify-builder-slice2-build-design.md). Cards for a state
|
|
are emitted by the handler transitioning INTO it; working states auto-advance;
|
|
the build itself rides on ``handle_plan_approval`` (approve_plan) because
|
|
``build.execution`` is a waiting state. ``build.complete`` is terminal and has
|
|
no handler -- its completion summary is emitted by the governance-tail handlers
|
|
(``handle_governance_feedback`` for automatic/disabled policies, or
|
|
``handle_await_learning`` for the ask policy).
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
|
|
from core.dify_builder.changes import describe_changed_nodes, describe_proposed_nodes
|
|
from core.dify_builder.contract import (
|
|
AssistantTurnItem,
|
|
BuildLearningCard,
|
|
ChallengeCard,
|
|
ChangeSetCard,
|
|
CheckpointCard,
|
|
DecisionItem,
|
|
ErrorCard,
|
|
ExecutionProgress,
|
|
FormCard,
|
|
NoticeItem,
|
|
PlanCard,
|
|
PublishCard,
|
|
ResourceSelectCard,
|
|
SummaryCard,
|
|
SummaryRow,
|
|
TestResultCard,
|
|
TestStat,
|
|
)
|
|
from core.dify_builder.handlers_fix import (
|
|
UNKNOWN_TEST_OUTCOME_NOTICE,
|
|
action_kind,
|
|
action_string,
|
|
append_card,
|
|
build_change_set,
|
|
build_form_fields,
|
|
emit_canvas,
|
|
first_failed_node,
|
|
is_input_failure,
|
|
launch_error_text,
|
|
merge_known_keys,
|
|
mint_checkpoint,
|
|
model_config_error_text,
|
|
perform_revert,
|
|
start_schema,
|
|
testdata_form_fields,
|
|
without_upload_values,
|
|
)
|
|
from core.dify_builder.models import (
|
|
ConversationItem,
|
|
Diagnosis,
|
|
DifyBuilderContext,
|
|
MutationIntent,
|
|
NodeEvent,
|
|
NodeOutput,
|
|
Risk,
|
|
Run,
|
|
Session,
|
|
TestInput,
|
|
Turn,
|
|
)
|
|
from core.dify_builder.progress import ProgressReporter
|
|
from core.dify_builder.runner import Env, Handler, StepResult
|
|
from core.dify_builder.state import PcState
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = [
|
|
"build_registry",
|
|
"handle_await_learning",
|
|
"handle_await_repair",
|
|
"handle_await_testdata",
|
|
"handle_capability_check",
|
|
"handle_execution",
|
|
"handle_goal_analysis",
|
|
"handle_governance_feedback",
|
|
"handle_initial_plan",
|
|
"handle_plan_approval",
|
|
"handle_publish",
|
|
"handle_resource_recommendation",
|
|
"handle_reverted",
|
|
"handle_review",
|
|
"handle_test_and_repair",
|
|
]
|
|
|
|
|
|
def _emit_completion(fc: DifyBuilderContext) -> list[ConversationItem]:
|
|
"""Shared build-complete summary, emitted on every governance-tail exit."""
|
|
rows = [
|
|
SummaryRow(label="Workflow", value="Start -> Knowledge -> LLM -> End"),
|
|
SummaryRow(label="Nodes", value=str(len(fc.built_node_ids))),
|
|
SummaryRow(label="Status", value="Complete"),
|
|
]
|
|
return append_card(fc, SummaryCard(variant="completion", title="Build complete", rows=rows))
|
|
|
|
|
|
def _refine_app_name(env: Env, fc: DifyBuilderContext) -> None:
|
|
"""Replace the prompt-derived app name with a model-written one, once (spec N1).
|
|
|
|
Creation named the app by cutting the prompt's opening clause -- mechanical,
|
|
and English-shaped where it has to judge. The goal has just been understood
|
|
here and a model is in hand, so the name can be rewritten properly, in
|
|
whatever language the user wrote. Plan approval then freezes it (spec N3).
|
|
|
|
The flag is cleared whether or not the proposal was usable, so a session
|
|
never renames twice and never retries a model that gave nothing. It is
|
|
committed with the transition while the rename commits on its own, so an
|
|
advance that fails after this point proposes again next attempt and
|
|
overwrites with an equivalent title.
|
|
"""
|
|
if not fc.app_name_auto:
|
|
return
|
|
fc.app_name_auto = False
|
|
if env.rename_app is None:
|
|
return
|
|
proposed = env.agent.propose_app_name(fc.goal_text, fc.requirements)
|
|
if proposed and proposed.strip():
|
|
stored = env.rename_app(proposed)
|
|
if stored:
|
|
fc.app_name = stored
|
|
|
|
|
|
def handle_capability_check(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) Entry state. Gates on the GOAL, not the action: a session
|
|
whose composer already carried the goal in (fc.goal_text set at creation)
|
|
needs no send_goal action and proceeds immediately. Only a goal-less
|
|
session (create-from-blank) still waits for someone to supply one via
|
|
send_goal. Either way: reset the canvas, analyze the goal into
|
|
requirements, and transition to build.goal_analysis emitting its form +
|
|
challenge cards."""
|
|
kind = action_kind(turn)
|
|
text, ok = action_string(turn, "text")
|
|
if ok and text:
|
|
fc.goal_text = text
|
|
# The composer already carried the goal in; only a goal-less session
|
|
# (create-from-blank) still needs someone to supply one.
|
|
if not fc.goal_text and kind != "send_goal":
|
|
return StepResult(next=PcState.BUILD_CAPABILITY_CHECK, context=fc)
|
|
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("build-understand-goal", "Understand the requested workflow"),
|
|
("build-prepare-requirements", "Prepare requirement fields"),
|
|
],
|
|
)
|
|
progress.activate("build-understand-goal")
|
|
emit_canvas(env, "reset_build_canvas")
|
|
analysis = env.agent.analyze_goal(fc.goal_text)
|
|
fc.form_fields = list(analysis.get("fields") or [])
|
|
fc.requirements = dict(analysis.get("values") or {})
|
|
_refine_app_name(env, fc)
|
|
|
|
progress.activate("build-prepare-requirements")
|
|
form_items = append_card(
|
|
fc,
|
|
FormCard(
|
|
variant="build_requirements",
|
|
fields=build_form_fields(fc.form_fields),
|
|
values=dict(fc.requirements),
|
|
frozen=False,
|
|
),
|
|
)
|
|
challenge_items = append_card(
|
|
fc,
|
|
ChallengeCard(
|
|
title="Proceeding with sensible defaults",
|
|
body="I filled in typical requirements; edit and submit to adjust.",
|
|
tone="warning",
|
|
),
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text="Let's clarify the requirements.",
|
|
cards=["form", "challenge"],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_GOAL_ANALYSIS,
|
|
context=fc,
|
|
items=[*form_items, *challenge_items, *turn_items],
|
|
)
|
|
|
|
|
|
def handle_goal_analysis(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) On ``submit_requirements`` merge the form payload, propose
|
|
plan v1, and go straight to resource discovery (build.resource_recommendation)
|
|
via the shared ``_discover_and_offer_resources`` helper. The decision-free
|
|
find_resources gate that used to sit at build.initial_plan (showing the
|
|
same plan a second time) is gone; that state is now reached only via the
|
|
continue_adjusting/retry_after_revert loop-back (handle_initial_plan)."""
|
|
kind = action_kind(turn)
|
|
if kind != "submit_requirements":
|
|
return StepResult(next=PcState.BUILD_GOAL_ANALYSIS, context=fc)
|
|
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("build-review-requirements", "Review confirmed requirements"),
|
|
("build-draft-plan", "Draft the workflow plan"),
|
|
],
|
|
)
|
|
progress.activate("build-review-requirements")
|
|
if turn.action is not None and isinstance(turn.action.payload, dict):
|
|
keys = [f["key"] for f in fc.form_fields if isinstance(f, dict) and f.get("key")]
|
|
fc.requirements = merge_known_keys(fc.requirements, turn.action.payload, keys)
|
|
|
|
progress.activate("build-draft-plan")
|
|
fc.plan_items = env.agent.propose_plan_v1(fc.requirements)
|
|
fc.plan_version_tag = "v1"
|
|
|
|
decision_items = append_card(fc, DecisionItem(text="Submitted requirements"))
|
|
# Pass the live reporter through: this is still the SAME operation as the
|
|
# requirements review above, and every other handler in this file uses
|
|
# exactly one ProgressReporter per step. A second reporter under the same
|
|
# env.operation_id would restart `revision` at 1, breaking the documented
|
|
# per-operation-monotonic invariant (contract.py's ProgressEventData).
|
|
resource_items, next_state = _discover_and_offer_resources(env, s, fc, progress)
|
|
return StepResult(
|
|
next=next_state,
|
|
context=fc,
|
|
items=[*decision_items, *resource_items],
|
|
)
|
|
|
|
|
|
def _discover_and_offer_resources(
|
|
env: Env, s: Session, fc: DifyBuilderContext, progress: ProgressReporter | None = None
|
|
) -> tuple[list[ConversationItem], PcState]:
|
|
"""Discover tenant resources and emit the selection card.
|
|
|
|
Shared by the straight-through path (requirements submitted -- continues
|
|
the caller's still-open ``progress`` reporter, so the whole step stays
|
|
ONE operation with monotonically increasing revisions) and the
|
|
continue_adjusting path, which re-enters at BUILD_INITIAL_PLAN with no
|
|
reporter yet (``progress=None``, so one is created here). One
|
|
implementation so the two entries cannot drift apart.
|
|
"""
|
|
steps = [
|
|
("build-discover-resources", "Find compatible resources"),
|
|
("build-prepare-resource-options", "Prepare resource recommendations"),
|
|
]
|
|
if progress is None:
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=steps,
|
|
)
|
|
else:
|
|
progress.add_steps(steps)
|
|
progress.activate("build-discover-resources")
|
|
options = env.agent.discover_resources(list(fc.plan_items))
|
|
progress.activate("build-prepare-resource-options")
|
|
rs_items = append_card(
|
|
fc,
|
|
ResourceSelectCard(
|
|
recommended=options,
|
|
),
|
|
)
|
|
if not options:
|
|
# An empty card on its own reads as a failure. Say why: nothing here
|
|
# matched, the build continues, and resources can still be added on the
|
|
# canvas. The card is still emitted so the confirm gate keeps its active
|
|
# interaction and a later pass cannot inherit a stale one.
|
|
rs_items += append_card(
|
|
fc,
|
|
NoticeItem(text="No workspace resources matched this plan — continuing without any."),
|
|
)
|
|
gap = env.agent.assess_capability_gap(list(fc.plan_items), options)
|
|
if gap:
|
|
# Distinct from the empty-resources notice above: this fires even when
|
|
# SOME resources matched but one plan step still has nothing that can
|
|
# perform it (including a tool that's installed but unauthorized --
|
|
# readiness "missing_config" doesn't cover a step either). The two
|
|
# notices are independent and can both appear.
|
|
rs_items += append_card(fc, NoticeItem(text=gap))
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text="Recommended resources.",
|
|
cards=["resource_select"],
|
|
),
|
|
)
|
|
return [*rs_items, *turn_items], PcState.BUILD_RESOURCE_RECOMMENDATION
|
|
|
|
|
|
def handle_initial_plan(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(working, auto) Discover the (canned, ready) resource and fall straight
|
|
through to build.resource_recommendation. Reachable only via the
|
|
continue_adjusting/retry_after_revert loop-back -- the straight-through
|
|
path no longer stops here (see handle_goal_analysis). Unconditional: this
|
|
is a working/pass-through state now (state.py), so the runner drives it
|
|
on entry with no action to gate on -- it must NOT re-require find_resources,
|
|
or the loop-back (which lands here with the action already consumed)
|
|
could never advance past it."""
|
|
items, next_state = _discover_and_offer_resources(env, s, fc)
|
|
return StepResult(next=next_state, context=fc, items=items)
|
|
|
|
|
|
def handle_resource_recommendation(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) On ``confirm_resources`` bind resources into plan v1 and
|
|
snapshot the pre-build graph as the restore checkpoint (self-minted id so
|
|
the CheckpointCard shown at plan_approval carries a real id -- mirrors
|
|
handle_verify's self-minted run id). Transition to build.plan_approval."""
|
|
kind = action_kind(turn)
|
|
if kind != "confirm_resources":
|
|
return StepResult(next=PcState.BUILD_RESOURCE_RECOMMENDATION, context=fc)
|
|
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("build-bind-resources", "Bind selected resources to the plan"),
|
|
("build-create-checkpoint", "Create a pre-build checkpoint"),
|
|
],
|
|
)
|
|
progress.activate("build-bind-resources")
|
|
fc.checkpoint_seq = fc.next_seq
|
|
|
|
resource_ids: list[str] = []
|
|
if turn.action is not None and isinstance(turn.action.payload, dict):
|
|
raw_ids = turn.action.payload.get("resource_ids")
|
|
if isinstance(raw_ids, list):
|
|
resource_ids = [r for r in raw_ids if isinstance(r, str)]
|
|
fc.resource_selection = {"resource_ids": resource_ids}
|
|
fc.plan_items = env.agent.bind_resources(list(fc.plan_items), resource_ids)
|
|
fc.plan_version_tag = "v1"
|
|
|
|
progress.activate("build-create-checkpoint")
|
|
graph, graph_hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
checkpoint_id = mint_checkpoint(env, s, fc, graph, graph_hash, PcState.BUILD_PLAN_APPROVAL)
|
|
|
|
decision_items = append_card(fc, DecisionItem(text="Confirmed resources"))
|
|
plan_items = append_card(fc, PlanCard(title="Build plan", version_tag="v1", items=list(fc.plan_items)))
|
|
checkpoint_items = append_card(
|
|
fc, CheckpointCard(checkpoint_id=checkpoint_id, label="Pre-build checkpoint", created_at="")
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text="Plan v1 ready for approval.",
|
|
cards=["plan", "checkpoint"],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_PLAN_APPROVAL,
|
|
context=fc,
|
|
items=[*decision_items, *plan_items, *checkpoint_items, *turn_items],
|
|
)
|
|
|
|
|
|
def handle_plan_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) THE BUILD. Only ``approve_repair`` (resolved from approve_plan)
|
|
builds: drive apply_repair once with all create_node/connect intents
|
|
(node-by-node canvas reveal via env.emit_canvas), emit the change_set +
|
|
plan v1.x + assistant_turn(with execution activities), transition to build.execution.
|
|
|
|
Idempotent by construction (final-review fix, Important #1): a loop-back
|
|
from build.review/build.reverted (continue_adjusting/revert/retry_after_
|
|
revert) returns to build.initial_plan WITHOUT resetting the already-built
|
|
graph. Re-walking find_resources -> confirm_resources -> approve_plan then
|
|
calls build_nodes() again, which always proposes the SAME fixed node ids
|
|
-- so before applying, drop any create_node/connect intent that already
|
|
exists in the current draft graph. Everything survives the first build
|
|
(nothing exists yet); a re-approve after a loop-back filters everything
|
|
out (it all already exists), so apply_repair([]) is a no-op rather than
|
|
raising on a colliding node id.
|
|
|
|
From-scratch delete-placeholder branch: when the draft has no non-start
|
|
nodes yet (nothing has been built), any start node(s) already on the
|
|
draft (e.g. the canvas's default placeholder start) are deleted before
|
|
the generator's intents are applied, so the generator's own start node
|
|
is the only one left. (Final-review fix, Minor #2): ids about to be
|
|
deleted are excluded from the already-present comparison, so a generator
|
|
create_node/connect that happens to reuse a just-deleted placeholder id
|
|
still gets applied instead of being silently dropped."""
|
|
kind = action_kind(turn)
|
|
if kind != "approve_repair":
|
|
return StepResult(next=PcState.BUILD_PLAN_APPROVAL, context=fc)
|
|
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("build-generate-graph", "Generate the workflow graph"),
|
|
("build-validate-graph", "Validate changes against the current canvas"),
|
|
("build-apply-graph", "Apply the workflow to the canvas"),
|
|
],
|
|
)
|
|
progress.activate("build-generate-graph")
|
|
emit_canvas(env, "create_checkpoint")
|
|
# Pass the user's selected resources so a chosen model resource grounds the
|
|
# built nodes (not the Builder's session model). resource_ids were already
|
|
# string-filtered when stored (handle_resource_recommendation).
|
|
selected_resource_ids = list(fc.resource_selection.get("resource_ids") or [])
|
|
build_result = env.agent.build_nodes(list(fc.plan_items), selected_resource_ids)
|
|
intents = build_result.intents
|
|
|
|
if not any(intent.op == "create_node" for intent in intents):
|
|
# Generation produced no nodes (build.build_nodes' honest-empty path when the
|
|
# generator + its one retry still fail to yield a valid graph). Do NOT delete
|
|
# the placeholder start or report a successful build -- that would empty the
|
|
# canvas while claiming "Workflow built on the canvas." Surface an honest error
|
|
# WITH the specific reason (build_result.error, e.g. UNRESOLVED_REFERENCE /
|
|
# non-object JSON / credit_balance_exhausted) so the user can act on it, and stay
|
|
# in plan_approval so they can adjust the goal/plan and re-approve. (A loop-back
|
|
# re-approve still returns the fixed create intents -- only filtered as
|
|
# already-present below -- so zero create intents means genuine failure.)
|
|
body = (
|
|
f"I couldn't generate a valid workflow graph: {build_result.error} "
|
|
"Adjust the goal or the plan and approve again to retry."
|
|
if build_result.error
|
|
else (
|
|
"I couldn't generate a valid workflow graph from this plan. "
|
|
"Adjust the goal or the plan and approve again to retry."
|
|
)
|
|
)
|
|
error_items = append_card(
|
|
fc,
|
|
ErrorCard(title="Couldn't build the workflow", body=body, diagnostics=build_result.diagnostics),
|
|
)
|
|
progress.fail_step("build-generate-graph")
|
|
execution = progress.finish(status="error")
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text=(
|
|
"I couldn't build a valid workflow graph -- see the error above. Adjust the plan and approve again."
|
|
),
|
|
cards=["error"],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_PLAN_APPROVAL,
|
|
context=fc,
|
|
items=[*error_items, *turn_items],
|
|
)
|
|
|
|
progress.activate("build-validate-graph")
|
|
current_graph, _current_hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
current_nodes = current_graph.get("nodes", [])
|
|
existing_node_ids = {n.get("id") for n in current_nodes}
|
|
existing_edges = {(e.get("source"), e.get("target")) for e in current_graph.get("edges", [])}
|
|
|
|
existing_non_start = [n for n in current_nodes if (n.get("data") or {}).get("type") != "start"]
|
|
delete_intents: list[MutationIntent] = []
|
|
if not existing_non_start: # from-scratch build: drop the draft's placeholder start(s)
|
|
delete_intents = [
|
|
MutationIntent(op="delete_node", args={"node_id": n["id"]})
|
|
for n in current_nodes
|
|
if (n.get("data") or {}).get("type") == "start" and n.get("id")
|
|
]
|
|
|
|
# M2 fix (final review, Minor/latent): ids about to be deleted must NOT
|
|
# count as "already present" for the create/connect filter below -- else
|
|
# a generator create_node/connect that reuses a just-deleted placeholder
|
|
# id (e.g. both named "start") is dropped, and the node vanishes (delete
|
|
# with no re-create). Only affects the from-scratch branch: on loop-back
|
|
# delete_intents is empty, so these sets are identical to the originals
|
|
# and behavior there is unchanged.
|
|
deleted_node_ids = {intent.args["node_id"] for intent in delete_intents}
|
|
creatable_existing_node_ids = existing_node_ids - deleted_node_ids
|
|
creatable_existing_edges = {
|
|
(src, dst) for (src, dst) in existing_edges if src not in deleted_node_ids and dst not in deleted_node_ids
|
|
}
|
|
|
|
def _already_present(intent) -> bool:
|
|
if intent.op == "create_node":
|
|
return intent.args.get("node_id") in creatable_existing_node_ids
|
|
if intent.op == "connect":
|
|
return (intent.args.get("from_node"), intent.args.get("to_node")) in creatable_existing_edges
|
|
return False
|
|
|
|
to_apply = delete_intents + [intent for intent in intents if not _already_present(intent)]
|
|
|
|
# A FIRST build of this session that would apply no node at all has not
|
|
# built anything. The draft already carries a PREVIOUS graph under the same
|
|
# generated ids -- the generator always emits node1, node2, ... -- so
|
|
# _already_present filtered every create against nodes this session never
|
|
# built, matching on id alone (an existing node2:llm swallows a planned
|
|
# node2:parameter-extractor). Reporting "graph built" here would claim a
|
|
# canvas the user never got: the empty-build false-success failure, one
|
|
# level further in than the zero-create-intents guard above.
|
|
#
|
|
# The loop-back re-approve this filter exists for is the opposite case and
|
|
# must still no-op silently -- there ``built_node_ids`` names the nodes
|
|
# this session built, so it never reaches this branch.
|
|
if not fc.built_node_ids and not any(intent.op == "create_node" for intent in to_apply):
|
|
progress.fail_step("build-validate-graph")
|
|
execution = progress.finish(status="error")
|
|
error_items = append_card(
|
|
fc,
|
|
ErrorCard(
|
|
title="Nothing was applied to the canvas",
|
|
body=(
|
|
"This app's canvas already contains nodes with the same ids as the ones this "
|
|
"plan would create, so applying it would have changed nothing and I've stopped "
|
|
"rather than report a build that did not happen. Clear the canvas (or start "
|
|
"from a new app) and approve again."
|
|
),
|
|
),
|
|
)
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text=(
|
|
"I didn't apply anything: the canvas already has nodes with these ids. "
|
|
"Clear it or start from a new app, then approve again."
|
|
),
|
|
cards=["error"],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_PLAN_APPROVAL,
|
|
context=fc,
|
|
items=[*error_items, *turn_items],
|
|
)
|
|
|
|
progress.activate("build-apply-graph")
|
|
result = env.dify.apply_repair(
|
|
s.app_id, turn.actor, to_apply, on_canvas=env.emit_canvas, expected_revision=fc.last_snapshot_hash
|
|
)
|
|
fc.last_snapshot_hash = result.new_hash
|
|
fc.last_structure_fingerprint = result.structure_fingerprint
|
|
fc.built_node_ids = [
|
|
intent.args["node_id"]
|
|
for intent in intents
|
|
if intent.op == "create_node" and isinstance(intent.args.get("node_id"), str)
|
|
]
|
|
changes, scope, fc.change_set = build_change_set(result, default_scope="structure", fallback_diff="graph built")
|
|
|
|
change_set_items = append_card(
|
|
fc,
|
|
ChangeSetCard(
|
|
count=len(changes),
|
|
changes=changes,
|
|
scope=scope,
|
|
nodes=result.nodes or describe_changed_nodes(result.changed_nodes),
|
|
),
|
|
)
|
|
# Spec N4: the app is named once here, in the card headline, and nowhere
|
|
# else in the build output -- the completion receipt never repeats it.
|
|
plan_items = append_card(
|
|
fc,
|
|
PlanCard(
|
|
title=f"{fc.app_name} is ready" if fc.app_name else "Build plan",
|
|
version_tag="v1.1",
|
|
items=list(fc.plan_items),
|
|
),
|
|
)
|
|
decision_items = append_card(fc, DecisionItem(text="Approved the plan"))
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text="Workflow built on the canvas.",
|
|
cards=["change_set", "plan"],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_EXECUTION,
|
|
context=fc,
|
|
items=[*change_set_items, *plan_items, *decision_items, *turn_items],
|
|
)
|
|
|
|
|
|
def handle_execution(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) At rest after the build. ``run_test`` -> build.await_testdata
|
|
ONLY when no test input is prepared yet AND the start schema declares a
|
|
file/file-list variable (a human has to supply an upload; nothing else
|
|
needs one) -- else the inputs are mocked inline via
|
|
``env.agent.generate_mock_inputs`` and the flow goes straight to
|
|
build.test_and_repair; ``revert`` (resolved to ``undo``) -> build.reverted:
|
|
restores the pre-build draft from the checkpoint and invalidates the
|
|
approvals made since it (via perform_revert)."""
|
|
kind = action_kind(turn)
|
|
if kind == "undo":
|
|
perform_revert(env, turn, s, fc)
|
|
items = append_card(fc, DecisionItem(text="Requested a revert"))
|
|
return StepResult(next=PcState.BUILD_REVERTED, context=fc, items=items)
|
|
if kind == "run_test":
|
|
if fc.test_input_ref == "":
|
|
graph, _hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
schema = start_schema(graph)
|
|
# Pre-fill the form with mock values instead of asking the user to
|
|
# invent them: the cost is one click, not N fields. Still SHOW
|
|
# them -- a green check produced by inputs nobody ever saw is weak
|
|
# evidence about the workflow. Upload fields stay empty because
|
|
# nothing can mock a file, so the form asks for those alone.
|
|
prefill = without_upload_values(schema, env.agent.generate_mock_inputs(schema, {}))
|
|
form_items = append_card(
|
|
fc,
|
|
FormCard(
|
|
variant="testdata",
|
|
fields=testdata_form_fields(schema),
|
|
values=prefill,
|
|
frozen=False,
|
|
),
|
|
)
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=str(uuid.uuid4()),
|
|
stage_id=str(s.current_state),
|
|
execution=ExecutionProgress(status="completed"),
|
|
reply_text="I filled in test inputs -- edit them if you like, then run the test.",
|
|
cards=["form"],
|
|
),
|
|
)
|
|
return StepResult(next=PcState.BUILD_AWAIT_TESTDATA, context=fc, items=[*form_items, *turn_items])
|
|
emit_canvas(env, "start_test_run")
|
|
items = append_card(fc, DecisionItem(text="Run tests"))
|
|
return StepResult(next=PcState.BUILD_TEST_AND_REPAIR, context=fc, items=items)
|
|
return StepResult(next=PcState.BUILD_EXECUTION, context=fc)
|
|
|
|
|
|
def handle_await_testdata(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) Prepare inputs for the live test run. mock -> schema-shaped
|
|
generate_mock_inputs; provide/upload -> the payload's inputs dict (may carry
|
|
file refs). Persists a TestInput and advances to build.test_and_repair."""
|
|
mode, _ = action_string(turn, "mode")
|
|
if mode == "mock":
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[("build-generate-test-inputs", "Generate test inputs")],
|
|
)
|
|
progress.activate("build-generate-test-inputs")
|
|
graph, _hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
inputs = env.agent.generate_mock_inputs(start_schema(graph), {})
|
|
progress.finish()
|
|
else:
|
|
inputs = {}
|
|
if turn.action is not None and isinstance(turn.action.payload.get("inputs"), dict):
|
|
inputs = turn.action.payload["inputs"]
|
|
ti = TestInput(session_id=s.id, source=mode or "upload", inputs=inputs)
|
|
env.repo.save_test_input(ti)
|
|
fc.test_input_ref = ti.id
|
|
return StepResult(next=PcState.BUILD_TEST_AND_REPAIR, context=fc)
|
|
|
|
|
|
# Node outputs are only truncated upstream at ~100,000 chars per string, so a
|
|
# report-generating workflow (the Builder's showcase case) can otherwise push
|
|
# hundreds of KB of JSON into the conversation-item row, the SSE frame, and
|
|
# the localizer walk on every successful build. The card is a preview, not a
|
|
# download -- this cap keeps it one.
|
|
_MAX_TERMINAL_OUTPUT_CHARS = 2000
|
|
_TERMINAL_OUTPUT_TRUNCATED_MARKER = "\n… (truncated)"
|
|
|
|
|
|
def _terminal_output(per_node: list[NodeOutput]) -> str:
|
|
"""The last node that produced anything, as readable JSON, or "".
|
|
|
|
Deliberately status-agnostic: node status spellings differ by source
|
|
("success" vs "succeeded"), and a status filter that silently misses is
|
|
worse than showing the output of a run that ended badly -- showing what
|
|
ran is the point of the card. Capped at ``_MAX_TERMINAL_OUTPUT_CHARS``
|
|
(see its comment) -- a big JSON blob is capped with a visible marker
|
|
rather than silently dropped.
|
|
"""
|
|
for node in reversed(per_node):
|
|
if node.outputs:
|
|
rendered = json.dumps(node.outputs, ensure_ascii=False, indent=2)
|
|
if len(rendered) > _MAX_TERMINAL_OUTPUT_CHARS:
|
|
return rendered[:_MAX_TERMINAL_OUTPUT_CHARS] + _TERMINAL_OUTPUT_TRUNCATED_MARKER
|
|
return rendered
|
|
return ""
|
|
|
|
|
|
def handle_test_and_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(working, auto) Live test: run the built draft with mock inputs. Success
|
|
-> build.review. Failure -> real diagnose + propose_repair, staged for the
|
|
build.await_repair approval gate. No auto-apply (human-gated)."""
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("build-prepare-test", "Prepare the workflow test"),
|
|
("build-run-test", "Run the workflow"),
|
|
("build-evaluate-test", "Evaluate the test result"),
|
|
],
|
|
)
|
|
progress.activate("build-prepare-test")
|
|
graph, _hash = env.dify.read_graph(s.app_id, turn.actor)
|
|
|
|
if fc.test_input_ref:
|
|
inputs = env.repo.get_test_input(fc.test_input_ref).inputs
|
|
else: # defensive: the gate normally prepares inputs first
|
|
inputs = env.agent.generate_mock_inputs(start_schema(graph), {})
|
|
ti = TestInput(session_id=s.id, source="mock", inputs=inputs)
|
|
env.repo.save_test_input(ti)
|
|
fc.test_input_ref = ti.id
|
|
|
|
progress.activate("build-run-test")
|
|
|
|
def emit(event: NodeEvent) -> None:
|
|
progress.observe_node("build-run-test", event)
|
|
|
|
try:
|
|
raw = env.dify.run_draft(s.app_id, turn.actor, inputs, emit, on_workflow_event=env.emit_workflow)
|
|
status, per_node, dify_run_id, run_error = raw.status, raw.per_node, raw.dify_run_id, ""
|
|
except Exception as exc:
|
|
# Never crash the advance; capture the launch error (log + store) instead
|
|
# of swallowing it, so diagnose/routing have something to act on.
|
|
logger.exception("dify_builder verify run failed to launch (session=%s, app=%s)", s.id, s.app_id)
|
|
status, per_node, dify_run_id, run_error = "failed", [], "", launch_error_text(exc)
|
|
|
|
if status == "succeeded":
|
|
progress.complete("build-run-test")
|
|
else:
|
|
progress.fail_step("build-run-test")
|
|
progress.activate("build-evaluate-test")
|
|
|
|
run = Run(
|
|
id=str(uuid.uuid4()),
|
|
kind="verify",
|
|
dify_run_id=dify_run_id,
|
|
status=status,
|
|
per_node=per_node,
|
|
error=run_error,
|
|
inputs_ref=fc.test_input_ref,
|
|
immutable=True,
|
|
)
|
|
|
|
if status == "succeeded":
|
|
emit_canvas(env, "mark_test_success", dify_run_id=run.dify_run_id)
|
|
test_items = append_card(
|
|
fc,
|
|
TestResultCard(
|
|
title="Test run",
|
|
subtitle="All checks passed",
|
|
tone="success",
|
|
stats=[TestStat(value="1", label="runs"), TestStat(value="0", label="errors")],
|
|
run_ids=[run.id],
|
|
dify_run_id=run.dify_run_id,
|
|
output=_terminal_output(per_node),
|
|
),
|
|
)
|
|
emit_canvas(env, "mark_review_ready")
|
|
summary_items = append_card(
|
|
fc,
|
|
SummaryCard(
|
|
variant="review",
|
|
title="Review",
|
|
items=[f"Workflow built ({len(fc.built_node_ids)} nodes)", "Tests passing"],
|
|
),
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text="Tests passed; ready for review.",
|
|
cards=["test_result", "summary"],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_REVIEW,
|
|
context=fc,
|
|
items=[*test_items, *summary_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
if status == "running":
|
|
# Stream truncated: the run's outcome is genuinely unknown, NOT a
|
|
# failure (Run.status documents "running" for exactly this case).
|
|
# Diagnosing/staging a repair here would edit the draft under a run
|
|
# that may still be executing -- surface a neutral notice instead and
|
|
# return to build.execution (re-runnable), without ever calling
|
|
# diagnose or propose_repair.
|
|
notice_items = append_card(fc, NoticeItem(text=UNKNOWN_TEST_OUTCOME_NOTICE, tone="neutral"))
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text=UNKNOWN_TEST_OUTCOME_NOTICE,
|
|
cards=["notice"],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_EXECUTION,
|
|
context=fc,
|
|
items=[*notice_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
# failure: real diagnosis + proposed repair, staged for the approval gate
|
|
run.culprit_node_id = first_failed_node(per_node)
|
|
fc.verify_run_id = run.id
|
|
emit_canvas(env, "mark_test_error", dify_run_id=run.dify_run_id)
|
|
|
|
if is_input_failure(run):
|
|
# the run failed on its INPUT, not the config -- route back to the
|
|
# testdata gate instead of the config-repair gate; clear the stale
|
|
# input ref (and the verify_run_id we just set) so a fresh
|
|
# provide_testdata cycle starts clean.
|
|
fc.test_input_ref = ""
|
|
fc.verify_run_id = ""
|
|
test_items = append_card(
|
|
fc,
|
|
TestResultCard(
|
|
title="Test run",
|
|
subtitle="Failed",
|
|
tone="error",
|
|
stats=[TestStat(value="1", label="runs"), TestStat(value="1", label="errors")],
|
|
run_ids=[run.id],
|
|
dify_run_id=run.dify_run_id,
|
|
),
|
|
)
|
|
form_items = append_card(
|
|
fc,
|
|
FormCard(
|
|
variant="testdata",
|
|
fields=testdata_form_fields(start_schema(graph)),
|
|
values={},
|
|
frozen=False,
|
|
),
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text="The run failed on its inputs — provide test data and retry.",
|
|
cards=["test_result", "form"],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_AWAIT_TESTDATA,
|
|
context=fc,
|
|
items=[*test_items, *form_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
model_error = model_config_error_text(run)
|
|
if model_error is not None:
|
|
# Model-config failure: the workflow references a model that isn't
|
|
# configured/available. This is NOT a workflow-logic bug -- diagnosing +
|
|
# repairing the graph just thrashes (adds if-else/check nodes). Surface it
|
|
# with a clear diagnosis and NO staged repair; the user configures the
|
|
# model (update_model / provider settings) and re-runs (approve at the gate).
|
|
root_cause = (
|
|
"The workflow uses a model that isn't configured or available for this workspace, so "
|
|
"it can't run. This is a model-configuration issue, not a workflow-logic problem -- "
|
|
"configure the model (or choose a different one) and re-run the test. "
|
|
f"Details: {model_error}"
|
|
)
|
|
fc.diagnosis = Diagnosis(culprit_node_id=first_failed_node(per_node), root_cause=root_cause, severity="high")
|
|
fc.staged_repair = []
|
|
fc.risk = Risk(level="high", reason="model not configured", has_external_side_effect=False)
|
|
test_items = append_card(
|
|
fc,
|
|
TestResultCard(
|
|
title="Test run",
|
|
subtitle="Failed",
|
|
tone="error",
|
|
stats=[TestStat(value="1", label="runs"), TestStat(value="1", label="errors")],
|
|
run_ids=[run.id],
|
|
dify_run_id=run.dify_run_id,
|
|
),
|
|
)
|
|
error_items = append_card(
|
|
fc,
|
|
ErrorCard(
|
|
title="Model not configured",
|
|
body=root_cause,
|
|
tone="danger",
|
|
node_id=fc.diagnosis.culprit_node_id,
|
|
),
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text=(
|
|
"The test failed because the workflow's model isn't configured. Configure it "
|
|
"(or change the model), then re-run — this isn't a workflow-logic issue."
|
|
),
|
|
cards=["test_result", "error"],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_AWAIT_REPAIR,
|
|
context=fc,
|
|
items=[*test_items, *error_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
# config failure: existing diagnose + propose_repair -> BUILD_AWAIT_REPAIR
|
|
progress.add_steps(
|
|
[
|
|
("build-diagnose-failure", "Diagnose the failed workflow"),
|
|
("build-prepare-repair", "Prepare a safe repair"),
|
|
]
|
|
)
|
|
progress.activate("build-diagnose-failure")
|
|
diagnosis = env.agent.diagnose(run, graph, per_node)
|
|
_note_repair_error(fc, run)
|
|
if _repair_is_repeating(fc):
|
|
# The same failure has now survived _MAX_REPEATED_REPAIRS repairs, so
|
|
# another round would aim at the same wrong thing. Stop spending runs
|
|
# and hand the decision back (ESQ1-285 burned nine approvals this way,
|
|
# ESQ1-290 six). Clearing staged_repair also engages the empty-repair
|
|
# guard above, so "Apply the fix" cannot be approved into a no-op.
|
|
fc.diagnosis = diagnosis
|
|
fc.staged_repair = []
|
|
progress.finish()
|
|
stuck_items = append_card(
|
|
fc,
|
|
ErrorCard(
|
|
title="Repeated failure",
|
|
body=(
|
|
f"{diagnosis.root_cause or 'The run failed.'}\n\n"
|
|
"The same error survived the last repairs, so I've stopped retrying. "
|
|
"Edit the node directly and test again, or revert."
|
|
),
|
|
tone="danger",
|
|
node_id=diagnosis.culprit_node_id,
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_AWAIT_REPAIR,
|
|
context=fc,
|
|
items=stuck_items,
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
progress.activate("build-prepare-repair")
|
|
intents, risk = env.agent.propose_repair(diagnosis, graph)
|
|
fc.diagnosis = diagnosis
|
|
fc.staged_repair = list(intents)
|
|
fc.risk = risk
|
|
test_items = append_card(
|
|
fc,
|
|
TestResultCard(
|
|
title="Test run",
|
|
subtitle="Failed",
|
|
tone="error",
|
|
stats=[TestStat(value="1", label="runs"), TestStat(value="1", label="errors")],
|
|
run_ids=[run.id],
|
|
dify_run_id=run.dify_run_id,
|
|
),
|
|
)
|
|
error_items = append_card(
|
|
fc,
|
|
ErrorCard(
|
|
title="Test failed",
|
|
body=diagnosis.root_cause or "The run failed.",
|
|
tone="danger",
|
|
node_id=diagnosis.culprit_node_id,
|
|
),
|
|
)
|
|
proposed = [f"{i.op} {i.args.get('node_id', '')}".strip() for i in intents]
|
|
cs_items = (
|
|
append_card(
|
|
fc,
|
|
ChangeSetCard(
|
|
count=len(intents),
|
|
changes=proposed,
|
|
scope="configuration",
|
|
nodes=describe_proposed_nodes(intents, graph),
|
|
),
|
|
)
|
|
if intents
|
|
else []
|
|
)
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text=(
|
|
"Test failed — here's a proposed fix to review."
|
|
if intents
|
|
else "Test failed — no safe automatic fix; edit or keep draft."
|
|
),
|
|
cards=["test_result", "error"] + (["change_set"] if intents else []),
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_AWAIT_REPAIR,
|
|
context=fc,
|
|
items=[*test_items, *error_items, *cs_items, *turn_items],
|
|
run=run,
|
|
run_id_sink=[run.id],
|
|
)
|
|
|
|
|
|
_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) -> 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``.
|
|
|
|
Takes the ``Run`` rather than a message so the key comes from the engine
|
|
(see ``_failure_signature``) and not from anything an LLM wrote.
|
|
"""
|
|
signature = _failure_signature(run)
|
|
if signature and signature == fc.last_repair_error:
|
|
fc.repair_attempts += 1
|
|
else:
|
|
fc.repair_attempts = 0
|
|
fc.last_repair_error = signature
|
|
|
|
|
|
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 handle_await_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) Post-failure gate mirroring fix.await_decision. approve_repair
|
|
applies the staged repair and waits at build.execution. The client resumes
|
|
testing only after applying the committed graph to its canvas;
|
|
keep_draft -> build.review; undo -> build.reverted. apply_repair runs ONLY
|
|
here, only on approve."""
|
|
kind = action_kind(turn)
|
|
if kind == "approve_repair":
|
|
if not fc.staged_repair:
|
|
# Nothing staged: applying would change nothing and the retest
|
|
# would fail identically. Don't spend a run on it (ESQ1-291).
|
|
items = append_card(
|
|
fc,
|
|
NoticeItem(text="No fix is staged for this failure -- keep the draft or revert."),
|
|
)
|
|
return StepResult(next=PcState.BUILD_AWAIT_REPAIR, context=fc, items=items)
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[
|
|
("build-apply-repair", "Apply the approved repair"),
|
|
("build-prepare-retest", "Prepare to retest the workflow"),
|
|
],
|
|
)
|
|
progress.activate("build-apply-repair")
|
|
try:
|
|
result = env.dify.apply_repair(
|
|
s.app_id,
|
|
turn.actor,
|
|
list(fc.staged_repair),
|
|
on_canvas=env.emit_canvas,
|
|
expected_revision=fc.last_snapshot_hash,
|
|
)
|
|
except ValueError as exc:
|
|
# The repair was validated against the graph as it stood at
|
|
# propose time; apply_repair re-validates against the draft as it
|
|
# is NOW, so a node deleted or a path removed in between makes an
|
|
# intent stale. A bad intent must not kill the session (ESQ1-271)
|
|
# -- degrade to the no-safe-fix surface and let the user decide.
|
|
logger.warning("Dify Builder: staged repair no longer applies for app %s: %s", s.app_id, exc)
|
|
fc.staged_repair = []
|
|
progress.finish()
|
|
items = append_card(
|
|
fc,
|
|
ErrorCard(
|
|
title="Couldn't apply the fix",
|
|
body=f"The proposed fix no longer applies to the current draft: {exc}",
|
|
tone="danger",
|
|
),
|
|
)
|
|
return StepResult(next=PcState.BUILD_AWAIT_REPAIR, context=fc, items=items)
|
|
fc.last_snapshot_hash = result.new_hash
|
|
fc.last_structure_fingerprint = result.structure_fingerprint
|
|
fc.staged_repair = []
|
|
changes, scope, fc.change_set = build_change_set(result, default_scope="configuration", fallback_diff="repair")
|
|
cs_items = append_card(
|
|
fc,
|
|
ChangeSetCard(
|
|
count=len(changes),
|
|
changes=changes,
|
|
scope=scope,
|
|
nodes=result.nodes or describe_changed_nodes(result.changed_nodes),
|
|
),
|
|
)
|
|
progress.activate("build-prepare-retest")
|
|
decision_items = append_card(fc, DecisionItem(text="Applied the fix; ready to retest"))
|
|
progress.finish()
|
|
return StepResult(next=PcState.BUILD_EXECUTION, context=fc, items=[*cs_items, *decision_items])
|
|
if kind == "keep_draft":
|
|
items = append_card(fc, DecisionItem(text="Kept the draft despite the failure"))
|
|
return StepResult(next=PcState.BUILD_REVIEW, context=fc, items=items)
|
|
if kind == "undo":
|
|
perform_revert(env, turn, s, fc)
|
|
items = append_card(fc, DecisionItem(text="Requested a revert"))
|
|
return StepResult(next=PcState.BUILD_REVERTED, context=fc, items=items)
|
|
return StepResult(next=PcState.BUILD_AWAIT_REPAIR, context=fc)
|
|
|
|
|
|
def handle_review(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) Terminal decision. publish_workflow -> build.publish;
|
|
keep_draft -> build.governance_feedback (skips publish); continue_adjusting
|
|
(resolved to re_fix) -> build.initial_plan (re-plan); revert (undo) ->
|
|
build.reverted: restores the pre-build draft from the checkpoint and
|
|
invalidates the approvals made since it (via perform_revert)."""
|
|
kind = action_kind(turn)
|
|
if kind == "publish_workflow":
|
|
items = append_card(fc, DecisionItem(text="Chose to publish"))
|
|
return StepResult(next=PcState.BUILD_PUBLISH, context=fc, items=items)
|
|
if kind == "keep_draft":
|
|
emit_canvas(env, "cancel_publish")
|
|
items = append_card(fc, DecisionItem(text="Kept the draft"))
|
|
return StepResult(next=PcState.BUILD_GOVERNANCE_FEEDBACK, context=fc, items=items)
|
|
if kind == "re_fix": # continue_adjusting
|
|
emit_canvas(env, "cancel_publish")
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[("build-revise-plan", "Revise the workflow plan")],
|
|
)
|
|
progress.activate("build-revise-plan")
|
|
# Draft the plan, but do NOT show it yet: build.initial_plan (next)
|
|
# runs resource discovery unconditionally and falls straight through
|
|
# to build.resource_recommendation, which shows the ONE plan card for
|
|
# this pass (v1, resources bound) -- exactly the duplicate-card
|
|
# removal task 2 already did for the straight-through path. Showing
|
|
# it here too would mean two "Build plan" v1 cards in one pass.
|
|
fc.plan_items = env.agent.propose_plan_v1(fc.requirements)
|
|
fc.plan_version_tag = "v1"
|
|
fc.test_input_ref = ""
|
|
fc.verify_run_id = ""
|
|
fc.repair_attempts = 0
|
|
fc.last_repair_error = ""
|
|
decision_items = append_card(fc, DecisionItem(text="Continue adjusting"))
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text="Revised plan.",
|
|
cards=[],
|
|
),
|
|
)
|
|
return StepResult(
|
|
next=PcState.BUILD_INITIAL_PLAN,
|
|
context=fc,
|
|
items=[*decision_items, *turn_items],
|
|
)
|
|
if kind == "undo": # revert
|
|
perform_revert(env, turn, s, fc)
|
|
items = append_card(fc, DecisionItem(text="Requested a revert"))
|
|
return StepResult(next=PcState.BUILD_REVERTED, context=fc, items=items)
|
|
return StepResult(next=PcState.BUILD_REVIEW, context=fc)
|
|
|
|
|
|
def handle_publish(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(working, auto) Publish the built workflow, emit the PublishCard, and
|
|
auto-advance to build.governance_feedback."""
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[("build-publish-workflow", "Publish the new workflow")],
|
|
)
|
|
progress.activate("build-publish-workflow")
|
|
env.dify.publish(s.app_id, turn.actor)
|
|
emit_canvas(env, "publish_workflow")
|
|
items = append_card(fc, PublishCard(version="1.0", badge="live"))
|
|
progress.finish()
|
|
return StepResult(next=PcState.BUILD_GOVERNANCE_FEEDBACK, context=fc, items=items)
|
|
|
|
|
|
def handle_governance_feedback(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(working, auto) Governance tail: apply the skill-learning policy.
|
|
automatic -> learn + accepted card; disabled -> skipped card; ask ->
|
|
emit the pending prompt and rest at build.await_learning. Reached via both
|
|
publish and keep_draft (scenario-neutral)."""
|
|
policy = fc.skill_learning_policy or "ask"
|
|
if policy == "automatic":
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[("build-capture-learning", "Capture reusable build guidance")],
|
|
)
|
|
progress.activate("build-capture-learning")
|
|
descriptor = env.agent.learn_from_build(
|
|
fc.goal_text, dict(fc.requirements), list(fc.plan_items), list(fc.built_node_ids)
|
|
)
|
|
items = append_card(fc, BuildLearningCard(policy="automatic", state="accepted"))
|
|
items += append_card(fc, NoticeItem(text=descriptor))
|
|
items += _emit_completion(fc)
|
|
progress.finish()
|
|
return StepResult(next=PcState.BUILD_COMPLETE, context=fc, items=items)
|
|
if policy == "disabled":
|
|
items = append_card(fc, BuildLearningCard(policy="disabled", state="skipped"))
|
|
items += _emit_completion(fc)
|
|
return StepResult(next=PcState.BUILD_COMPLETE, context=fc, items=items)
|
|
# ask (default): prompt, then rest for the user's accept/skip.
|
|
items = append_card(fc, BuildLearningCard(policy="ask", state="pending"))
|
|
return StepResult(next=PcState.BUILD_AWAIT_LEARNING, context=fc, items=items)
|
|
|
|
|
|
def handle_await_learning(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) Resolve the ask-policy skill-learning prompt. accept_learning
|
|
-> learn + accepted decision; anything else (skip_learning / absent) ->
|
|
skipped. Either way emit the completion summary and reach build.complete."""
|
|
kind = action_kind(turn)
|
|
if kind == "accept_learning":
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[("build-capture-learning", "Capture reusable build guidance")],
|
|
)
|
|
progress.activate("build-capture-learning")
|
|
descriptor = env.agent.learn_from_build(
|
|
fc.goal_text, dict(fc.requirements), list(fc.plan_items), list(fc.built_node_ids)
|
|
)
|
|
items = append_card(fc, DecisionItem(text="Accepted skill learning"))
|
|
items += append_card(fc, NoticeItem(text=descriptor))
|
|
progress.finish()
|
|
else:
|
|
items = append_card(fc, DecisionItem(text="Skipped skill learning"))
|
|
items += _emit_completion(fc)
|
|
return StepResult(next=PcState.BUILD_COMPLETE, context=fc, items=items)
|
|
|
|
|
|
def handle_reverted(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
|
|
"""(waiting) After a revert. ``retry_after_revert`` (resolved to re_fix)
|
|
re-proposes plan v1 and returns to build.initial_plan."""
|
|
kind = action_kind(turn)
|
|
if kind != "re_fix":
|
|
return StepResult(next=PcState.BUILD_REVERTED, context=fc)
|
|
progress = ProgressReporter.for_session(
|
|
emit=env.emit_progress,
|
|
operation_id=env.operation_id,
|
|
session=s,
|
|
stage_id=str(s.current_state),
|
|
steps=[("build-restart-plan", "Rebuild the workflow plan")],
|
|
)
|
|
progress.activate("build-restart-plan")
|
|
# Draft the plan, but do NOT show it yet -- same duplicate-card removal
|
|
# as handle_review's re_fix branch: build.initial_plan (next) falls
|
|
# straight through to build.resource_recommendation, which shows the ONE
|
|
# plan card for this pass (v1, resources bound).
|
|
fc.plan_items = env.agent.propose_plan_v1(fc.requirements)
|
|
fc.plan_version_tag = "v1"
|
|
fc.test_input_ref = ""
|
|
fc.verify_run_id = ""
|
|
fc.repair_attempts = 0
|
|
fc.last_repair_error = ""
|
|
execution = progress.finish()
|
|
turn_items = append_card(
|
|
fc,
|
|
AssistantTurnItem(
|
|
turn_id=progress.operation_id,
|
|
stage_id=str(s.current_state),
|
|
execution=execution,
|
|
reply_text="Restarting the plan.",
|
|
cards=[],
|
|
),
|
|
)
|
|
return StepResult(next=PcState.BUILD_INITIAL_PLAN, context=fc, items=list(turn_items))
|
|
|
|
|
|
def build_registry() -> dict[PcState, Handler]:
|
|
"""The Build handler table. Grows across Slice 2 tasks; ``build.complete``
|
|
is terminal and intentionally absent (the loop returns before lookup)."""
|
|
return {
|
|
PcState.BUILD_CAPABILITY_CHECK: handle_capability_check,
|
|
PcState.BUILD_GOAL_ANALYSIS: handle_goal_analysis,
|
|
PcState.BUILD_INITIAL_PLAN: handle_initial_plan,
|
|
PcState.BUILD_RESOURCE_RECOMMENDATION: handle_resource_recommendation,
|
|
PcState.BUILD_PLAN_APPROVAL: handle_plan_approval,
|
|
PcState.BUILD_EXECUTION: handle_execution,
|
|
PcState.BUILD_AWAIT_TESTDATA: handle_await_testdata,
|
|
PcState.BUILD_TEST_AND_REPAIR: handle_test_and_repair,
|
|
PcState.BUILD_AWAIT_REPAIR: handle_await_repair,
|
|
PcState.BUILD_REVIEW: handle_review,
|
|
PcState.BUILD_PUBLISH: handle_publish,
|
|
PcState.BUILD_GOVERNANCE_FEEDBACK: handle_governance_feedback,
|
|
PcState.BUILD_AWAIT_LEARNING: handle_await_learning,
|
|
PcState.BUILD_REVERTED: handle_reverted,
|
|
}
|