Files
dify/api/core/dify_builder/models.py
T
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

338 lines
12 KiB
Python

"""Dify Builder domain models.
Port of dify-enterprise/server/pkg/enterprise/biz/dify_builder/models.go.
Deltas from the Go source (per the P1 port plan's Global Constraints / ADR):
- ``ForwardAuth`` is dropped entirely. ``Turn`` carries an ``Actor``
(``account_id``, ``tenant_id``) instead of a forwarded console token, which
is never persisted and only ever valid for a single ``Advance`` call.
- Every field except ``PcState``/``EntryMode``-typed fields
(``Session.entry_mode``, ``Session.current_state``, ``Checkpoint.state``)
``Turn.actor``, and ``ChangedNode.node_id`` gets a default matching the Go zero value (``""``,
``0``, ``False``, empty list/dict, ``datetime.min``). This mirrors how the
Go test suite constructs these structs: almost every struct literal in
``*_test.go`` sets only a handful of fields and relies on Go's implicit
zero-initialization for the rest (e.g. ``&Session{AppID: "app", ...}``,
``&DifyBuilderContext{}``, ``&DifyBuilderContext{FailedRunID: "TR-1"}``). The excepted
fields have no meaningful "empty" value (an empty string is not a valid
``PcState``/``EntryMode`` member) and are always set explicitly in the Go
suite, so they stay required here.
- ``Diagnosis`` / ``Risk`` / ``MutationIntent`` / ``ChangeSet`` / ``NodeEvent``
/ ``ApplyResult`` (Go: ``ports.go``) live here rather than in ``ports.py``.
``DifyBuilderContext`` embeds ``Diagnosis``/``Risk``/``ChangeSet``/
``MutationIntent`` directly, and defining them in ``ports.py`` (which
imports the rest of these models) would create a models<->ports import
cycle; ``ports.py`` imports them back out of this module instead.
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from typing import Any
from core.dify_builder.state import PcState
class EntryMode(StrEnum):
FIX = "fix"
FIX_CHECKLIST = "fix_checklist"
BUILD = "build"
EDIT = "edit"
@dataclass(kw_only=True)
class ChecklistError:
"""One pre-publish checklist finding for a node.
Config errors (``messages``) are fixable via ``set_node_config``;
``unconnected`` and ``plugin_missing`` are structural — surfaced to the
user, not auto-fixed in v1.
"""
node_id: str = ""
node_type: str = ""
title: str = ""
messages: list[str] = field(default_factory=list)
unconnected: bool = False
plugin_missing: bool = False
@dataclass(kw_only=True)
class Session:
id: str = ""
app_id: str = ""
tenant_id: str = ""
owner_account_id: str = ""
entry_mode: EntryMode
current_state: PcState
version: int = 0
created_at: datetime = field(default_factory=lambda: datetime.min)
updated_at: datetime = field(default_factory=lambda: datetime.min)
# Graph, Inputs and StartSchema are opaque JSON blobs the engine passes
# through; only the Dify adapter and the agent interpret them.
Graph = dict[str, Any]
Inputs = dict[str, Any]
StartSchema = dict[str, Any]
@dataclass(kw_only=True)
class NodeOutput:
node_id: str = ""
title: str = ""
status: str = "" # success | failed | skip
error: str = ""
inputs: Inputs = field(default_factory=dict)
outputs: Inputs = field(default_factory=dict)
@dataclass(kw_only=True)
class Run:
id: str = ""
kind: str = "" # original-failed | verify
dify_run_id: str = ""
status: str = "" # running | succeeded | failed
per_node: list[NodeOutput] = field(default_factory=list)
culprit_node_id: str = ""
# Launch-failure text (run threw before any node executed); "" once a node ran.
error: str = ""
inputs_ref: str = ""
tokens: int = 0
elapsed_ms: int = 0
immutable: bool = False
@dataclass(kw_only=True)
class Snapshot:
id: str = ""
session_id: str = ""
hash: str = ""
graph: Graph = field(default_factory=dict)
@dataclass(kw_only=True)
class Checkpoint:
id: str = ""
session_id: str = ""
state: PcState
snapshot_id: str = ""
@dataclass(kw_only=True)
class TestInput:
# Not a pytest test class; ``__test__`` (unannotated, so not a dataclass
# field) tells pytest's Test*-name collector to skip it.
__test__ = False
id: str = ""
session_id: str = ""
source: str = "" # reuse | mock | upload
inputs: Inputs = field(default_factory=dict)
start_schema_hash: str = ""
@dataclass(kw_only=True)
class ConversationItem:
seq: int = 0
kind: str = "" # user | decision | notice | run_context | preflight_context | assistant_turn | plan
# | form | challenge | resource_select | checkpoint | change_set | test_result | error | summary
# | publish | build_learning
payload: dict[str, Any] = field(default_factory=dict)
at_version: int = 0
@dataclass(kw_only=True)
class Diagnosis:
culprit_node_id: str = ""
root_cause: str = ""
severity: str = ""
@dataclass(kw_only=True)
class Risk:
level: str = "" # low | high
reason: str = ""
has_external_side_effect: bool = False
@dataclass(kw_only=True)
class MutationIntent:
"""A single graph mutation the dify_builder applies to the draft.
``op`` selects one of five verbs (Slice 1: mutation verbs); ``args`` is
the verb's untyped wire-shaped payload -- ``services.dify_builder
.graph_ops`` unpacks it as keyword arguments (``apply_fn(graph,
**intent.args)``), so each verb's arg-dict keys below are exactly that
verb's ``apply_*`` function's parameter names. Required keys are
enforced by ``graph_ops.validate_intent_args``; ``?`` marks an optional
key.
- ``set_node_config``: ``{node_id, path, value}``
- ``create_node``: ``{node_type, config, position?, node_id?}``
- ``delete_node``: ``{node_id}``
- ``connect``: ``{from_node, to_node, source_handle?, target_handle?}``
- ``insert_between``: ``{edge, node_type, config, position?, node_id?}``
"""
op: str = ""
args: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class ChangeSet:
changed_nodes: list[str] = field(default_factory=list)
diff: str = ""
@dataclass(kw_only=True)
class NodeEvent:
"""A per-node progress event emitted while a draft run streams."""
node_id: str = ""
title: str = ""
status: str = ""
error: str = ""
# The Dify workflow run these nodes belong to. Carried on the event so a
# client can point the canvas at the run while it is still going -- the
# run id is known from the first streamed frame, long before the card
# that reports the outcome exists. "" when the adapter cannot supply one.
run_id: str = ""
@dataclass(kw_only=True)
class ChangedNode:
"""A node's identity and display name captured with a change summary."""
node_id: str
title: str = ""
@dataclass(kw_only=True)
class ApplyResult:
"""Reports what a repair changed and the draft's new hash."""
changed_nodes: list[str] = field(default_factory=list)
nodes: list[ChangedNode] = field(default_factory=list)
new_hash: str = ""
changes: list[str] = field(default_factory=list)
scope: str = ""
structure_fingerprint: str = ""
@dataclass(kw_only=True)
class BuildNodesResult:
"""Outcome of ``build_nodes``: the create/connect ``intents`` to apply, plus a
human-readable ``error`` explaining WHY generation failed when ``intents`` is
empty -- the generator's specific reason (e.g. UNRESOLVED_REFERENCE, non-object
JSON) or a provider error (e.g. credit_balance_exhausted). Empty on success."""
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)
class DifyBuilderContext:
"""Per-session working state persisted as the commit context.
One context type for all entry modes (Fix, checklist, Build, Edit).
Fix-specific and Build-specific fields coexist; each scenario touches
only its own, all default-valued (serialized as a JSON blob -- no
migration on rename or on adding fields).
"""
# -- Fix / checklist fields --
failed_run_id: str = ""
diagnosis: Diagnosis | None = None
staged_repair: list[MutationIntent] = field(default_factory=list)
risk: Risk | None = None
change_set: ChangeSet | None = None
checkpoint_id: str = ""
verify_run_id: str = ""
test_input_ref: str = ""
last_snapshot_hash: str = ""
next_seq: int = 0
# source distinguishes diagnosis origin: "" (run, default) | "checklist".
source: str = ""
checklist_errors: list[ChecklistError] = field(default_factory=list)
# -- Build fields (Slice 2, additive) --
goal_text: str = ""
reply_language: str = "" # detected user-input language (BCP-47); drives reply/localization
requirements: dict[str, Any] = field(default_factory=dict)
plan_items: list[str] = field(default_factory=list)
plan_version_tag: str = ""
resource_selection: dict[str, Any] = field(default_factory=dict)
built_node_ids: list[str] = field(default_factory=list)
form_fields: list[dict] = field(default_factory=list)
# The app's name as last seen by the engine, so the build-complete card can
# say it (spec N4). Empty means unknown -- that card then keeps its generic
# title rather than naming the app wrongly.
app_name: str = ""
# True while the app still carries the name cut from its creating prompt, so
# Builder may refine it once (spec N1); cleared the moment it does. Plan
# approval freezes the name for good (spec N3, 批准即定名).
app_name_auto: bool = False
# -- Edit fields (Slice 3, additive) --
edit_rules: dict[str, Any] = field(default_factory=dict)
edit_target_node_ids: list[str] = field(default_factory=list)
# -- Lifecycle fields (Slice 4, additive) --
paused: bool = False
checkpoint_seq: int = 0
# -- Recovery fields (C-1, additive) --
last_structure_fingerprint: str = ""
recovery_class: str = ""
# -- Governance fields (C-2, additive) --
skill_learning_policy: str = "ask"
# -- Agent model selection (Foundation slice, additive) --
# The user's per-session model choice, in Dify's model-selector shape
# {provider, name, mode, completion_params}. Empty == use the tenant default LLM.
# Kept a plain dict so core imports no services/model-runtime type.
model_config: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class Action:
"""A user-driven event entering the machine."""
# request_fix | approve_repair | reject_repair | run_verify | provide_testdata |
# publish | keep_draft | undo | re_fix | message
kind: str = ""
payload: dict[str, Any] = field(default_factory=dict)
base_version: int = 0
# Opaque draft-workflow revision observed in the latest server view.
# Empty means the action does not mutate or otherwise depend on the app
# draft (for example a free-text conversation turn).
base_app_revision: str = ""
@dataclass(kw_only=True)
class Actor:
"""The acting console identity for one Advance call. Never persisted."""
account_id: str
tenant_id: str
@dataclass(kw_only=True)
class Turn:
"""Per-request data carried through one Advance.
Replaces Go's ``Turn.Auth ForwardAuth`` — there is no forwarded console
token in this port; ``actor`` identifies who is driving this turn.
"""
action: Action | None = None
actor: Actor