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

929 lines
25 KiB
Python

"""Dify Builder FE<->backend interaction contract.
New for the Build/Edit slice (spec: ``docs/superpowers/specs/
2026-08-21-dify-builder-full-flow-contract-design.md``, §2 enums, §6
canvas events, §7 state machines). These enums freeze the wire vocabulary
shared by all entry modes (Fix, Build, Edit) — canned agent today, a future
ProAgent tomorrow, same shapes (§ Global Constraints: "canned-agnostic").
All members are ``StrEnum`` so ``dataclasses.asdict``/``json.dumps`` emit the
snake_case wire value directly; the member name is just the UPPER_SNAKE of
that value.
"""
from dataclasses import asdict, dataclass, field
from enum import StrEnum
from typing import Any, ClassVar, Literal
from core.dify_builder.models import ChangedNode, ConversationItem, EntryMode
class Phase(StrEnum):
"""Coarse UX phase shown in the panel header (spec §2)."""
UNDERSTAND = "understand"
CLARIFY = "clarify"
RESOURCES = "resources"
PLAN = "plan"
MODIFY = "modify"
TEST = "test"
REVIEW = "review"
PUBLISH = "publish"
COMPLETE = "complete"
class RunStatus(StrEnum):
"""Widened run-status vocabulary (spec §2).
``processing`` => ``canvas_read_only = true`` (WORKING).
``waiting_*``/``paused`` => editable (WAITING).
``complete``/``failed`` => terminal.
"""
PROCESSING = "processing"
WAITING_INPUT = "waiting_input"
WAITING_CONFIRMATION = "waiting_confirmation"
PAUSED = "paused"
FAILED = "failed"
COMPLETE = "complete"
class ActionKind(StrEnum):
"""Action button styling / semantics (spec §2, §5).
``automatic`` actions are not buttons — the client must not render
them; the server auto-advances. They exist in the stream only so the
FE can show "auto-continuing...".
"""
PRIMARY = "primary"
SECONDARY = "secondary"
DESTRUCTIVE = "destructive"
AUTOMATIC = "automatic"
class CardKind(StrEnum):
"""The conversation-item/card vocabulary (spec §4)."""
USER = "user"
DECISION = "decision"
NOTICE = "notice"
RUN_CONTEXT = "run_context"
PREFLIGHT_CONTEXT = "preflight_context"
ASSISTANT_TURN = "assistant_turn"
PLAN = "plan"
FORM = "form"
CHALLENGE = "challenge"
RESOURCE_SELECT = "resource_select"
CHECKPOINT = "checkpoint"
CHANGE_SET = "change_set"
TEST_RESULT = "test_result"
ERROR = "error"
SUMMARY = "summary"
PUBLISH = "publish"
BUILD_LEARNING = "build_learning"
class CanvasEvent(StrEnum):
"""Granular canvas-mutation signals (spec §6).
Exactly 22 members, snake_cased from the mock's ``DifyBuilderCanvasEvent``
union. Presentation of committed backend state: replaying these from a
snapshot must reconstruct the same canvas.
"""
RESET_BUILD_CANVAS = "reset_build_canvas"
ADD_START_NODE = "add_start_node"
ADD_KNOWLEDGE_NODE = "add_knowledge_node"
ADD_LLM_NODE = "add_llm_node"
ADD_OUTPUT_NODE = "add_output_node"
FOCUS_WORKFLOW = "focus_workflow"
HIGHLIGHT_EDIT_TARGET = "highlight_edit_target"
APPLY_EDIT_PLAN = "apply_edit_plan"
START_TEST_RUN = "start_test_run"
MARK_TEST_ERROR = "mark_test_error"
FOCUS_ERROR_NODE = "focus_error_node"
FOCUS_CHECKLIST_NODE = "focus_checklist_node"
CREATE_CHECKPOINT = "create_checkpoint"
APPLY_ERROR_FIX = "apply_error_fix"
MARK_REPAIR_APPLIED = "mark_repair_applied"
APPLY_PREFLIGHT_FIX = "apply_preflight_fix"
START_RETEST = "start_retest"
MARK_TEST_SUCCESS = "mark_test_success"
MARK_REVIEW_READY = "mark_review_ready"
REVERT_CHECKPOINT = "revert_checkpoint"
CANCEL_PUBLISH = "cancel_publish"
PUBLISH_WORKFLOW = "publish_workflow"
class ConflictPolicy(StrEnum):
"""Resource-conflict handling in Build (spec §2)."""
AUDITED = "audited"
ASK = "ask"
class SkillLearningPolicy(StrEnum):
"""Governance tail (spec §2). Deferred — contract-only for now."""
ASK = "ask"
AUTOMATIC = "automatic"
DISABLED = "disabled"
class RecoveryClass(StrEnum):
"""How the draft drifted from what the dify_builder last knew, when a session
is reopened after a hand-edit (spec §8 / C-1)."""
UNCHANGED = "unchanged"
CONFIG_ONLY = "config_only"
STRUCTURAL_COMPATIBLE = "structural_compatible"
STRUCTURAL_INVALIDATING = "structural_invalidating"
class BuilderErrorCode(StrEnum):
"""Stable HTTP error codes returned by the Builder console routes."""
BAD_REQUEST = "bad_request"
NOT_FOUND = "not_found"
CONFLICT = "conflict"
SESSION_BUSY = "session_busy"
FEATURE_UNAVAILABLE = "feature_unavailable"
MODEL_UNAVAILABLE = "model_unavailable"
@dataclass
class Action:
"""A UI action the FE renders (spec §5).
DISTINCT from ``core.dify_builder.models.Action`` (the submit DTO
the FE POSTs back) -- this one is what the server *sends* describing a
renderable button.
"""
id: str
label: str
kind: ActionKind
next_state: str | None = None
canvas_event: str | None = None
# The only two buttons an interactive card shows; every real choice is an option
# inside it. One interaction shape at every gate, not one per state.
CONFIRM_ACTION_ID = "confirm"
CANCEL_ACTION_ID = "cancel"
@dataclass
class OptionInput:
"""Free text an option reveals once selected ("Something else", "Reject with a reason").
Bounds are declared for the client to enforce; see ``Decision`` for why the
server does not reject on them yet.
"""
placeholder: str = ""
min_length: int = 0
max_length: int = 100
required: bool = True
@dataclass
class CardOption:
"""One answer a gate offers, inside the card instead of as a button.
``id`` is the former standalone action id, unchanged, so an option resolves
to exactly the handler kind its button used to.
"""
id: str
label: str
# The second line under an option. Empty for now: the per-state action table
# carries labels only, and writing the copy here is a product decision.
description: str = ""
# Shown as a DEFAULT badge. Advisory only -- these gates have side effects,
# so declining to choose must never apply anything.
is_default: bool = False
tone: str = "neutral" # neutral | destructive
input: OptionInput | None = None
next_state: str | None = None
canvas_event: str | None = None
@dataclass
class Decision:
"""What the current gate asks: card options plus the fixed button pair.
Replaces the per-state action bar. The client renders ``options`` in the
active card, shows only ``confirm`` and ``cancel``, and on confirm posts
``confirm`` with ``{"option_id": ..., "free_text": ...}``. ``cancel`` is
presentational -- it dismisses without posting and the gate stays open.
While ``SessionView.actions`` is still populated, the server accepts the old
per-action ids too and does not enforce ``OptionInput`` bounds, so a client
posting a bare action id keeps working. Enforcement moves server-side when
``actions`` goes.
"""
options: list[CardOption] = field(default_factory=list)
confirm: Action | None = None
cancel: Action | None = None
default_option_id: str = ""
@dataclass
class CheckpointRef:
"""Active restore point on the SessionView (spec §8)."""
checkpoint_id: str
label: str
created_at: str
@dataclass
class RecoveryRef:
"""Draft-drift recovery offer on the SessionView (spec §8 / C-1)."""
recovery_class: str
can_continue: bool
can_restart: bool
message: str
@dataclass
class BuilderError:
"""Error body returned before an SSE response starts, or by a JSON route.
``message`` and ``recoverable`` are optional extension fields. Current
responses only require the stable ``code`` field.
"""
code: BuilderErrorCode
message: str | None = None
recoverable: bool | None = None
# ---------------------------------------------------------------------------
# Card sub-types (spec §4.3). Plain dataclasses -- no ``kind`` discriminant,
# they nest inside a card's fields rather than standing alone in the
# conversation.
# ---------------------------------------------------------------------------
@dataclass
class FormField:
"""One field of a ``form`` card, including Start-node input constraints."""
key: str
label: str
type: str
options: list[str] = field(default_factory=list)
required: bool = False
default: str | int | float | bool | None = None
max_length: int | None = field(default=None, metadata={"minimum": 1})
allowed_file_types: list[str] = field(default_factory=list)
allowed_file_extensions: list[str] = field(default_factory=list)
allowed_file_upload_methods: list[str] = field(default_factory=list)
placeholder: str | None = None
hint: str | None = None
unit: str | None = None
json_schema: str | dict[str, Any] | None = None
number_limits: int | None = field(default=None, metadata={"minimum": 1})
@dataclass
class ResourceOption:
"""One recommended resource in a ``resource_select`` card.
``kind`` in knowledge|plugin|model|credential; ``readiness`` in
ready|missing_config|missing_plugin|placeholder|waiting_setup.
Credentials are referenced by name only -- never a secret.
"""
id: str
label: str
meta: str
kind: str
readiness: str
@dataclass
class SessionModel:
"""The LLM chosen for this session, echoed for the FE model picker.
Empty on the wire (``model: null``) means the session uses the tenant default.
"""
provider: str
name: str
mode: str = ""
completion_params: dict[str, Any] = field(default_factory=dict)
@dataclass
class AppRevision:
"""Shared draft-workflow revision associated with a private session.
``observed`` is the revision last incorporated by the Builder engine;
``current`` is read from the app draft when this SessionView is built.
A mismatch tells the client to refresh/rebase before submitting a
workflow-dependent action.
"""
observed: str
current: str
conflicted: bool
@dataclass
class ActiveInteraction:
"""The one persisted card that may accept input at the current version.
Historical cards remain renderable, but the client must only enable this
card. ``valid_at_version`` is an explicit fence in addition to the normal
action ``base_version`` check.
"""
action_id: str
card: ConversationItem
valid_at_version: int
@dataclass
class ConversationPage:
"""A group-safe page of durable conversation items.
``limit`` at the HTTP boundary counts rendered conversation groups rather
than raw rows, so a card bundle is never split from its assistant turn.
Items inside the page stay in ascending ``seq`` order.
"""
data: list[ConversationItem]
has_more: bool
first_seq: int | None
last_seq: int | None
@dataclass
class SessionView:
"""Bounded read model returned by Builder session-facing routes.
Conversation history deliberately lives behind ``ConversationPage``. This
object is safe to send at command start/end without payload growth as a
session gets older.
"""
session_id: str
app_id: str
version: int
state: str
canvas_read_only: bool
run_status: RunStatus
interrupted: bool
conversation_last_seq: int
entry_mode: EntryMode = EntryMode.FIX
phase: Phase = Phase.UNDERSTAND
# DEPRECATED until the web client reads ``decision``: the same choice, in
# the standalone-button shape.
actions: list[Action] = field(default_factory=list)
decision: Decision | None = None
active_interaction: ActiveInteraction | None = None
checkpoint: CheckpointRef | None = None
recovery: RecoveryRef | None = None
model: SessionModel | None = None
app_revision: AppRevision | None = None
@dataclass
class PreflightIssue:
"""One checklist finding in a ``preflight_context`` card.
``kind`` in connection|variable|required_config|availability.
"""
node_id: str
label: str
kind: str
ExecutionActivityState = Literal["active", "done", "failed", "stopped"]
ExecutionActivityKind = Literal["stage", "node"]
ExecutionProgressStatus = Literal["running", "completed", "error", "stopped"]
@dataclass
class ExecutionActivity:
"""One observable action that has started during a Builder operation.
Planned future work is intentionally absent. Node activities use
``parent_id`` to sit under the stage that owns the workflow run.
"""
id: str
label: str
state: ExecutionActivityState
kind: ExecutionActivityKind = "stage"
parent_id: str | None = None
@dataclass
class ExecutionProgress:
"""A snapshot containing only execution activities observed so far."""
status: ExecutionProgressStatus
activities: list[ExecutionActivity] = field(default_factory=list)
@dataclass
class TestStat:
"""One stat tile in a ``test_result`` card."""
# Not a pytest test class; ``__test__`` (unannotated, so not a dataclass
# field) tells pytest's Test*-name collector to skip it.
__test__ = False
value: str
label: str
@dataclass
class SummaryRow:
"""One row of a ``completion``-variant ``summary`` card."""
label: str
value: str
# ---------------------------------------------------------------------------
# Cards (spec §4.3). Each subclasses ``_Card``: ``kind`` is a ``ClassVar``
# discriminant (not a dataclass field), so ``dataclasses.asdict(card)`` is
# exactly the card's wire ``payload`` -- no redundant ``kind`` inside it.
# ``to_item`` wraps the payload into the shipped ``ConversationItem``
# envelope (§4.1): ``{seq, kind, payload, at_version}``.
# ---------------------------------------------------------------------------
@dataclass
class _Card:
kind: ClassVar[CardKind]
def to_item(self, seq: int, at_version: int) -> ConversationItem:
return ConversationItem(seq=seq, kind=str(self.kind), payload=asdict(self), at_version=at_version)
@dataclass
class UserItem(_Card):
"""Right-aligned bubble: raw user text."""
kind: ClassVar[CardKind] = CardKind.USER
text: str
turn_id: str
@dataclass
class DecisionItem(_Card):
"""Right-aligned bubble: a committed-choice summary."""
kind: ClassVar[CardKind] = CardKind.DECISION
text: str
@dataclass
class NoticeItem(_Card):
"""A neutral/informational system notice."""
kind: ClassVar[CardKind] = CardKind.NOTICE
text: str
tone: str = "neutral"
@dataclass
class RunContextCard(_Card):
"""Fix-run injected context (spec §4.3). ``trace_ref`` is redacted."""
kind: ClassVar[CardKind] = CardKind.RUN_CONTEXT
run_id: str
title: str
error_code: str
message: str
trace_ref: str = ""
@dataclass
class PreflightContextCard(_Card):
"""Checklist-fix injected context (spec §4.3)."""
kind: ClassVar[CardKind] = CardKind.PREFLIGHT_CONTEXT
node_count: int
issue_count: int
issues: list[PreflightIssue] = field(default_factory=list)
@dataclass
class AssistantTurnItem(_Card):
"""The streamable unit (spec §4.2). ``stage_id`` is the PcState this
turn ran in; ``cards`` lists which cards (in order) this turn attaches.
``card_state`` is ``None`` or ``"invalidated"`` (frozen after task exit).
"""
kind: ClassVar[CardKind] = CardKind.ASSISTANT_TURN
turn_id: str
stage_id: str
execution: ExecutionProgress
reasoning_text: str | None = None
reply_text: str | None = None
cards: list[str] = field(default_factory=list)
card_state: str | None = None
@dataclass
class PlanCard(_Card):
"""Versioned plan (spec §4.3). ``items`` are ordered, human-readable
steps. Build emits v1/v2/v2.x; Edit emits a single change plan.
"""
kind: ClassVar[CardKind] = CardKind.PLAN
title: str
version_tag: str
items: list[str] = field(default_factory=list)
subtitle: str | None = None
@dataclass
class FormCard(_Card):
"""Editable, re-submittable input card. ``variant`` in
build_requirements|edit_rules|testdata.
"""
kind: ClassVar[CardKind] = CardKind.FORM
variant: str
fields: list[FormField] = field(default_factory=list)
values: dict = field(default_factory=dict)
frozen: bool = False
@dataclass
class ChallengeCard(_Card):
"""High-impact-rule confirmation, distinct from a plain question."""
kind: ClassVar[CardKind] = CardKind.CHALLENGE
title: str
body: str
tone: str = "warning"
@dataclass
class ResourceSelectCard(_Card):
"""Resource discovery: multi-select ``recommended`` resources."""
kind: ClassVar[CardKind] = CardKind.RESOURCE_SELECT
recommended: list[ResourceOption] = field(default_factory=list)
@dataclass
class CheckpointCard(_Card):
"""Restore point, backed by create-checkpoint/revert-checkpoint canvas
events. Restoring invalidates prior approvals.
"""
kind: ClassVar[CardKind] = CardKind.CHECKPOINT
checkpoint_id: str
label: str
created_at: str
@dataclass
class ChangeSetCard(_Card):
"""Read-only change summary. ``scope`` in annotation|configuration|structure."""
kind: ClassVar[CardKind] = CardKind.CHANGE_SET
count: int
changes: list[str]
scope: str
nodes: list[ChangedNode] = field(default_factory=list)
@dataclass
class TestResultCard(_Card):
"""Structured test/verify result. ``tone`` in success|error."""
# Not a pytest test class; ``__test__`` (unannotated, so not a dataclass
# field) tells pytest's Test*-name collector to skip it.
__test__ = False
kind: ClassVar[CardKind] = CardKind.TEST_RESULT
title: str
subtitle: str
tone: str
stats: list[TestStat] = field(default_factory=list)
run_ids: list[str] = field(default_factory=list)
# The Dify workflow run these results came from, so a client can reopen
# the run on the canvas later. ``run_ids`` holds Builder run ids, which
# resolve to nothing outside the engine; the canvas needs THIS one. It is
# persisted with the card because the SSE frames that also carry it do
# not survive a page reload. "" when no run backs the card.
dify_run_id: str = ""
# The terminal node's outputs, pretty-printed. "" when the run produced
# none. A passing test that shows no result is not evidence of anything.
output: str = ""
@dataclass
class ErrorCard(_Card):
"""A found/fixed problem."""
kind: ClassVar[CardKind] = CardKind.ERROR
title: str
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
class SummaryCard(_Card):
"""Polymorphic by phase. ``variant`` in context|review|completion --
``items`` is used for context/review, ``rows`` for completion.
"""
kind: ClassVar[CardKind] = CardKind.SUMMARY
variant: str
title: str | None = None
items: list[str] = field(default_factory=list)
rows: list[SummaryRow] = field(default_factory=list)
@dataclass
class PublishCard(_Card):
"""Publish confirmation. Build -> v1.0, Edit -> v2.4-style bump, Fix ->
patch bump.
"""
kind: ClassVar[CardKind] = CardKind.PUBLISH
version: str
badge: str = "live"
@dataclass
class BuildLearningCard(_Card):
"""Post-build "sink this experience into System Skills?" card.
Deferred -- contract-only for now. ``policy`` is a
``SkillLearningPolicy`` value; ``state`` in pending|accepted|skipped.
"""
kind: ClassVar[CardKind] = CardKind.BUILD_LEARNING
policy: str
state: str
# ---------------------------------------------------------------------------
# Typed submit payloads (spec §5, ``SubmitPayloadKind``). Plain dataclasses
# -- no ``kind`` discriminant, no ``to_item``: these are POSTed by the FE as
# ``Action.payload``, never persisted as conversation items.
# ---------------------------------------------------------------------------
@dataclass
class GoalPayload:
text: str
@dataclass
class EditGoalPayload:
text: str
@dataclass
class RequirementsPayload:
report_types: str
audience: str
currency: str
metrics: str
output: str
prefer_audited: bool = False
@dataclass
class EditRulesPayload:
risk_threshold: str
review_team: str
timeout_behavior: str
preserve_summary: bool = False
@dataclass
class ResourcesPayload:
resource_ids: list[str] = field(default_factory=list)
@dataclass
class RunContextPayload:
run_id: str
node_name: str
error_code: str
@dataclass
class CheckpointPayload:
checkpoint_id: str
@dataclass
class TestPayload:
# Not a pytest test class; ``__test__`` (unannotated, so not a dataclass
# field) tells pytest's Test*-name collector to skip it.
__test__ = False
run_ids: list[str] = field(default_factory=list)
@dataclass
class PublishPayload:
version: str
@dataclass
class TestdataPayload:
"""``mode`` in upload|self_test|mock."""
mode: str
inputs: dict | None = None
# ---------------------------------------------------------------------------
# SSE payloads. ``_SseEventData`` is the shared wire-payload marker. Each payload
# declares its envelope event name as the ``sse_event`` ClassVar. Bus-level
# ``kind`` remains on payloads while the HTTP transport wraps each value in a
# generated ``{ event, data }`` response envelope.
# ---------------------------------------------------------------------------
class _SseEventData:
sse_event: ClassVar[str]
@dataclass
class CommandStartedEventData(SessionView, _SseEventData):
"""Bounded command/reconnect handshake; never contains conversation history."""
sse_event: ClassVar[str] = "command_started"
kind: Literal["command_started"] = "command_started"
@dataclass
class WorkflowEventData(_SseEventData):
sse_event: ClassVar[str] = "workflow"
session_id: str
operation_id: str
stage_id: str
at_version: int
revision: int
payload: dict[str, object]
kind: Literal["workflow"] = "workflow"
@dataclass
class CanvasEdge:
source: str
target: str
@dataclass
class CanvasEventData(_SseEventData):
sse_event: ClassVar[str] = "canvas"
session_id: str
operation_id: str
stage_id: str
at_version: int
revision: int
event: CanvasEvent
kind: Literal["canvas"] = "canvas"
node_id: str | None = None
edge: CanvasEdge | None = None
dify_run_id: str = ""
@dataclass
class AgentMessageEventData(_SseEventData):
"""One incremental assistant-text chunk for an in-flight chat turn.
``answer`` follows the existing Dify ``agent_message`` convention: it is
a delta, not the accumulated answer. ``seq``/``at_version`` identify the
durable assistant item that replaces the transient stream item when the
turn commits.
"""
sse_event: ClassVar[str] = "agent_message"
session_id: str
operation_id: str
id: str
answer: str
seq: int
at_version: int
revision: int
stage_id: str
kind: Literal["agent_message"] = "agent_message"
@dataclass
class ReasoningEventData(_SseEventData):
"""One model-provided reasoning delta for the current operation.
Reasoning is independent from curated execution progress. ``span_id``
identifies the cognitive call within an operation so clients can preserve
stable rendering while deltas arrive.
"""
sse_event: ClassVar[str] = "reasoning"
session_id: str
operation_id: str
stage_id: str
at_version: int
revision: int
span_id: str
delta: str
kind: Literal["reasoning"] = "reasoning"
@dataclass
class ProgressEventData(_SseEventData):
"""A replaceable execution snapshot for an in-flight operation.
``revision`` is monotonic within ``operation_id``. ``at_version`` points
at the next durable transition that supersedes this snapshot, allowing a
client to discard delayed progress after it has already applied a commit.
The payload contains observable actions only. Model-provided reasoning is
delivered separately through ``ReasoningEventData``.
"""
sse_event: ClassVar[str] = "progress"
session_id: str
operation_id: str
stage_id: str
at_version: int
revision: int
execution: ExecutionProgress
kind: Literal["progress"] = "progress"
@dataclass
class CommitEventData(_SseEventData):
"""One CAS-backed conversation increment emitted before terminal state."""
sse_event: ClassVar[str] = "commit"
session_id: str
operation_id: str
stage_id: str
at_version: int
version: int
state: str
settled: bool
items: list[ConversationItem]
kind: Literal["commit"] = "commit"
@dataclass
class StateEventData(SessionView, _SseEventData):
sse_event: ClassVar[str] = "state"
kind: Literal["state"] = "state"
@dataclass
class ErrorEventData(_SseEventData):
sse_event: ClassVar[str] = "error"
error: str
kind: Literal["error"] = "error"
code: str | None = None
message: str | None = None
recoverable: bool | None = None