"""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" @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 @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 actions: list[Action] = field(default_factory=list) active_interaction: ActiveInteraction | None = None checkpoint: CheckpointRef | None = None recovery: RecoveryRef | None = None model: SessionModel | None = None app_revision: AppRevision | None = None @dataclass class ConflictPolicyOption: """One selectable conflict-resolution policy in a ``resource_select`` card.""" id: str label: str recommended: bool = False @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 + single-select ``conflict_policy_options``. """ kind: ClassVar[CardKind] = CardKind.RESOURCE_SELECT recommended: list[ResourceOption] = field(default_factory=list) conflict_policy_options: list[ConflictPolicyOption] = 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) @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 @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: conflict_policy: str 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 NodeEventData(_SseEventData): sse_event: ClassVar[str] = "node" session_id: str operation_id: str stage_id: str at_version: int revision: int node_id: str title: str status: str error: str kind: Literal["node"] = "node" @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 @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