feat(dify-builder): carry generation diagnostics into the exported debug log

api pods lose their logs on restart, so a failed build's real cause
("Workflow generator: structural validation failed: ...") could only be read
from a pod log that may already be gone. Session 01a08fd0 hit exactly that.

Carry the generator's own diagnostics back with the result instead:
BuildNodesResult and ErrorCard gain a ``diagnostics`` list, and build_nodes
records one entry per failed generation attempt -- initial AND the corrective
retry, so an error that changes between attempts stays visible -- plus the
all-rejected and exception paths.

Each entry holds a server-side UTC ``at`` (so the surviving pod-log window can
be searched around it), ``source``, ``attempt``, the generator's ``message``
(the same text the server logs), its ``codes``, and the full structured
``errors`` including ``node_id``. That is strictly more than the log line
carries: for an UNRESOLVED_REFERENCE the export now names the offending node.

The list rides in the ErrorCard's item payload, which is what the streamed
conversation item -- and therefore the exported debug log -- preserves. It is
not rendered in the UI. Retries that eventually succeed keep their breadcrumbs.

Deliberately not mirroring the "structural validation failed" prefix: build.py
cannot tell structural failures from the rest without duplicating the
generator's code taxonomy, which would go stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Charles YaoandClaude Opus 5 committed 2026-09-17 15:16:10 -07:00
1 parent 77924d3470
commit cc9ca8b1dd
6 files changed
+158 -5

No files matched your search

+4
View File
@@ -588,6 +588,10 @@ class ErrorCard(_Card):
body: str
tone: str = "danger"
node_id: str | None = None
# Machine-readable failure breadcrumbs (server timestamp + generator codes /
# node ids). Not rendered: it rides along in the item payload so the exported
# debug log retains what would otherwise only exist in a pod log.
diagnostics: list[dict[str, Any]] = field(default_factory=list)
@dataclass
+1 -1
View File
@@ -395,7 +395,7 @@ def handle_plan_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex
)
error_items = append_card(
fc,
ErrorCard(title="Couldn't build the workflow", body=body),
ErrorCard(title="Couldn't build the workflow", body=body, diagnostics=build_result.diagnostics),
)
progress.fail_step("build-generate-graph")
execution = progress.finish(status="error")
+5
View File
@@ -225,6 +225,11 @@ class BuildNodesResult:
intents: list[MutationIntent] = field(default_factory=list)
error: str = ""
# Structured, timestamped breadcrumbs for the exported debug log. Server logs
# are lost when the api pod restarts, so each failed generation attempt travels
# back here (server UTC ``at``, generator ``code``/``node_id``/``detail``) and is
# stamped onto the ErrorCard -> conversation item -> exported trace.
diagnostics: list[dict[str, Any]] = field(default_factory=list)
@dataclass(kw_only=True)
+46 -4
View File
@@ -7,6 +7,7 @@ rather than crashing the advance. build_nodes lives in the same module
import logging
from collections.abc import Callable, Sequence
from datetime import UTC, datetime
from typing import Any
from core.app.app_config.entities import ModelConfig
@@ -256,6 +257,31 @@ def _generation_error_text(result: dict[str, Any]) -> str:
return "the generator returned no usable graph"
def _diagnostic(**fields: Any) -> dict[str, Any]:
"""One debug-log breadcrumb, stamped with the server's UTC time.
``at`` is the correlation key: pod logs are lost on restart, but the exported
debug log keeps this timestamp so the surviving log window can be searched
around it (and matched to the same failure the server logged).
"""
return {"at": datetime.now(UTC).isoformat(), **fields}
def _generation_diagnostic(result: dict[str, Any], *, attempt: int) -> dict[str, Any]:
"""Capture one failed generation attempt: the generator's structured errors
(``code`` / ``detail`` / ``node_id`` -- e.g. UNRESOLVED_REFERENCE on node2),
which is strictly more than the server-side log line carries."""
errors = result.get("errors")
errors = [e for e in errors if isinstance(e, dict)] if isinstance(errors, list) else []
return _diagnostic(
source="workflow-generator",
attempt=attempt,
message=_generation_error_text(result),
codes=[str(e.get("code")) for e in errors if e.get("code")],
errors=errors,
)
def build_nodes(
tenant_id: str,
model_config: dict[str, Any],
@@ -275,9 +301,11 @@ def build_nodes(
current_graph=None,
)
diagnostics: list[dict[str, Any]] = []
result = _generate(base_instruction)
graph = result.get("graph") or {}
if result.get("error") or not graph.get("nodes"):
diagnostics.append(_generation_diagnostic(result, attempt=1))
# The generator's own retry only covers invalid-JSON / bad-schema, NOT a
# structurally-valid graph that fails topology validation (e.g. no 'end'
# node). Retry ONCE with the specific error fed back as a corrective nudge.
@@ -285,6 +313,7 @@ def build_nodes(
result = _generate(_terminal_retry_instruction(base_instruction, retry_error))
graph = result.get("graph") or {}
if result.get("error") or not graph.get("nodes"):
diagnostics.append(_generation_diagnostic(result, attempt=len(diagnostics) + 1))
error = _generation_error_text(result)
logger.warning(
"Dify Builder: build_nodes produced no graph for tenant %s (%d plan items): error=%s",
@@ -292,7 +321,7 @@ def build_nodes(
len(plan_items),
error,
)
return BuildNodesResult(intents=[], error=error)
return BuildNodesResult(intents=[], error=error, diagnostics=diagnostics)
intents = graph_translate.to_intents(graph)
# Ground node model blocks to the user-SELECTED model (resource confirmation),
# falling back to the generation/session model when none was selected. The
@@ -305,8 +334,16 @@ def build_nodes(
reason = rejected[0][1] if rejected else "no applicable node intents"
error = f"the generated nodes were rejected by validation: {reason}"
logger.warning("Dify Builder: build_nodes rejected all intents for tenant %s: %s", tenant_id, error)
return BuildNodesResult(intents=[], error=error)
return BuildNodesResult(intents=applicable)
diagnostics.append(
_diagnostic(
source="build_nodes",
message=error,
rejected=[{"reason": str(r), "intent": str(i.op)} for i, r in rejected],
)
)
return BuildNodesResult(intents=[], error=error, diagnostics=diagnostics)
# Retries that eventually succeeded still leave their breadcrumbs behind.
return BuildNodesResult(intents=applicable, diagnostics=diagnostics)
except Exception as exc: # any generation/translation failure -> honest empty build
logger.exception(
"Dify Builder: build_nodes generation failed for tenant %s (%d plan items); returning empty build",
@@ -315,7 +352,12 @@ def build_nodes(
)
# Surface the exception text (e.g. a provider error like credit_balance_exhausted)
# so the user sees WHY, not a hardcoded 'couldn't build' message.
return BuildNodesResult(intents=[], error=str(exc).strip() or type(exc).__name__)
message = str(exc).strip() or type(exc).__name__
return BuildNodesResult(
intents=[],
error=message,
diagnostics=[_diagnostic(source="build_nodes", message=message, exception=type(exc).__name__)],
)
def _ground(intents: list[MutationIntent], mc: ModelConfig, tenant_id: str, plan_items: list[str]) -> None:
@@ -284,6 +284,44 @@ def test_plan_approval_empty_build_surfaces_error_and_keeps_canvas():
assert assistant.payload["reply_text"] != "Workflow built on the canvas." # no false success claim
def test_plan_approval_error_card_carries_diagnostics_into_the_item_payload():
"""Pod logs vanish on restart, so the generator's structured diagnostics must
ride in the ErrorCard's item payload -- that payload is what the streamed
conversation item (and therefore the exported debug log) preserves."""
from core.dify_builder.handlers_build import handle_plan_approval
from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort
env, _ = _new_env()
env.dify = FakeBuildDifyPort()
env.dify.graph = {
"nodes": [{"id": "start", "data": {"type": "start", "title": "Old", "variables": []}}],
"edges": [],
}
diag = [
{
"at": "2026-09-17T20:22:45.123456+00:00",
"source": "workflow-generator",
"attempt": 1,
"message": "Reference {#node2.response#} not declared on node 'node2'",
"codes": ["UNRESOLVED_REFERENCE"],
"errors": [{"code": "UNRESOLVED_REFERENCE", "detail": "...", "node_id": "node2"}],
}
]
env.agent.build_nodes = lambda _plan, _rids=None: BuildNodesResult(
intents=[], error="Reference {#node2.response#} not declared on node 'node2'", diagnostics=diag
)
s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_PLAN_APPROVAL)
fc = DifyBuilderContext(plan_items=["x"])
res = handle_plan_approval(env, Turn(actor=_actor(), action=Action(kind="approve_repair")), s, fc)
error = next(i for i in res.items if i.kind == "error")
payload_diag = error.payload["diagnostics"]
assert payload_diag == diag # verbatim: server timestamp, codes and node ids all survive
assert payload_diag[0]["errors"][0]["node_id"] == "node2" # the offending node is identifiable
assert payload_diag[0]["at"].endswith("+00:00") # UTC, so pod logs can be searched around it
def test_plan_approval_deletes_pre_existing_start_on_from_scratch_build():
from core.dify_builder.handlers_build import handle_plan_approval
from core.dify_builder.models import MutationIntent
@@ -1,4 +1,5 @@
import logging
from datetime import datetime
from unittest.mock import patch
from services.dify_builder.agent import build, resources
@@ -378,3 +379,66 @@ def test_build_nodes_error_reason_joins_errors_list_when_no_top_level_error():
assert result.intents == []
assert "top-level value was a list" in result.error
def test_build_nodes_records_structured_diagnostics_for_the_debug_export():
"""Server logs are lost when the api pod restarts, so the generator's own
diagnostic ("structural validation failed") must travel back with the result:
structured code/detail/node_id plus a server timestamp, so the exported debug
log carries it and can be correlated with pod logs."""
failing = {
"graph": {"nodes": [], "edges": []},
"error": "UNRESOLVED_REFERENCE: Reference {#node2.response#} not declared on node 'node2'",
"errors": [
{
"code": "UNRESOLVED_REFERENCE",
"detail": "Reference {#node2.response#} not declared on node 'node2'",
"node_id": "node2",
}
],
}
with (
patch.object(build, "_generator_model_config", return_value=_fake_mc("anthropic", "x")),
patch(
"services.dify_builder.agent.build.WorkflowGeneratorService.generate_workflow_graph",
return_value=failing,
),
):
result = build.build_nodes("t1", {}, ["call the GitHub API and summarize"])
assert result.intents == []
# one diagnostic per generation attempt (initial + the single corrective retry)
assert len(result.diagnostics) == 2
first = result.diagnostics[0]
assert first["source"] == "workflow-generator"
# the same text the server logged as the "%s" of "structural validation failed: %s"
assert "Reference {#node2.response#} not declared" in first["message"]
assert first["codes"] == ["UNRESOLVED_REFERENCE"]
assert first["attempt"] == 1
assert result.diagnostics[1]["attempt"] == 2
# structured detail survives -- code AND the offending node id
assert first["errors"][0]["code"] == "UNRESOLVED_REFERENCE"
assert first["errors"][0]["node_id"] == "node2"
# a server timestamp to locate the corresponding pod-log lines
datetime.fromisoformat(first["at"]) # parses -> real ISO-8601
assert first["at"].endswith("+00:00") # UTC, unambiguous across pods
def test_build_nodes_records_diagnostic_when_generation_raises():
"""A provider/runtime failure must also leave a timestamped breadcrumb."""
with (
patch.object(build, "_generator_model_config", return_value=_fake_mc("anthropic", "x")),
patch(
"services.dify_builder.agent.build.WorkflowGeneratorService.generate_workflow_graph",
side_effect=RuntimeError("credit_balance_exhausted"),
),
):
result = build.build_nodes("t1", {}, ["x"])
assert result.intents == []
assert len(result.diagnostics) == 1
d = result.diagnostics[0]
assert d["source"] == "build_nodes"
assert d["exception"] == "RuntimeError"
assert "credit_balance_exhausted" in d["message"]
datetime.fromisoformat(d["at"])