Commit Graph
13858 Commits
Author SHA1 Message Date
Charles YaoandClaude Opus 5 e54a2cbd2a fix(dify-builder): ground only credentials and host endpoints, never data inputs
The test form and mock data skipped every start variable an http node
read, so a search query or a language header was never mocked. Only a
host-position URL template, credential-keyed header/param lines and an
api-key auth now count, with one shared is_credential_key in core. A
bare "key" param, an empty credential and a bare "Bearer" are grounded
too, and an existing Dify template is never re-grounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:43:00 -07:00
Charles YaoandClaude Opus 5 e741ce902b fix(dify-builder): a credential the user didn't supply becomes an input
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:22:46 -07:00
Charles YaoandClaude Opus 5 ea46a69040 fix(dify-builder): an endpoint the user didn't supply becomes an input
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:07:02 -07:00
Charles YaoandClaude Opus 5 a73c443ecd fix(dify-builder): scrub only real credential keys and exact URL hosts
Keys are credentials by their last segment (api_key, access_token,
Authorization), not by containing "token" or "password", so
password_policy / token_limit defaults survive. URL hosts stop at the
first non-URL character and drop trailing punctuation, so a goal that
names a host at the end of a sentence or next to CJK text is recognised.
Placeholder tokens like <…> only count under a credential key or an
auth scheme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:52:37 -07:00
Charles YaoandClaude Opus 5 be24b00a2a fix(dify-builder): requirements analysis stops inventing endpoints and credentials
S5b (local E2E 2026-09-22, F2): analyze_goal's "a sensible default value per
field" led the LLM to prefill render_api_url=https://api.yourcompany.com/...
and Authorization: Bearer YOUR_API_KEY for a goal that named neither -- caught
only because "yourcompany" happened to match an existing placeholder-URL
regex; a plausible invented host would slip through and recreate ESQ1-302's
launch failure one step upstream, in the requirements form itself.

Adds services/dify_builder/agent/user_supplied.py (pure, no I/O): url_hosts,
is_user_supplied_url, CREDENTIAL_PLACEHOLDER_RE, is_user_supplied_secret,
trusted_text_for -- shared primitives for telling a literal the user actually
wrote from one an LLM invented, used by this task and consumed by later
tasks in the same plan.

build.analyze_goal now tells the model never to invent such a field's
default, and _scrub_invented_defaults deterministically blanks any top-level
field whose value contains a URL host, or a credential-shaped or
credential-keyed literal, not present in the goal text -- decided on hosts
and literal text, never on LLM prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:40:33 -07:00
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 21aff66f45 fix(dify-builder): bind a tool by its id and pin it in the generator
The planner is told to name a tool by its id, but binding matched only the
label and then the "tool" keyword, so two tool steps naming ids both bound
to the first tool step. _covering_step now tries the resource id before the
label. A tool's "(using ...)" suffix also carries its provider/tool id in
ASCII brackets: the shared generator pins a tool only on that identifier
(tool_catalogue._find_explicit_tool_keys), never on the label. The planner
prompt asks for the id as a separate word, since a CJK-glued id never pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:56:46 -07:00
Charles YaoandClaude Opus 5 b1ff7c1596 fix(dify-builder): match resource labels and node types as whole words
"tool" matched "toolkit" and "llm" matched "fulfillment", binding a
resource to an unrelated step instead of falling back. Labels and
keywords now match case-insensitively on ASCII-alphanumeric boundaries,
which still matches CJK step text such as "llm节点".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:35:16 -07:00
Charles YaoandClaude Opus 5 763f93ebc9 fix(dify-builder): re-binding resources replaces the old suffixes
A loop-back feeds the already-bound plan back into bind_resources,
which only appended: a deselected resource kept its "(using …)" and
suffixes piled up in the text handed to the generator. Trailing
"(using …)" groups are stripped before binding, so a re-bind reflects
exactly the current selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:33:46 -07:00
Charles YaoandClaude Opus 5 681f92557d fix(dify-builder): bind each resource to the plan step it covers
bind_resources appended every confirmed resource's label to the LAST
plan step: the ESQ1-302 end-node step read "(using deepseek-v4-flash,
Code Interpreter)" while the step that needed a tool said nothing, and
that is the text the generator was handed. Each resource now lands on
the first step naming its label, else the first step naming its kind's
node type (llm / knowledge-retrieval / tool); the last step is only the
fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:22:32 -07:00
Charles YaoandClaude Opus 5 da338dd5fd fix(dify-builder): only a localhost host marks an endpoint as a placeholder
_PLACEHOLDER_URL_RE matched "localhost" as a substring anywhere in the
URL, so a real tunnel host like abc123.localhost.run was wrongly
flagged; the check now parses the URL and only matches when the host
itself is localhost or ends with .localhost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:16:28 -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 e4a30b7be1 feat(dify-builder): a placeholder endpoint becomes a required start variable
The ESQ1-302 draft called https://api.example.com/ppt/generate. The
model had no endpoint, so it invented one, and every test run failed
against a host that does not exist. build_nodes now re-points an
http-request whose URL is empty or a placeholder (example.com,
your-api, <your-domain>, {tenant}, localhost) at a new REQUIRED
start-node variable "<node>_url", so the test-data gate asks the user
for the real endpoint -- or, when they have none, says what the step
needs. A Dify template reference is not a placeholder.

Builder-only: the shared http-request snippet's own example is
https://example.com, and cmd+K keeps its placeholders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:00:19 -07:00
Charles YaoandClaude Opus 5 6bd32a733e feat(dify-builder): show the planner the tenant's ready tools
propose_plan_v1 was shown REQUIREMENTS only. It cannot plan a tool step
for a tool it has never seen, so the ESQ1-302 plan said "http-request
节点:调用外部PPT生成API" while the tenant had a ready Markdown -> PPTX
tool -- and every later stage (resource recommendation, the shared
generator, the directive) took the plan's word for it.

ready_tool_catalogue lists the tenant's READY tools (an installed-but-
unauthorized tool cannot cover a step), sorted and capped at 40; the
planner prompt gains an "Installed tools you may name" section and tells
the planner to plan an http-request only for an endpoint the user
actually supplied. A listing failure degrades to the old no-catalogue
prompt, never a crashed advance. The positional signature is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:52:01 -07:00
Charles YaoandClaude Opus 5 493d40591a fix(dify-builder): the directive no longer steers away from installed tools
fe3f8154b5 told the generator to use a tool node "only when the plan
names an installed tool; otherwise use 'http-request'". That contradicts
the shared planner prompt (INSTALLED-TOOL-FIRST), and the Builder's own
planner was never shown the tool catalogue, so it could not name one:
every third-party step fell through to an http-request with an invented
endpoint (ESQ1-302 called https://api.example.com/ppt/generate while
the tenant had a ready Markdown -> PPTX tool).

The rule now says tool-first, forbids invented and placeholder
endpoints outright, and keeps the half that earned its place (ESQ1-286):
an http-request's changing inputs belong on the start node.

Builder-only: nothing under core/workflow/generator/ changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:45:05 -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 9218d1583e fix(dify-builder): record intents a first build drops on a partial reject
build_nodes dry-runs the generator's intents through filter_applicable
and recorded the rejections only when NOTHING applied. A partial reject
-- e.g. a connect on a branch handle the node does not declare, one the
generator postprocess could not re-home and apply_connect now refuses --
kept the applicable intents and dropped the rest with no log line and no
diagnostic, leaving that arm unwired on the canvas without a trace.

A partial reject now logs a warning naming each dropped op, its args
(minus a create_node's config body) and the reason, and appends a
build_nodes diagnostic with the same list; the applicable intents are
still returned. The total-reject path is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:29:43 -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 197cd9c5ad fix(dify-builder): teach Edit branch handles and retry a partial reject
apply_connect now rejects a connect from a branch node on a handle it
does not declare. Edit's prompt never mentioned source_handle and its
graph view showed no handles, so such connects defaulted to "source"
and a partial reject was dropped without a retry. The op schema and
graph view now carry the handles, and any rejection gets one corrective
re-prompt whose feedback names the declared handles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:43:43 -07:00
Charles YaoandClaude Opus 5 cf99766d93 fix(dify-builder): count nodes the chokepoint normalizers healed as changed
apply_repair's value normalizers can heal a node the repair's intents
never touched; the diff showed it but changed_nodes did not. Both
normalizers' changed ids now join changed_nodes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:43:35 -07:00
Charles YaoandClaude Opus 5 d299a81a9f fix(dify-builder): normalize values at the write chokepoint; reject undeclared handles
apply_repair runs the same graph_normalizers the generator's postprocess
runs, for the intents the generator never saw: a Fix/Edit set_node_config
that writes a condition value as a JSON number (ESQ1-285's flip-flop) or
an http body item without type is healed before the preflight would
reject it.

apply_connect rejects an edge from a branch node on a handle the node
does not declare -- including the default "source" it used to fill in
-- with the same ValueError the handlers already surface as "Couldn't
apply the fix". Such an edge hangs off nothing and its arm never runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:27:44 -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 fcb7c719ef feat(dify-builder): preflight the draft in apply_repair before writing it
ESQ1-302 and ESQ1-303 both died at Graph.init -- before workflow_started,
so the Builder saw an error frame with no run id and no rows, and spent
runs and approvals discovering what a validation pass could have said
up front. apply_repair now runs that pass (preflight_errors: the same
node-data validation create_node performs, with no factory or runtime
state) over the graph it is about to write and raises PreflightError,
naming each node Graph.init would reject.

Only NEW problems count: a pre-existing broken node the repair does not
touch must not veto an unrelated fix, and a repair that heals it passes.
PreflightError is a ValueError so the handlers' existing stale-intent
guards already surface it as a card.

The real ESQ1-303 dev draft is the fixture: preflight names node2 and
its numeric value, and passes once the value is a string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:21:17 -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