Commit Graph
3967 Commits
Author SHA1 Message Date
Charles YaoandClaude Opus 5 fc0f5cf8fd test(dify-builder): real resource shapes and placeholder-endpoint coverage
- bind_resources fixtures use the production model label, the full
  provider/model id (langgenius/tokener/tokener/deepseek-v4-flash), and the
  real Code Interpreter id (code/simple_code); the docstring's ESQ1-302 quote
  now shows that shape too.
- The run_test prefill uses without_endpoint_values instead of re-evaluating
  endpoint_variable_names(graph) for every key.
- _ground_placeholder_endpoints: two placeholder http-request nodes get two
  start variables, and a start variable that already exists is not duplicated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 03:02:35 -07:00
Charles YaoandClaude Opus 5 f826cb8227 fix(dify-builder): "use mock data" leaves the endpoint for the form
The Build, Edit and Fix mock call sites passed generate_mock_inputs through
unfiltered, so an endpoint variable got a mocked URL. The engine accepts any
URL at launch; the http-request node then fails on connection, which
is_input_failure reads as a config failure, so the flow fed the config-repair
loop (the ESQ1-302 thrash). without_endpoint_values drops the endpoint keys at
every mock call site: the missing required key now fails the launch as
"<var> is required in input form", which routes back to the test-data gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:59:53 -07:00
Charles YaoandClaude Opus 5 291651b747 fix(dify-builder): the test form asks for an endpoint instead of mocking it
A placeholder endpoint is re-pointed to a required start variable, but
the test-data form pre-filled every text field with a mocked value, so
one click ran against another invented URL. Start variables an
http-request node reads its URL from now stay empty, like upload fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:16:20 -07:00
Charles YaoandClaude Opus 5 9d7acf4089 fix(workflow-generator): log every branch edge the repair re-homes
The inline repair the shared graph_normalizers replaced logged each
re-homed edge at info; the delegated version returns (and logs) only
the edges it could NOT re-home, so a successful re-home -- the planner's
"else" moved onto "false", a default-handle edge moved onto a class --
left no trace in the generator logs.

_repair_branch_edge_handles now snapshots each edge's sourceHandle (by
edge object) before the shared repair and logs every edge whose handle
changed (source -> target, old -> new) at info; the unresolved warnings
are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:35:43 -07:00
Charles YaoandClaude Opus 5 7d400fdca8 fix(workflow): keep a failure-arm name off ELSE on fail-branch branch nodes
Two gaps in the fail-closed branch-handle repair:

- The failure-arm aliases lacked fail_branch / failbranch / error_branch
  / on_fail / fallback. A planner's "fail_branch" edge on a fail-branch
  http-request stayed unresolved (only the hyphenated spelling matched
  the declared handle exactly). They are looked up through _canon_name
  like every other name, so any spacing / hyphen / underscore spelling
  hits.
- On an if-else / question-classifier / human-input node with
  error_strategy fail-branch and that arm unwired, the fan-out rule
  eliminated an unrecognized name onto the one free branch arm (ELSE, a
  class, an action) -- even when the edge meant the failure arm. Rule 2
  no longer runs for such a node while its fail-branch arm is unwired;
  the edge is left for a validator instead. Once fail-branch is wired
  the rule applies as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:33:09 -07:00
Charles YaoandClaude Opus 5 920011be82 fix(workflow): call condition value normalizers with one signature
_normalize_conditions called its value normalizer as (value, operator)
and, on any TypeError, retried it as (value) -- the fallback that let
the one-argument list-operator normalizer through. It also swallowed a
genuine TypeError raised inside a normalizer, replacing it with an
unrelated "missing argument" error, and needed two type: ignores.

normalize_filter_condition_value now takes (value, operator="") and
ignores the operator (a FilterCondition scalar is never list-wrapped),
so both normalizers share Callable[[Any, str], Any]; the walker calls
it once, with no try/except and no type: ignore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:31:17 -07:00
Charles YaoandClaude Opus 5 6b6f50d412 fix(workflow-generator): say what the branch-handle repair does now
The _repair_branch_edge_handles docstring claimed an ambiguous edge is
left "so _validate_structure fails the graph closed" -- true only while
the INVALID_BRANCH_HANDLE check (its own, separately revertable commit)
is in. It now says the edge is left as it was, logged, and returned by
the shared function so a validator can fail closed when that check is
enabled. The comment above the call in _postprocess_graph still
described the old repair (if-else / question-classifier, default
handle only); it now names every branch type the shared repair covers
and the named-but-wrong handles it re-homes, fail-closed.

Comments only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:28:05 -07:00
Charles YaoandClaude Opus 5 a154d4b894 fix(workflow): declare "true" on a legacy if-else with no cases
graphon runs an if-else whose `cases` is absent or None (the legacy
top-level `conditions` shape) as one case with id "true"
(IfElseNodeData.iter_cases; if_else_node routes "true" when
uses_legacy_shape), and the canvas migrates it to case_id "true".
declared_branch_handles declared only "false" for it, so the engine's
own IF arm looked undeclared: apply_connect refused a connect on "true",
and with only "true" wired the fan-out rule re-homed the IF arm onto
ELSE in the generator postprocess.

The case ids now mirror iter_cases (legacy -> ["true"]; an explicit
`cases: []` stays case-less), and the one-case IF-alias rule counts the
legacy shape as one case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:26:54 -07:00
Charles YaoandClaude Opus 5 2692243792 feat(workflow-generator): bind branch-node ids to the planner's handles
The planner names a branch's outgoing source_handle values (rule 12) in
one LLM call; the node builder chooses the case / class / action ids in
another, and nothing told it the planner had already chosen. ESQ1-303's
if-else was built with case "true" against planned edges on
"score_equals_60" / "else", so both arms were skipped.

The builder already receives the planned edges with their handles in
"Normalized plan and topology". The if-else, question-classifier and
human-input snippets now state the contract: declared ids MUST be those
planned handles; the ELSE arm is implicit ("false") and is never a case.
human-input also gets a matching clause for its implicit timeout arm
("__timeout"), which must never be declared as a user action. Shared
with cmd+K /create and /refine; the postprocess repair stays as a
fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:01:18 -07:00
Charles YaoandClaude Opus 5 b2b306b3d0 feat(workflow-generator): reject undeclared branch handles and bad http bodies
_validate_structure fails closed on two defects that reached the canvas
silently: an edge leaving a branch node on a handle postprocess could
not re-home unambiguously (INVALID_BRANCH_HANDLE -- the arm would never
run while the run reports "succeeded", ESQ1-303), and a json / raw-text
/ binary http-request body with a number of items other than one
(INVALID_HTTP_BODY -- the executor rejects it at run time, ESQ1-302, and
no automatic collapse of several key/value items is correct).

cmd+K /create and /refine see these as structured errors with the node
id, like every other structural error; the Builder retries once with the
error fed back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:52:26 -07:00
Charles YaoandClaude Opus 5 1b0b7e77f2 fix(workflow-generator): apply the shared graph normalizers in postprocess
_postprocess_graph now runs core.workflow.graph_normalizers: condition
values written as JSON numbers become strings, if-else varType is
derived from a declared start-variable type, http-request body items
get the type BodyData cannot default, and _repair_branch_edge_handles
delegates to the shared fail-closed repair (so a named-but-wrong handle
such as "else" is re-homed, not only the default "source").

Shared with cmd+K /create and /refine: every change turns a graph the
engine would have refused into one it accepts, and none alters a graph
the engine already accepted. Nothing is rejected here.

Validated on the real ESQ1-303 dev draft: value "60", varType "number",
handles true/false, edge ids derived from the repaired handles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:18:17 -07:00
Charles YaoandClaude Opus 5 8adc33ea2f fix(workflow): keep branch-handle repair fail-closed for success, error and timeout arms
A missing sourceHandle is "source" to the engine, so a fail-branch node's
unkeyed success edges are valid and must not be re-homed. The error and
timeout arms exist because of the node's configuration, not because the
author wired a branch, so elimination and default assignment never guess
onto them; named edges reach them through explicit aliases instead.
Ambiguous names (duplicate class names, an alias that disagrees with a
stem match) now fail closed, the one-case IF aliases count case ids
rather than handles, and name keys fold _ - and spaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:06:52 -07:00
Charles YaoandClaude Opus 5 36409bd14e feat(workflow): fail-closed branch-handle repair and declared-handle check
The planner names a branch's outgoing source_handle values in one LLM
call; the node builder chooses the case / class / action ids in another.
ESQ1-303's edges left its if-else on "score_equals_60" and "else"
against a case named "true", so the engine skipped both arms and the
run succeeded with nothing.

declared_branch_handles lists what a node routes on; repair_branch_edge_
handles re-homes an edge only when it is forced (exact / alias / name /
stem match, one arm fanning out under a single invented name, default-
source edges onto unused handles in order) and returns everything
ambiguous untouched; undeclared_branch_handles reports what is left so a
validator can reject it. A wrong guess would silently swap IF and ELSE.
Aliases are trusted as names -- the documented limit -- which is why the
node-builder handle contract, not this repair, is the primary fix.

A fail-branch node's success path stays on "source" (what graphon's
NodeRunResult.edge_source_handle actually emits by default), not an
invented "success" handle nothing ever routes on; a fail-branch
branch-type node (if-else / question-classifier / human-input) just
gains the extra "fail-branch" arm. human-input also always declares its
implicit "__timeout" arm, since every human-input node has a timeout
config and the FE always renders that handle; that arm is excluded from
the "unused declared handles" pool the fan-out/default-order repair
rules count against, since it is routinely left unwired by design and
must not block or absorb repair of the arms the author did configure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:47:24 -07:00
Charles YaoandClaude Opus 5 967e3b3347 fix(workflow): an empty string http body has no item
graphon turns a falsy body.data into an empty list before the executor
counts items, so a json / raw-text / binary body whose data is "" fails
at run time. http_request_body_errors counted any string as one item;
only a non-empty string is one now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:36:34 -07:00
Charles YaoandClaude Opus 5 115b47c066 feat(workflow): http-request body item type default and item-count check
graphon's BodyData.type (Literal["file","text"]) has no default and the
shared builder prompt only ever shows an empty "none" body, so the LLM
writes {key, value} items and the graph dies at Graph.init (ESQ1-302).
normalize_http_request_bodies fills the type (file when the item carries
a file selector, else text) and empties a "none" body.

The executor sends json / raw-text / binary bodies as ONE rendered
template. Two {key, value} items under a json body have no correct
automatic collapse -- quoting breaks a JSON-valued variable (ESQ1-302's
node3.text is JSON), not quoting breaks plain text -- so
http_request_body_errors reports the node instead of guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:31:16 -07:00
Charles YaoandClaude Opus 5 82c11a5747 feat(workflow): derive if-else varType from a declared start-variable type
varType is a frontend-only hint the canvas uses to pick the operator
list; the runtime ignores it. Builder-made if-else nodes never carried it
(ESQ1-303), which steers the panel to string operators on a number and
reproduces ESQ1-285's runtime error. Derive it only when a start
variable's declared type makes it certain; never overwrite, never guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:23:12 -07:00
Charles YaoandClaude Opus 5 e1b595ee22 fix(workflow): list-operator filter values follow FilterCondition, not Condition
list-operator's filter conditions are FilterCondition (value: str |
Sequence[str] | bool, default ""), and filtering an array of strings or
numbers requires a plain string. The normalizer treated them like
if-else's Condition: it wrapped an in/not-in scalar into a list and left
None alone, both of which graphon rejects. They now get their own rule:
None becomes "", numbers become text, nothing is wrapped. List items use
the same integral-float rule as scalars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:17:43 -07:00
Charles YaoandClaude Opus 5 b00453949e feat(workflow): pure normalizer for LLM-written condition values
graphon's Condition.value is str | Sequence[str] | bool | None. The node
builder wrote "value": 60 for the ESQ1-303 draft; Graph.init builds every
node up front, so that one number stopped the whole workflow. pydantic
also coerces int 1 / 0 to True / False, so bool must be checked before
int.

core/workflow/graph_normalizers.py is the one place both the shared
generator's postprocess and the Builder's write chokepoint will call, so
the two paths cannot disagree. This commit adds the module with the
condition-value normalizer only (if-else cases, sub-variable conditions,
loop break conditions, list-operator filters); nothing calls it yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:07:55 -07:00
Charles YaoandClaude Opus 5 05f255619d chore(dify-builder): export note_unknown_outcome from handlers_fix
Build and Edit import it from handlers_fix, but __all__ left it out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:01:01 -07:00
Charles YaoandClaude Opus 5 3dea847521 fix(dify-builder): a re-fix from review starts a fresh unknown-outcome count
Build's and Edit's re_fix branches (handle_review's continue_adjusting,
handle_reverted's retry_after_revert) reset repair_attempts and
last_repair_error but not unknown_outcome_count. After the cap (count=2)
-> keep_draft -> review -> re_fix, the new cycle's FIRST unknown outcome
re-capped with "couldn't be determined twice in a row". Reset it at all
four sites, as Fix's re_fix already does.

Also pins that Edit's unknown-outcome cap drops a stale diagnosis and
staged repair (seeded, so the assertion is not vacuous).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:59:20 -07:00
Charles YaoandClaude Opus 5 b7017aa363 fix(dify-builder): tell a draft that would not start apart from a stale fix
apply_repair's preflight raises PreflightError when the graph it is about
to write would fail at Graph.init. Every handler caught it as a plain
ValueError, so the five apply sites could not tell it apart from a
graph_ops rejection:

- plan approval (Build, Edit) called ANY ValueError "The workflow can't
  start", including "node not found: x", sending the user hunting for a
  broken node that does not exist;
- the repair-apply gates (Build/Edit await_repair, Fix apply) called a
  preflight rejection "The proposed fix no longer applies to the current
  draft" -- the fix applied fine; it would leave an unstartable draft.

Add core DraftWouldNotStartError(ValueError) and make PreflightError
subclass it (services imports core; core still imports no services).
Each site now catches it before the ValueError branch, with the same
control flow and the true reason: plan approval keeps "The workflow
can't start" for it and says "Couldn't apply the workflow" otherwise;
the repair gates say "The workflow can't start" for it and keep
"Couldn't apply the fix" otherwise. The shared card/turn construction is
factored (_graph_not_applied, _change_not_applied, and
drop_unapplied_repair in handlers_fix) and the new literals are
catalogued in strings.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:57:33 -07:00
Charles YaoandClaude Opus 5 6d85677006 fix(dify-builder): reset Edit's repair breaker when the user keeps adjusting
Build resets the breaker counter when the user re-enters the repair
loop from review or after a revert; Edit's equivalent branches did not,
so a count left over from an earlier cycle could trip the breaker one
round early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:32:52 -07:00
Charles YaoandClaude Opus 5 b9ee13f0a9 fix(dify-builder): give Edit the repair breaker Build has
Edit's affected-path test diagnosed and staged a repair for the same
engine failure with no counter at all, so the loop 6877532f8e closed for
Build stayed open for Edit. The breaker (engine-keyed signature,
consecutive-repeat counter, MAX_REPEATED_REPAIRS) moves to handlers_fix
under public names, Build keeps its underscore names as aliases, and
Edit stops at its gate on the third identical failure. A new edit goal
resets the counter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:25:00 -07:00
Charles YaoandClaude Opus 5 ccb217d72f fix(dify-builder): a re-fix starts a fresh unknown-outcome count
Fix's re_fix action resets the rest of the repair loop's state but kept
unknown_outcome_count, so after the cap tripped the first unknown
outcome of the new repair cycle re-capped immediately instead of being
re-runnable once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:18:28 -07:00
Charles YaoandClaude Opus 5 dd11872222 fix(dify-builder): stop re-running after two consecutive unknown outcomes
The unknown-outcome branch (a stream that ended with neither a terminal
nor an error frame) returned the user to the re-run state with no
limit in all three flows. With error frames now reported as failures
that branch is rare, but a stream that keeps ending early could still be
re-run forever. DifyBuilderContext.unknown_outcome_count counts
consecutive unknown outcomes (reset by any determinate one); the second
in a row stops at the gate with a card telling the user to look at the
run itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:10:10 -07:00
Charles YaoandClaude Opus 5 a8e89d4583 fix(dify-builder): flag only a branch that routed nowhere as no output
The first cut flagged any succeeded run where a branch node ran and no
End did, which also caught a legitimate side-effect-only arm that ran
to completion, and treated any fail-branch node as a router. The rule
is now the ESQ1-303 mechanism itself: a branch node ran and none of its
edge targets ran (the engine skipped every arm), in a graph that has an
End that was never reached. The card points at that branch node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:59:37 -07:00
Charles YaoandClaude Opus 5 7712442959 fix(dify-builder): a run that took a branch and reached no End is not a pass
ESQ1-303: both edges of a Builder-made if-else used handles the node
does not declare, so the engine skipped both arms, reached no End node,
and reported "succeeded" with outputs={}. Every verify handler took any
"succeeded" as "All checks passed".

A succeeded run that executed a branch node (if-else, question-
classifier, human-input, or a fail-branch node) and no End node is now
"Finished without output": an error-toned test card plus a card pointing
at the branch node, waiting at the gate with no staged repair -- the
engine reported no error, so there is nothing to diagnose. The rule is
deliberately that narrow: requiring an End node for every succeeded run
would false-fail side-effect-only branches, which the structural
validator does not forbid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:46:12 -07:00
Charles YaoandClaude Opus 5 a02c0fe3a8 fix(dify-builder): revert the canvas when plan approval is rejected
apply_repair streams a canvas marker per applied intent before its
preflight runs, so a rejected build left the client with markers for
mutations that were never written. The plan-approval catch now emits
revert_checkpoint: nothing was written and the draft is still the
checkpoint taken at approval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:37:52 -07:00
Charles YaoandClaude Opus 5 181c5e37c3 fix(dify-builder): show a draft that would not start as a card, not a crash
apply_repair now rejects a graph Graph.init would refuse (PreflightError).
Build's and Edit's plan approval called apply_repair with no guard, so
the rejection would have escaped the handler and left the operation
interrupted mid-step. Both now catch it: the step is marked failed, an
error card names the node and the reason, nothing has been written, and
the plan stays at its gate -- re-approving regenerates the graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:29:35 -07:00
Charles YaoandClaude Opus 5 f970c857c6 refactor(workflow): one validation sequence for create_node and validate_node_config
validate_node_config promises to raise exactly when Graph.init would.
That only holds while it and DifyNodeFactory.create_node run the same
validation steps, so both now call one helper instead of two copies.
No behavior or error-text change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:15:35 -07:00
Charles YaoandClaude Opus 5 2fb51857ce fix(workflow): name the node in every node-config error
A graph-init failure surfaced as "2 validation errors for
HttpRequestNodeData ..." -- pydantic names the model class, never the
node. graphon logs the node id and re-raises the raw error, so the error
frame the Builder received (ESQ1-302) could not say which node was
wrong, and its repair agent had to guess.

DifyNodeFactory.create_node now re-raises every ValueError from the
validation stage (shared-shape validation, class resolution, concrete
NodeData re-validation) prefixed "node '<id>' (<type>): ". This runs for
EVERY workflow start, so the error text of every graph-init failure
changes, not only the Builder's. The wrapped error is a plain ValueError,
so app_generator's handling takes its ValueError branch (log only under
DEBUG) instead of the ValidationError branch; the published error is the
same.

validate_node_config runs the same three validation steps with no
factory instance and no runtime state -- the dry Graph.init a draft can
be put through before it is written or run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:07:44 -07:00
Charles YaoandClaude Opus 5 87f00a8308 fix(dify-builder): keep Run.error in every verify handler
run_draft populates Run.error, and all three consumers threw it away:
Build and Edit assigned run_error = "" and Fix's Run(...) literal had no
error= at all. A launch failure therefore persisted with an empty error
(the long-open "TRUNCATED_STREAM_ERROR not persisted, err_len=0" item),
diagnose saw nothing, and the repair breaker -- which keys a launch
failure on run.error -- could never fire on one.

Fix's handle_verify also gains the try/except Build and Edit already
have, so a run_draft that raises degrades to a failed Run carrying the
launch error instead of killing the advance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:58:27 -07:00
Charles YaoandClaude Opus 5 d067b95bce fix(dify-builder): map an error frame to a failed Run with its reason
The streaming pipeline reports a run that threw as one ``event: error``
frame with top-level message/code and no data (ESQ1-302: Graph.init
rejected a node before workflow_started, so there is no run id either).
run_mapping had no representation for it: _TERMINAL_STREAM_EVENTS lists
only workflow_finished/paused, so the frame was neither terminal nor a
node event and the run came back "running" with the truncated-stream
notice.

error_from_stream_chunk reads such a frame; map_error_frame_run turns it
into a FAILED Run carrying the message (whitespace collapsed, so it is a
stable repair-breaker signature) and the code. map_run_result now keeps
the engine's run-level error text for a failed run instead of dropping
it -- a launch failure has no per-node rows, so that text is the only
evidence diagnose and the breaker have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:45:26 -07:00
twwu d9991a11e1 Merge branch 'main' into feat/dify-builder 2026-09-21 15:23:36 +08:00
twwu d5ca5ea8ef fix(dify-builder): stream native test runs after canvas refresh
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.
2026-09-21 15:12:22 +08:00
Charles YaoandClaude Opus 5 e2633ad6a0 fix(dify-builder): stop reporting a build that applied nothing
Starting a NEW Build session on an app whose draft already holds a previous
build's graph silently changed nothing and still reported "graph built".

_already_present matches a create_node by node id ALONE, and the generator
always emits node1, node2, ... So a new plan whose node2 is a
parameter-extractor was filtered out against an existing node2 that is an
llm: to_apply became empty, apply_repair([]) no-oped, change_set fell back
to "graph built", and the session advanced to build.execution over an
untouched canvas. Observed live -- the snapshot taken before apply was
byte-identical to the draft after it, and the subsequent test run "passed"
because it ran the OLD graph.

The id filter itself is deliberate and stays: it is what makes a loop-back
re-approve (continue_adjusting / revert) idempotent instead of raising on a
colliding id. The guard therefore keys on built_node_ids, which names what
THIS session built -- empty only on a session's first build, which is
exactly the broken case. A loop-back still no-ops silently and advances.

Does NOT clear the canvas on the user's behalf: that would destroy an
existing workflow. It reports honestly and stays in plan_approval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 23:50:32 -07:00
Charles YaoandClaude Opus 5 6877532f8e fix(dify-builder): key the repeated-repair guard on the engine, not LLM prose
_repair_is_repeating could never fire in production. _note_repair_error
counted repeats by exact string equality on diagnosis.root_cause, which is
LLM prose regenerated on every diagnosis call. Observed live across four
turns of one session: same failure, reworded every time, one turn switching
to Chinese. Equality therefore never held, repair_attempts reset forever,
and the guard was unreachable -- that session burned 9 failed verify runs
and 8 repair approvals with the guard in place.

The call site's own comment reads "ESQ1-285 burned nine approvals this way,
ESQ1-290 six". The reproduction burned nine again.

Key on the ENGINE's output instead: _failure_signature(run) returns the
first failed node's id plus its error text (whitespace-collapsed), falling
back to the launch error, and "" when the run identifies nothing -- so two
unknown failures never compare equal.

Why the existing tests passed while production was broken: every one of them
fed _note_repair_error the SAME literal string twice, and StubAgent.diagnose
always returns one fixed root_cause. They encoded the premise that is false
in production. The new handler-level test drives three turns with an agent
that rewords its root_cause each call while the engine fails identically,
and fails against the old implementation with repair_attempts == 0.

last_repair_error now holds that signature rather than prose. It was never
displayed -- its only use was this comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 23:45:48 -07:00
Sourav Rajvi f9129bc500 fix(api): grant in-run tool files to later workflow nodes (#41300) 2026-09-21 05:39:04 +00:00
盐粒 Yanli 2a66bf6f09 refactor(workflow): prepare inputs by entry node type 🤖🤖🤖 (#42595) 2026-09-21 05:26:23 +00:00
Charles YaoandClaude Opus 5 9a910f27e7 fix(dify-builder): close the repair-gate holes in Build, Edit and Fix
Four findings from the whole-branch review, all in the repair loop:

I1 -- repair_attempts/last_repair_error were added to the context and to
serde but not to recovery._reset_working_fields (which clears eleven sibling
fields) nor to the re_fix/restart re-plan branches, so a fresh build could
inherit repair_attempts >= the ceiling and decline a repair on its FIRST
failure. They are cleared with their siblings now. The guard also ANDed a
monotonic counter with a consecutive-error test, so A -> B -> B tripped it
although B had survived one repair. _note_repair_error now makes the counter
mean what the docstring says: consecutive repeats of ONE error, reset when
the error changes.

I2 -- the repeated-failure return dropped run/run_id_sink, so repo.save_run
never ran while fc.verify_run_id already pointed at that run: a dangling
pointer and the third failing run lost from the debug export.

I3 -- ESQ1-291's empty-staged-repair guard existed in Build only. Edit's
model-config branch GUARANTEES an empty staged repair and routes to a state
whose approve applied it blindly; handlers_fix.handle_apply had the same
shape. Both are guarded now, reusing Build's notice.

I5 -- apply_repair re-validates intents against the draft as it is NOW, so a
dotted path that was applicable at propose time can raise ValueError at
approve time. Uncaught at all three approve sites, it reached the runner and
drove the session to FAILED -- the exact ESQ1-271 outcome this branch set out
to remove. Each site now degrades to a "Couldn't apply the fix" error card
naming the reason and clears the staged repair (engaging the I3 guard).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:10:28 -07:00
Charles YaoandClaude Opus 5 917425a81b fix(workflow-generator): guard TOOL output-schema shape in _declares_variable
Round-1 review defect: the TOOL branch added for ESQ1-296/ESQ1-289 assumed
output_schema was always a dict once truthy. A non-dict output_schema (or a
non-dict `properties` inside it) raised AttributeError instead of failing
open. That crash is new -- the old branch never read output_schema at all --
and it happens outside the _run_stage try/except and outside the
controller's narrow exception allowlist, so it would take down the SSE
generator mid-stream for both cmd+K and the Builder.

Reachable via refine mode: _hydrate_tool_nodes only stamps a schema when it
finds a matching catalogue entry, so a hand-edited or legacy graph can carry
forward an output_schema of any shape.

Fix: shape-check before every .get() -- output_schema must be a dict and its
properties must be a dict, or the branch returns True.

SHARED WITH CMD+K: fails open whenever the schema is absent, empty, or an
unreadable shape, so a tool we cannot describe behaves exactly as before.
Only provably-wrong references, from a schema we can actually read, are
rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles YaoandClaude Opus 5 00b9629800 fix(workflow-generator): validate tool-node output references
_declares_variable returned True unconditionally for TOOL nodes, so any
invented downstream reference survived generation. ESQ1-296 and ESQ1-289
are the same three lines: code/simple_code is a TOOL node, so the real
code-node validation directly above never saw it. They differ only in
consumer -- End tolerates the missing variable and yields a silent null,
if-else fails at run time and feeds the repair loop.

Tool outputs are not opaque: tool_node.py fixes text/files/json and the
generator already copies the provider's output_schema onto the node.
Validate against both, and alias the invented `result` to `text` so the
common mistake self-heals.

SHARED WITH CMD+K: fails open when no output_schema is present, so a tool
we cannot describe behaves exactly as before. Only provably-wrong
references are rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles YaoandClaude Opus 5 38ef6178a6 fix(dify-builder): refuse to apply a repair that stages no intents
handle_await_repair offered "Apply the fix" unconditionally, so an empty
staged_repair produced approve -> apply nothing -> retest -> same failure
-> offer again, with no LLM in the loop. ESQ1-291's trace shows five such
approvals, every change set {count: 0, changes: []}.

Adds repair_attempts / last_repair_error to the context (both defaulted,
so persisted contexts deserialize unchanged) and _repair_is_repeating for
the follow-on gate. An empty staged repair is now declined with a decision
the user can act on instead of a run that cannot help.

_repair_is_repeating is also wired in at the point a repair is OFFERED
(the config-failure path in handle_test_and_repair, right after
diagnose()): a third identical failure clears staged_repair and surfaces
a "Repeated failure" card instead of proposing a fourth repair
(ESQ1-285/290 burned nine and six approvals this way).

Also catalogs the two new user-facing literals in core/dify_builder/
strings.py so test_catalog_covers_handler_literals keeps passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:25 -07:00
Charles YaoandClaude Opus 5 1b9d301faf fix(dify-builder): reject non-string node ids and paths instead of failing the session
An LLM-supplied int node_id reached diff_graphs, which drops falsy and
non-string ids from its node maps, so result.changes came back empty and
build_change_set's fallback joined a list containing an int. The
TypeError took the whole session to `failed` (ESQ1-271) after the
mutation had already been committed to the draft.

validate_intent_args now rejects a non-string or empty node id, so
filter_applicable turns it into a rejection reason the agent can act on.
build_change_set str()s defensively -- that join must never be what kills
a session.

Also extends the same guard to set_node_config's `path` arg: a non-string
path used to reach _resolve_path's str.split() and raise AttributeError,
surfacing "'int' object has no attribute 'split'" to the repair agent
instead of an actionable rejection reason. Same boundary, same defect
shape, deliberately included here rather than deferred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:25 -07:00
Wu Tianwei bf8333bc77 fix(builder): avoid layout conflicts and serialize draft updates (#42601) 2026-09-21 09:48:02 +08:00
Charles YaoandClaude Opus 5 3838db8aa7 feat(dify-builder): pre-fill the test-data form instead of hiding the mock
Mocking the inputs removed the work of inventing test data, but it also
removed the user's sight of what was actually tested. A green check
produced by {"topic": "test"} is weak evidence about a workflow, and an
invisible mock is a quieter version of the same false confidence a
passing-but-unverified test gives.

So keep the gate and pre-fill it: the form arrives with the mocked values
already in it, unfrozen, so the common case is one click rather than N
fields to type, and anyone who cares can edit before running. The cost
item 5 removed was PRODUCING the data, not confirming it.

Upload fields stay empty and still ask, because nothing can mock a file --
and the mock generator falls back to a plain string for types it does not
know, so it would otherwise pre-fill a file field with "test". New
``upload_variable_names`` and ``without_upload_values`` strip those values;
``needs_upload_inputs`` is re-expressed on top of the first rather than
duplicating the type check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:40:30 -07:00
Charles YaoandClaude Opus 5 96f98b5804 feat(dify-builder): persist the Dify run id on the test-result card
The run id already reaches a client over SSE, which is enough to open the
run on the canvas while the page is up. It is not enough afterwards: SSE
frames do not survive a reload, and the persisted card's ``run_ids`` holds
BUILDER run ids -- uuid4s the engine mints, which resolve to nothing
outside it. So on reopening a session the card was the only record of the
result, and the canvas view was unreachable.

Put ``dify_run_id`` on TestResultCard, beside ``run_ids``, wired at all
nine construction sites across the Build, Edit and Fix flows. Now the
canvas is reachable from the durable conversation too, which is what makes
the card's ``output`` genuinely the client's choice to render or skip
rather than the only surviving copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:31:34 -07:00
Charles YaoandClaude Opus 5 a3c2e7fca5 feat(dify-builder): tell the canvas which run to show
Streaming the test run gives the user a live progress bar but no results:
NodeEvent carries node_id/title/status/error and nothing else, so a client
watching the stream learns which node is running, never what it produced.
The results already exist -- the run is a normal InvokeFrom.DEBUGGER draft
run, its node executions are in the database, and the editor's canvas
already knows how to render exactly that view. It was simply never told
which run to open.

So put the Dify run id where a client can reach it:

- NodeEvent gains run_id, filled from the streaming frame. Every non-ping
  frame is stamped with workflow_run_id, so this arrives with the FIRST
  node event -- while the run is still going, which is the point.
- The node SSE frame publishes it alongside the existing fields.
- mark_test_success / mark_test_error carry dify_run_id in both the Build
  and Edit flows, so the terminal state names its run too.

This is what makes per-node results land on the graph, where a node's
inputs and outputs already have a home, instead of only as a JSON blob on
a conversation card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:16:41 -07:00
Charles YaoandClaude Opus 5 69ccf2ad15 fix(dify-builder): wire resource readiness into recommender/gap prompts
Three minor whole-branch-review fixups:

- ResourceOption.readiness ("ready"/"missing_config") was computed but never
  shown to the model: discover_resources's recommender prompt listed ids and
  labels only, so it could recommend an unauthorized tool; _GAP_SYSTEM showed
  readiness in the listing (assess_capability_gap) but never defined what
  missing_config means. Both prompts now show readiness and state that
  missing_config is installed-but-not-authorized, i.e. not usable -- pinned
  with tests asserting the literal "missing_config" in the constructed
  prompts.
- strings.py: dropped three orphaned catalogue entries ("Here's the initial
  plan.", "Ask each time", "Prefer audited") -- the conflict-policy concept
  they backed was removed end to end; nothing in the handlers emits them.
- core/workflow/generator/tool_catalogue.py: provider.need_credentials was
  read OUTSIDE the per-provider try/except that already guards
  get_tools()/tool parsing. This module is shared with the cmd+K /create and
  /refine flows, so a provider whose property raised would break those too,
  not just the Builder. Moved the read inside the try/except so a bad
  provider is skipped like any other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:41:52 -07:00
Charles YaoandClaude Opus 5 7ae152c5c2 fix(dify-builder): cap the test-result card's rendered output
_terminal_output json.dumps'd a node's outputs with no length limit. Node
outputs are only truncated upstream at ~100,000 chars per string, so a
report-generating workflow -- the Builder's showcase case -- could push
hundreds of KB of JSON into a dify_builder_conversation_items row, the SSE
frame, and the localizer walk on every successful build.

Cap the rendered output at 2000 chars with a visible truncation marker
before it reaches TestResultCard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:37:45 -07:00