mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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>
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""Pure mapping from a Dify workflow-run result into the dify_builder domain.
|
|
|
|
``AppGenerateService.generate(..., streaming=True)`` yields a stream of
|
|
*SSE-formatted strings*; ``stream_chunk_as_mapping`` turns one back into the
|
|
event mapping it carries, and that mapping plus the run's per-node execution
|
|
rows are all these functions need to build a dify_builder ``Run`` /
|
|
``NodeEvent``. No DB, no services, no I/O — node-execution rows are
|
|
duck-typed (``node_id``, ``node_type``, ``title``, ``status``, ``error``,
|
|
``.outputs_dict``/``.inputs_dict``) rather than importing the real
|
|
``WorkflowNodeExecutionModel``, so tests can pass lightweight stand-ins
|
|
(e.g. ``SimpleNamespace``).
|
|
|
|
Status mapping is intentionally conservative: only a clean ``SUCCEEDED``
|
|
finish counts as a dify_builder-domain "succeeded" run. ``FAILED``,
|
|
``PARTIAL_SUCCEEDED``, ``STOPPED``, and ``PAUSED`` all map to "failed", as
|
|
does the distinct *paused-shape* response (human-input node — carries
|
|
``paused_nodes``/``reasons`` instead of a clean ``status``/``outputs``/
|
|
``error`` shape). A verify run that didn't cleanly succeed should never
|
|
read as green to the user, even if it's technically "still running"
|
|
(paused) or "mostly fine" (partial success) rather than an outright error.
|
|
"""
|
|
|
|
import json
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import replace
|
|
from typing import Any
|
|
|
|
from core.dify_builder.models import NodeEvent, NodeOutput, Run
|
|
|
|
_FAILED_NODE_STATUSES = {"failed", "exception"}
|
|
|
|
# Workflow-run statuses that mean "not over yet" (``WorkflowExecutionStatus``);
|
|
# "" covers a row we could not read at all.
|
|
_UNFINISHED_RUN_STATUSES = {"", "scheduled", "running"}
|
|
|
|
# What a truncated stream reports instead of a fabricated failure. Kept in the
|
|
# domain ``Run.error`` so the reason reaches the user rather than a bare status.
|
|
TRUNCATED_STREAM_ERROR = "the workflow run's progress stream ended before the run did, so its outcome is unknown"
|
|
|
|
# ``BaseAppGenerator.convert_to_event_stream`` (the tail of every streaming
|
|
# ``AppGenerateService.generate``) emits ``f"data: {json}\n\n"`` for an event
|
|
# mapping and ``f"event: {msg}\n\n"`` for a bare keep-alive such as ``"ping"``.
|
|
_STREAM_DATA_PREFIX = "data: "
|
|
|
|
# ``node_started`` carries no status yet, so it reports ``running``; the empty
|
|
# string means "take the status off the chunk itself" (``node_finished``).
|
|
_NODE_STREAM_EVENTS = {"node_started": "running", "node_finished": ""}
|
|
|
|
# The two events that end a stream (``streaming_utils._normalize_terminal_events``).
|
|
_TERMINAL_STREAM_EVENTS = {"workflow_finished", "workflow_paused"}
|
|
|
|
# Events whose ``data["id"]`` is the RUN id. On a node frame ``data["id"]`` is
|
|
# the node-execution id instead, so it must never be read as a run id.
|
|
_RUN_ID_DATA_EVENTS = {"workflow_started", "workflow_finished", "workflow_paused"}
|
|
|
|
# The frame ``ErrorStreamResponse`` becomes on the wire (see
|
|
# ``WorkflowAppGenerateResponseConverter.convert_stream_full_response``):
|
|
# top-level ``message`` / ``code`` / ``status`` and NO ``data``. It ends the
|
|
# stream but carries no run status, so it is not a terminal frame. ``run_draft``
|
|
# keeps it aside and consults it only when no terminal frame arrived --
|
|
# precedence is terminal frame > error frame > nothing (unknown). Chatflow
|
|
# emits ``workflow_finished(failed)`` AND an error frame for one failure, so
|
|
# the terminal frame must win when both are present.
|
|
_ERROR_STREAM_EVENT = "error"
|
|
|
|
|
|
def error_from_stream_chunk(chunk: Mapping[str, Any]) -> dict[str, str] | None:
|
|
"""The ``message`` / ``code`` an ``event: error`` frame carries, else ``None``."""
|
|
if str(chunk.get("event") or "") != _ERROR_STREAM_EVENT:
|
|
return None
|
|
return {"message": str(chunk.get("message") or ""), "code": str(chunk.get("code") or "")}
|
|
|
|
|
|
def map_error_frame_run(error: Mapping[str, str], run_id: str, node_execs: Sequence[Any]) -> Run:
|
|
"""A run whose stream ended on an explicit ``error`` frame: FAILED, with the
|
|
frame's message (and code) as ``Run.error``.
|
|
|
|
``per_node`` is whatever rows exist for ``run_id``: empty for a launch
|
|
failure (the ESQ1-302 frame arrives before ``workflow_started``, so there is
|
|
no run id and no row) and populated for a mid-run error. Whitespace is
|
|
collapsed so the text is a stable repair-breaker signature
|
|
(``handlers_build._failure_signature``).
|
|
"""
|
|
message = " ".join((error.get("message") or "").split())
|
|
code = (error.get("code") or "").strip()
|
|
if not message:
|
|
message = f"workflow run failed ({code})" if code else "workflow run failed"
|
|
elif code and code not in message:
|
|
message = f"{message} [{code}]"
|
|
return map_run_result({"id": run_id, "status": "failed", "error": message}, node_execs)
|
|
|
|
|
|
def _status_value(status: Any) -> Any:
|
|
"""Unwrap a ``StrEnum``'s ``.value`` if present, else pass through as-is.
|
|
|
|
Accepts either a real ``graphon`` enum member or a plain string (or
|
|
``None``), so callers don't need to import ``graphon`` to use this
|
|
module.
|
|
"""
|
|
return getattr(status, "value", status)
|
|
|
|
|
|
def _is_paused_shape(data: Mapping[str, Any]) -> bool:
|
|
"""A paused (human-input node) response carries these keys instead of
|
|
a normal ``status``/``outputs``/``error`` shape."""
|
|
return "paused_nodes" in data or "reasons" in data
|
|
|
|
|
|
def stream_chunk_as_mapping(chunk: Any) -> Mapping[str, Any] | None:
|
|
"""One ``AppGenerateService.generate(streaming=True)`` item as its event
|
|
mapping, or ``None`` when it carries no event.
|
|
|
|
The streaming stack hands back SSE-formatted strings, not dicts: the
|
|
workflow app generator's ``convert_stream_full_response`` builds the event
|
|
dicts, but ``convert_to_event_stream`` then wraps each one as
|
|
``"data: {json}\\n\\n"`` and every keep-alive as ``"event: ping\\n\\n"``.
|
|
Plain mappings are still accepted so callers stay correct if that last
|
|
wrapping layer is ever removed.
|
|
"""
|
|
if isinstance(chunk, Mapping):
|
|
return chunk
|
|
if not isinstance(chunk, str):
|
|
return None
|
|
text = chunk.strip()
|
|
if not text.startswith(_STREAM_DATA_PREFIX):
|
|
return None # "ping" / "event: ping\n\n" keep-alives
|
|
try:
|
|
payload = json.loads(text[len(_STREAM_DATA_PREFIX) :])
|
|
except ValueError:
|
|
return None
|
|
return payload if isinstance(payload, Mapping) else None
|
|
|
|
|
|
def node_event_from_stream_chunk(chunk: Mapping[str, Any]) -> NodeEvent | None:
|
|
"""One streaming chunk as a ``NodeEvent``, or ``None`` when it is not
|
|
about a node (ping frames, workflow-level frames, ...)."""
|
|
event = str(chunk.get("event") or "")
|
|
if event not in _NODE_STREAM_EVENTS:
|
|
return None
|
|
data = chunk.get("data") or {}
|
|
status = _NODE_STREAM_EVENTS[event] or _status_value(data.get("status"))
|
|
return NodeEvent(
|
|
node_id=str(data.get("node_id") or ""),
|
|
title=str(data.get("title") or ""),
|
|
status=str(status or ""),
|
|
error=str(data.get("error") or ""),
|
|
run_id=run_id_from_stream_chunk(chunk),
|
|
)
|
|
|
|
|
|
def run_id_from_stream_chunk(chunk: Mapping[str, Any]) -> str:
|
|
"""The workflow run id a chunk carries, or ``""``.
|
|
|
|
``convert_stream_full_response`` stamps ``workflow_run_id`` on *every*
|
|
non-ping frame, so the run id is known from the first frame onwards --
|
|
long before the terminal frame that may never arrive. That is what lets a
|
|
truncated stream still ask the database how the run actually went.
|
|
"""
|
|
direct = chunk.get("workflow_run_id")
|
|
if direct:
|
|
return str(direct)
|
|
data = chunk.get("data")
|
|
if not isinstance(data, Mapping):
|
|
return ""
|
|
if data.get("workflow_run_id"):
|
|
return str(data["workflow_run_id"])
|
|
if str(chunk.get("event") or "") in _RUN_ID_DATA_EVENTS:
|
|
return str(data.get("id") or "")
|
|
return ""
|
|
|
|
|
|
def run_result_data_from_run_row(run_row: Any) -> dict[str, Any]:
|
|
"""A ``WorkflowRun`` row shaped the way ``map_run_result`` expects it.
|
|
|
|
The stream is the fast path, not the authority: when it ends without a
|
|
terminal frame the row is what actually says how the run went. Duck-typed
|
|
like the node-execution rows, so tests can pass stand-ins.
|
|
"""
|
|
return {
|
|
"id": str(getattr(run_row, "id", "") or ""),
|
|
"status": _status_value(getattr(run_row, "status", "")) or "",
|
|
"error": getattr(run_row, "error", "") or "",
|
|
"elapsed_time": getattr(run_row, "elapsed_time", 0) or 0,
|
|
"total_tokens": getattr(run_row, "total_tokens", 0) or 0,
|
|
}
|
|
|
|
|
|
def is_unfinished_run_status(status: Any) -> bool:
|
|
"""True while a workflow run has not reached any final state."""
|
|
return str(_status_value(status) or "") in _UNFINISHED_RUN_STATUSES
|
|
|
|
|
|
def map_unknown_run_outcome(data: Mapping[str, Any], node_execs: Sequence[Any]) -> Run:
|
|
"""A run whose outcome nothing could establish -- NOT a failed run.
|
|
|
|
``map_run_result`` is deliberately conservative: anything that is not a
|
|
clean ``succeeded`` reads as ``failed``. That is right for a run we watched
|
|
end, and wrong for one we merely lost sight of -- a fabricated ``failed``
|
|
sends a build that may well have succeeded straight into the repair loop.
|
|
``Run.status`` documents ``running`` alongside ``succeeded``/``failed``
|
|
exactly for this "not over / not known" case, and ``Run.error`` carries the
|
|
reason so it is visible instead of silent.
|
|
"""
|
|
return replace(map_run_result(data, node_execs), status="running", error=TRUNCATED_STREAM_ERROR)
|
|
|
|
|
|
def run_result_data_from_terminal_chunk(chunk: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
"""The ``data`` a terminal streaming chunk carries, shaped the way
|
|
``map_run_result`` expects it, or ``None`` for a non-terminal chunk.
|
|
|
|
``map_run_result`` was written against the *blocking* response, whose
|
|
``data`` always carries ``id`` (``WorkflowAppBlockingResponse.Data`` and
|
|
``WorkflowAppPausedBlockingResponse.Data`` both declare it). The streaming
|
|
``workflow_finished`` frame matches that shape, but the streaming
|
|
``workflow_paused`` frame (``WorkflowPauseStreamResponse.Data``) carries
|
|
``workflow_run_id`` instead of ``id``. Rather than loosen
|
|
``map_run_result`` — the fix/edit flows share it — the run id is backfilled
|
|
here, at the call site's edge.
|
|
"""
|
|
if str(chunk.get("event") or "") not in _TERMINAL_STREAM_EVENTS:
|
|
return None
|
|
data = dict(chunk.get("data") or {})
|
|
if not data.get("id"):
|
|
data["id"] = str(data.get("workflow_run_id") or chunk.get("workflow_run_id") or "")
|
|
return data
|
|
|
|
|
|
def map_run_result(data: Mapping[str, Any], node_execs: Sequence[Any]) -> Run:
|
|
"""Map a ``AppGenerateService.generate`` run result into a ``Run``.
|
|
|
|
``Run.id`` is deliberately left ``""`` — the caller (``handle_verify``)
|
|
assigns the id and sets ``DifyBuilderContext.verify_run_id``, not this function.
|
|
"""
|
|
if _is_paused_shape(data):
|
|
status = "failed"
|
|
else:
|
|
status = "succeeded" if _status_value(data.get("status")) == "succeeded" else "failed"
|
|
|
|
per_node = [
|
|
NodeOutput(
|
|
node_id=node.node_id,
|
|
title=node.title,
|
|
status=_status_value(node.status),
|
|
error=node.error or "",
|
|
outputs=node.outputs_dict,
|
|
)
|
|
for node in node_execs
|
|
]
|
|
|
|
culprit_node_id = next((n.node_id for n in per_node if n.status in _FAILED_NODE_STATUSES), "")
|
|
|
|
return Run(
|
|
kind="verify",
|
|
immutable=True,
|
|
dify_run_id=data["id"],
|
|
status=status,
|
|
per_node=per_node,
|
|
culprit_node_id=culprit_node_id,
|
|
# The engine's run-level error text. A succeeded run never carries one;
|
|
# a launch failure has no rows at all, so this is then the only
|
|
# evidence the repair breaker and diagnose have.
|
|
error="" if status == "succeeded" else str(data.get("error") or ""),
|
|
tokens=data.get("total_tokens", 0),
|
|
elapsed_ms=int(data.get("elapsed_time", 0) * 1000),
|
|
)
|
|
|
|
|
|
def to_node_event(node_exec: Any) -> NodeEvent:
|
|
"""Map one node-execution row into a per-node progress ``NodeEvent``."""
|
|
return NodeEvent(
|
|
node_id=node_exec.node_id,
|
|
title=node_exec.title,
|
|
status=_status_value(node_exec.status),
|
|
error=node_exec.error or "",
|
|
)
|