Files
dify/api/core/dify_builder/contract.py
T
Charles YaoandClaude Opus 4.8 d2cfaf7f72 fix(dify_builder): narrow input-failure signals, fix build delete/create id collision
Final-review fix wave for the testdata-gate + start-selector slice:

- I1 (Important): is_input_failure's signal list included the bare
  "required" (and generic "variable not found"), which also fire on
  config/runtime faults unrelated to test inputs -- misrouting those
  failures to the *_AWAIT_TESTDATA gate, where the diagnosed auto-repair
  is never offered and re-running just loops at the gate. Narrowed
  _INPUT_FAILURE_SIGNALS to ("file variable", "not provided", "missing
  input"), favoring precision per the spec's ambiguous-failure ->
  config-repair safe default. The real file-input E2E error ("File
  variable not found for selector: [...]") still matches via "file
  variable".

- M2 (Minor, latent): handle_plan_approval's from-scratch delete-then-
  create-filter path could drop a generator create_node/connect that
  reuses a just-deleted placeholder start's id, since _already_present
  compared against the PRE-delete existing_node_ids/existing_edges --
  delete with no re-create, silently vanishing the node. Deleted ids are
  now excluded from that comparison set; the loop-back path (no deletes)
  is unaffected.

- Cleanups: state.py's Build/Edit state-count comments (14->15, 9->10,
  verified against the actual enum members), FormCard's docstring now
  lists the "testdata" variant, and handle_plan_approval binds
  current_graph's nodes to one local instead of calling .get() 3x and
  documents the delete-placeholder branch.

Tests: extended test_is_input_failure_matches_input_signals's sibling
with a config "required" case (red before the signal-list fix, green
after), and added a plan_approval test where the generator's create_node
reuses the deleted placeholder start's id (red before the id-exclusion
fix -- a dangling connect ValueError -- green after).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 10:03:48 -07:00

597 lines
15 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 ClassVar
from core.dify_builder.models import ConversationItem
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).
``thinking``/``executing`` => ``canvas_read_only = true`` (WORKING).
``waiting_*``/``paused`` => editable (WAITING).
``complete``/``failed`` => terminal.
"""
THINKING = "thinking"
EXECUTING = "executing"
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"
@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
# ---------------------------------------------------------------------------
# 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. ``type`` in text|textarea|select|bool."""
key: str
label: str
type: str
options: list[str] = field(default_factory=list)
@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
@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
@dataclass
class TraceStep:
"""One step of an ``assistant_turn``'s trace (spec §4.2).
``state`` in pending|active|done|stopped; ``tone`` in
neutral|success|error; ``canvas_event`` fires as the step activates.
"""
id: str
label: str
state: str
tone: str = "neutral"
canvas_event: str | None = None
@dataclass
class Trace:
"""The streamable trace nested in an ``assistant_turn`` (spec §4.2).
``status`` in running|completed|error|stopped.
"""
status: str
steps: list[TraceStep] = 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
@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
trace: Trace
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):
"""Collapsible diff. ``scope`` in annotation|configuration|structure.
The ``view_changes`` action forces ``full_diff_open``.
"""
kind: ClassVar[CardKind] = CardKind.CHANGE_SET
count: int
changes: list[str]
scope: str
full_diff_open: bool = False
@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