Files
dify/api/services/dify_builder/service.py
T

2084 lines
89 KiB
Python

"""The Dify Builder usecase: session lifecycle + dispatch.
Port of dify-enterprise/server/pkg/enterprise/biz/dify_builder/usecase.go.
Dependency-injected (``repo``, ``session_lock``, ``enqueue_fn`` are
constructor args) so it stays unit-testable without importing the real
Redis-backed ``session_lock`` module or Celery -- those are wired in by the
caller (P3b Task 4: the Flask controller + the Celery task's ``.delay``).
"""
import json
import logging
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, nullcontext, suppress
from dataclasses import asdict, replace
from enum import StrEnum
from typing import TYPE_CHECKING, Protocol
from uuid import NAMESPACE_URL, uuid4, uuid5
from core.dify_builder.contract import (
CONFIRM_ACTION_ID,
ActionKind,
ActiveInteraction,
AppRevision,
CardOption,
CheckpointRef,
ConversationPage,
Decision,
InteractionResponseField,
InteractionResponseItem,
Phase,
PreflightContextCard,
PreflightIssue,
RecoveryClass,
RunContextCard,
RunStatus,
SessionModel,
SessionView,
UserItem,
)
from core.dify_builder.contract import Action as UiAction
from core.dify_builder.errors import BadRequestError, BusyError, ConflictError, NotFoundError
from core.dify_builder.models import (
CONFIRMED_TOOL_ACTION,
Action,
Actor,
ChecklistError,
ConversationItem,
DifyBuilderContext,
EntryMode,
PublicationDecision,
Run,
Session,
)
from core.dify_builder.state import PcState, canvas_read_only, is_terminal, is_waiting, is_working
from runtime_contracts.builder.copy_ids import BuilderCopyId
from runtime_contracts.builder.execution import canonical_digest
from runtime_contracts.builder.presentation import BuilderText, copy_text, stored_text, text_parts
from services.dify_builder import trace_diagnostics
from services.dify_builder.action_policy import requires_app_revision
from services.dify_builder.agent.model_resolver import validate_model_config
from services.dify_builder.application import recovery
from services.dify_builder.application.execution_policy import (
BuilderExecutionPolicyError,
HttpFixtureSetV1,
decode_http_fixture_submission,
)
from services.dify_builder.application.ports import Repository
from services.dify_builder.business_acceptance import context_publication_decision
from services.dify_builder.receipt_snapshot import apply_receipt_snapshot
from services.dify_builder.repair_gate import NO_REPAIR_ACTIONS, NO_REPAIR_DECISION_COPY
from services.dify_builder.test_actions import TEST_ACTIONS, TestActionPort, parse_test_action
if TYPE_CHECKING:
from core.dify_builder.real_call_consent import ConsentReceipt
from services.dify_builder.real_call_consent_service import BuilderRealCallConsentService
logger = logging.getLogger(__name__)
__all__ = [
"AppAccess",
"DifyBuilderService",
"SessionLock",
"SessionView",
"resolve_action_kind",
"resolve_submitted_action",
]
class AppAccess(StrEnum):
"""App-scoped permission tier required by one Builder operation.
Every tier includes the base app-edit check in the production authorizer;
test/run and release additionally enforce their dedicated RBAC scenes.
"""
EDIT = "edit"
TEST_AND_RUN = "test_and_run"
RELEASE = "release"
class SessionLock(Protocol):
"""The per-session advance gate. Both the ``session_lock`` module and
the in-memory fake used in tests satisfy this structurally."""
def acquire(self, session_id: str, *, generation: int | None = None) -> str | None: ...
def release(self, session_id: str, token: str) -> None: ...
def exists(self, session_id: str) -> bool: ...
def current_owner(self, session_id: str) -> str | None: ...
class _SessionSubscription(Protocol):
"""The subscription surface used by command streams."""
def receive(self, timeout: float | None = 0.1) -> bytes | None: ...
def close(self) -> None: ...
_PHASE_FOR: dict[PcState, Phase] = {
# Fix.
PcState.FIX_DIAGNOSE: Phase.UNDERSTAND,
PcState.FIX_PROPOSE: Phase.PLAN,
PcState.FIX_AWAIT_APPROVAL: Phase.PLAN,
PcState.FIX_APPLY: Phase.MODIFY,
PcState.FIX_AWAIT_VERIFY: Phase.TEST,
PcState.FIX_AWAIT_TESTDATA: Phase.TEST,
PcState.FIX_VERIFY: Phase.TEST,
PcState.FIX_AWAIT_DECISION: Phase.REVIEW,
PcState.FIX_PUBLISH: Phase.PUBLISH,
# Checklist.
PcState.CHECKLIST_DIAGNOSE: Phase.UNDERSTAND,
PcState.CHECKLIST_PROPOSE: Phase.PLAN,
PcState.CHECKLIST_AWAIT_RECHECK: Phase.TEST,
# Terminal.
PcState.SUCCESS: Phase.COMPLETE,
PcState.FAILED: Phase.COMPLETE,
# Build.
PcState.BUILD_CAPABILITY_CHECK: Phase.UNDERSTAND,
PcState.BUILD_GOAL_ANALYSIS: Phase.CLARIFY,
PcState.BUILD_INITIAL_PLAN: Phase.PLAN,
PcState.BUILD_RESOURCE_RECOMMENDATION: Phase.RESOURCES,
PcState.BUILD_PLAN_APPROVAL: Phase.PLAN,
PcState.BUILD_EXECUTION: Phase.MODIFY,
PcState.BUILD_AWAIT_TESTDATA: Phase.TEST,
PcState.BUILD_TEST_AND_REPAIR: Phase.TEST,
PcState.BUILD_AWAIT_REPAIR: Phase.TEST,
PcState.BUILD_REVIEW: Phase.REVIEW,
PcState.BUILD_PUBLISH: Phase.PUBLISH,
PcState.BUILD_COMPLETE: Phase.COMPLETE,
PcState.BUILD_REVERTED: Phase.PLAN,
# Edit.
PcState.EDIT_CAPABILITY_CHECK: Phase.UNDERSTAND,
PcState.EDIT_IMPACT_ANALYSIS: Phase.CLARIFY,
PcState.EDIT_PLAN_APPROVAL: Phase.PLAN,
PcState.EDIT_APPLY_CHANGES: Phase.MODIFY,
PcState.EDIT_AWAIT_TESTDATA: Phase.TEST,
PcState.EDIT_TEST_AFFECTED_PATHS: Phase.TEST,
PcState.EDIT_AWAIT_REPAIR: Phase.TEST,
PcState.EDIT_REVIEW: Phase.REVIEW,
PcState.EDIT_PUBLISH: Phase.PUBLISH,
PcState.EDIT_COMPLETE: Phase.COMPLETE,
PcState.EDIT_REVERTED: Phase.PLAN,
}
def _phase_for(state: PcState) -> Phase:
"""Map a ``PcState`` to the coarse UX ``Phase`` shown in the panel
header (spec §2, §7). Defensive fallback for any unmapped state."""
return _PHASE_FOR.get(state, Phase.UNDERSTAND)
_ACTIONS_FOR: dict[PcState, list[UiAction]] = {
PcState.FIX_AWAIT_APPROVAL: [
UiAction(id="approve_plan", label=copy_text(BuilderCopyId.ACTIONS_APPROVE_FIX), kind=ActionKind.PRIMARY),
UiAction(id="reject_repair", label=copy_text(BuilderCopyId.ACTIONS_REJECT), kind=ActionKind.DESTRUCTIVE),
],
PcState.FIX_AWAIT_VERIFY: [
UiAction(id="run_validation", label=copy_text(BuilderCopyId.ACTIONS_RUN_VALIDATION), kind=ActionKind.PRIMARY),
UiAction(id="revert", label=copy_text(BuilderCopyId.ACTIONS_REVERT), kind=ActionKind.DESTRUCTIVE),
],
PcState.FIX_AWAIT_TESTDATA: [
UiAction(id="provide_testdata", label=copy_text(BuilderCopyId.FORMS_TEST_DATA_TITLE), kind=ActionKind.PRIMARY),
],
PcState.FIX_AWAIT_DECISION: [
UiAction(id="publish_fix", label=copy_text(BuilderCopyId.ACTIONS_PUBLISH_FIX), kind=ActionKind.PRIMARY),
UiAction(id="keep_draft", label=copy_text(BuilderCopyId.ACTIONS_KEEP_DRAFT), kind=ActionKind.SECONDARY),
UiAction(id="continue_adjusting", label=copy_text(BuilderCopyId.ACTIONS_FIX_AGAIN), kind=ActionKind.SECONDARY),
UiAction(id="revert", label=copy_text(BuilderCopyId.ACTIONS_REVERT), kind=ActionKind.DESTRUCTIVE),
],
PcState.CHECKLIST_AWAIT_RECHECK: [
UiAction(id="recheck", label=copy_text(BuilderCopyId.ACTIONS_RE_CHECK), kind=ActionKind.PRIMARY),
UiAction(id="revert", label=copy_text(BuilderCopyId.ACTIONS_REVERT), kind=ActionKind.DESTRUCTIVE),
],
PcState.FAILED: [
UiAction(
id="restart", label=copy_text(BuilderCopyId.ACTIONS_RESTART_FROM_CURRENT_DRAFT), kind=ActionKind.PRIMARY
),
],
# Build (Slice 2). next_state/canvas_event carry the frozen state-map hints.
PcState.BUILD_GOAL_ANALYSIS: [
UiAction(
id="submit_requirements",
label=copy_text(BuilderCopyId.ACTIONS_SUBMIT_REQUIREMENTS),
kind=ActionKind.PRIMARY,
next_state="build.resource_recommendation",
),
],
# build.initial_plan offers no UI action: it is a working/pass-through
# state now (state.py), not a waiting one -- the straight-through path no
# longer stops there (submit_requirements now goes directly to resource
# discovery -- see handlers_build.handle_goal_analysis), and the
# continue_adjusting/retry_after_revert loop-back that still lands there
# runs its handler unconditionally on entry, with no action to gate on.
PcState.BUILD_RESOURCE_RECOMMENDATION: [
UiAction(
id="confirm_resources",
label=copy_text(BuilderCopyId.ACTIONS_CONFIRM_RESOURCES),
kind=ActionKind.PRIMARY,
next_state="build.plan_approval",
),
],
PcState.BUILD_PLAN_APPROVAL: [
UiAction(
id="approve_plan",
label=copy_text(BuilderCopyId.ACTIONS_APPROVE_PLAN),
kind=ActionKind.PRIMARY,
next_state="build.execution",
),
],
PcState.BUILD_EXECUTION: [
UiAction(
id="run_test",
label=copy_text(BuilderCopyId.ACTIONS_RUN_TEST),
kind=ActionKind.PRIMARY,
next_state="build.test_and_repair",
),
UiAction(
id="revert",
label=copy_text(BuilderCopyId.ACTIONS_REVERT),
kind=ActionKind.DESTRUCTIVE,
next_state="build.reverted",
canvas_event="revert_checkpoint",
),
],
PcState.BUILD_AWAIT_TESTDATA: [
UiAction(id="provide_testdata", label=copy_text(BuilderCopyId.FORMS_TEST_DATA_TITLE), kind=ActionKind.PRIMARY),
],
PcState.BUILD_AWAIT_REPAIR: [
UiAction(
id="approve_plan",
label=copy_text(BuilderCopyId.ACTIONS_APPLY_THE_FIX),
kind=ActionKind.PRIMARY,
next_state="build.execution",
),
UiAction(
id="keep_draft",
label=copy_text(BuilderCopyId.ACTIONS_KEEP_DRAFT),
kind=ActionKind.SECONDARY,
next_state="build.complete",
),
UiAction(
id="revert",
label=copy_text(BuilderCopyId.ACTIONS_REVERT),
kind=ActionKind.DESTRUCTIVE,
next_state="build.reverted",
canvas_event="revert_checkpoint",
),
],
PcState.BUILD_REVIEW: [
UiAction(
id="publish_workflow",
label=copy_text(BuilderCopyId.ACTIONS_PUBLISH),
kind=ActionKind.PRIMARY,
next_state="build.publish",
canvas_event="publish_workflow",
),
UiAction(
id="keep_draft",
label=copy_text(BuilderCopyId.ACTIONS_KEEP_DRAFT),
kind=ActionKind.SECONDARY,
next_state="build.governance_feedback",
),
UiAction(
id="continue_adjusting",
label=copy_text(BuilderCopyId.ACTIONS_CONTINUE_ADJUSTING),
kind=ActionKind.SECONDARY,
next_state="build.initial_plan",
),
UiAction(
id="revert",
label=copy_text(BuilderCopyId.ACTIONS_REVERT),
kind=ActionKind.DESTRUCTIVE,
next_state="build.reverted",
canvas_event="revert_checkpoint",
),
],
PcState.BUILD_REVERTED: [
UiAction(
id="retry_after_revert",
label=copy_text(BuilderCopyId.ACTIONS_RETRY),
kind=ActionKind.PRIMARY,
next_state="build.initial_plan",
),
],
# Edit (Slice 3). next_state/canvas_event carry the frozen state-map hints.
PcState.EDIT_CAPABILITY_CHECK: [
UiAction(
id="send_edit_goal",
label=copy_text(BuilderCopyId.ACTIONS_SEND),
kind=ActionKind.PRIMARY,
next_state="edit.impact_analysis",
),
],
PcState.EDIT_IMPACT_ANALYSIS: [
UiAction(
id="submit_edit_rules",
label=copy_text(BuilderCopyId.ACTIONS_SUBMIT_RULES),
kind=ActionKind.PRIMARY,
next_state="edit.plan_approval",
),
],
# Approve is NOT the only exit: an approval the engine refuses leaves the
# user parked here with the plan unchanged, so the gate also offers a way
# back to the rules and a way out entirely (triage
# edit-branch-failure-2026-09-22, "Hard dead end").
PcState.EDIT_PLAN_APPROVAL: [
UiAction(
id="approve_plan",
label=copy_text(BuilderCopyId.ACTIONS_APPROVE_CHANGES),
kind=ActionKind.PRIMARY,
next_state="edit.apply_changes",
),
UiAction(
id="continue_adjusting",
label=copy_text(BuilderCopyId.ACTIONS_CONTINUE_ADJUSTING),
kind=ActionKind.SECONDARY,
next_state="edit.impact_analysis",
),
# Same ``revert`` id and the same perform_revert path as every other
# gate, but labelled for what it does HERE: nothing has been applied
# yet, so this discards the proposal rather than undoing an edit the
# user can see on the canvas.
UiAction(
id="revert",
label=copy_text(BuilderCopyId.ACTIONS_DISCARD_THIS_CHANGE_PLAN),
kind=ActionKind.DESTRUCTIVE,
next_state="edit.reverted",
canvas_event="revert_checkpoint",
),
],
PcState.EDIT_APPLY_CHANGES: [
UiAction(
id="run_affected_tests",
label=copy_text(BuilderCopyId.ACTIONS_RUN_AFFECTED_TESTS),
kind=ActionKind.PRIMARY,
next_state="edit.test_affected_paths",
),
UiAction(
id="revert",
label=copy_text(BuilderCopyId.ACTIONS_REVERT),
kind=ActionKind.DESTRUCTIVE,
next_state="edit.reverted",
canvas_event="revert_checkpoint",
),
],
PcState.EDIT_AWAIT_TESTDATA: [
UiAction(id="provide_testdata", label=copy_text(BuilderCopyId.FORMS_TEST_DATA_TITLE), kind=ActionKind.PRIMARY),
],
PcState.EDIT_AWAIT_REPAIR: [
UiAction(
id="approve_plan",
label=copy_text(BuilderCopyId.ACTIONS_APPLY_THE_FIX),
kind=ActionKind.PRIMARY,
next_state="edit.apply_changes",
),
UiAction(
id="keep_draft",
label=copy_text(BuilderCopyId.ACTIONS_KEEP_DRAFT),
kind=ActionKind.SECONDARY,
next_state="edit.complete",
),
UiAction(
id="revert",
label=copy_text(BuilderCopyId.ACTIONS_REVERT),
kind=ActionKind.DESTRUCTIVE,
next_state="edit.reverted",
canvas_event="revert_checkpoint",
),
],
PcState.EDIT_REVIEW: [
UiAction(
id="publish_workflow",
label=copy_text(BuilderCopyId.ACTIONS_PUBLISH),
kind=ActionKind.PRIMARY,
next_state="edit.publish",
canvas_event="publish_workflow",
),
UiAction(
id="keep_draft",
label=copy_text(BuilderCopyId.ACTIONS_KEEP_DRAFT),
kind=ActionKind.SECONDARY,
next_state="edit.complete",
),
UiAction(
id="continue_adjusting",
label=copy_text(BuilderCopyId.ACTIONS_CONTINUE_ADJUSTING),
kind=ActionKind.SECONDARY,
next_state="edit.impact_analysis",
),
UiAction(
id="revert",
label=copy_text(BuilderCopyId.ACTIONS_REVERT),
kind=ActionKind.DESTRUCTIVE,
next_state="edit.reverted",
canvas_event="revert_checkpoint",
),
],
PcState.EDIT_REVERTED: [
UiAction(
id="retry_after_revert",
label=copy_text(BuilderCopyId.ACTIONS_RETRY),
kind=ActionKind.PRIMARY,
next_state="edit.plan_approval",
),
],
}
def _actions_for(
state: PcState,
fc: DifyBuilderContext | None = None,
*,
interrupted: bool = False,
app_revision_conflicted: bool = False,
publication: PublicationDecision | None = None,
) -> list[UiAction]:
"""Return only actions legal for the projected lifecycle condition."""
if state == PcState.FAILED:
# A durable, deterministic failure -- re-running the same handler would
# just fail again, so only offer a restart from the current draft.
return [
UiAction(
id="restart", label=copy_text(BuilderCopyId.ACTIONS_RESTART_FROM_CURRENT_DRAFT), kind=ActionKind.PRIMARY
)
]
if fc is not None and fc.paused:
return []
recovery_ref = recovery.recovery_ref_for(fc.recovery_class) if fc is not None else None
if recovery_ref is not None:
actions: list[UiAction] = []
if recovery_ref.can_continue:
actions.append(
UiAction(
id="recovery_continue", label=copy_text(BuilderCopyId.ACTIONS_CONTINUE), kind=ActionKind.PRIMARY
)
)
if recovery_ref.can_restart:
actions.append(
UiAction(
id="restart",
label=copy_text(BuilderCopyId.ACTIONS_RESTART_FROM_CURRENT_DRAFT),
kind=ActionKind.SECONDARY,
)
)
return actions
if app_revision_conflicted:
return [
UiAction(
id="check_recovery",
label=copy_text(BuilderCopyId.ACTIONS_REVIEW_DRAFT_CHANGES),
kind=ActionKind.PRIMARY,
)
]
if interrupted:
# Retry re-runs a working handler. Draft drift must first use the
# classification and explicit recovery choices above, just as at a
# waiting gate; only an unchanged draft can retry immediately.
return [
UiAction(id="recovery_continue", label=copy_text(BuilderCopyId.ACTIONS_RETRY), kind=ActionKind.PRIMARY),
UiAction(
id="restart",
label=copy_text(BuilderCopyId.ACTIONS_RESTART_FROM_CURRENT_DRAFT),
kind=ActionKind.SECONDARY,
),
]
actions = list(_ACTIONS_FOR.get(state, []))
retest = {
PcState.BUILD_REVIEW: ("run_test", copy_text(BuilderCopyId.ACTIONS_RUN_TEST)),
PcState.EDIT_REVIEW: ("run_affected_tests", copy_text(BuilderCopyId.ACTIONS_RUN_AFFECTED_TESTS)),
PcState.FIX_AWAIT_DECISION: ("run_validation", copy_text(BuilderCopyId.ACTIONS_RUN_VALIDATION)),
}
if state in retest and (publication is None or not publication.allowed):
actions = [a for a in actions if a.id not in {"publish_workflow", "publish_fix"}]
action_id, label = retest[state]
actions.insert(0, UiAction(id=action_id, label=label, kind=ActionKind.PRIMARY))
if state in {PcState.FIX_AWAIT_APPROVAL, PcState.BUILD_AWAIT_REPAIR, PcState.EDIT_AWAIT_REPAIR} and (
fc is None or not fc.staged_repair
):
if state == PcState.FIX_AWAIT_APPROVAL:
return list(NO_REPAIR_ACTIONS)
return [action for action in actions if action.id != "approve_plan"]
return actions
_ACTION_ID_TO_KIND: dict[str, str] = {
"approve_plan": "approve_repair",
"run_validation": "run_verify",
"publish_fix": "publish",
"continue_adjusting": "re_fix",
"pause": "stop",
"revert": "undo",
"restart": "recovery_restart",
"retry_after_revert": "re_fix",
# provide_testdata / recheck / keep_draft already match handler kinds → passthrough
}
_DECISION_COPY_FOR: dict[PcState, tuple[BuilderText, BuilderText]] = {
PcState.FIX_AWAIT_APPROVAL: (
copy_text(BuilderCopyId.DECISION_HOW_SHOULD_BUILDER_PROCEED_WITH_THIS_FIX),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.FIX_AWAIT_VERIFY: (
copy_text(BuilderCopyId.DECISION_WHAT_SHOULD_BUILDER_DO_WITH_THE_APPLIED_FIX),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.FIX_AWAIT_DECISION: (
copy_text(BuilderCopyId.DECISION_WHAT_SHOULD_HAPPEN_TO_THIS_FIX),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.CHECKLIST_AWAIT_RECHECK: (
copy_text(BuilderCopyId.DECISION_WHAT_SHOULD_BUILDER_DO_NEXT),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.BUILD_PLAN_APPROVAL: (
copy_text(BuilderCopyId.DECISION_IS_THIS_WORKFLOW_PLAN_READY_TO_APPLY),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.BUILD_EXECUTION: (
copy_text(BuilderCopyId.DECISION_WHAT_SHOULD_BUILDER_DO_WITH_THE_BUILT_WORKFLOW),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.BUILD_AWAIT_REPAIR: (
copy_text(BuilderCopyId.DECISION_WHAT_SHOULD_BUILDER_DO_WITH_THE_PROPOSED_REPAIR),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.BUILD_REVIEW: (
copy_text(BuilderCopyId.DECISION_WHAT_SHOULD_HAPPEN_TO_THIS_WORKFLOW),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.BUILD_REVERTED: (
copy_text(BuilderCopyId.DECISION_HOW_SHOULD_BUILDER_CONTINUE_AFTER_THE_REVERT),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.EDIT_PLAN_APPROVAL: (
copy_text(BuilderCopyId.DECISION_IS_THIS_CHANGE_PLAN_READY_TO_APPLY),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.EDIT_APPLY_CHANGES: (
copy_text(BuilderCopyId.DECISION_WHAT_SHOULD_BUILDER_DO_WITH_THE_UPDATED_WORKFLOW),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.EDIT_AWAIT_REPAIR: (
copy_text(BuilderCopyId.DECISION_WHAT_SHOULD_BUILDER_DO_WITH_THE_PROPOSED_REPAIR),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.EDIT_REVIEW: (
copy_text(BuilderCopyId.DECISION_WHAT_SHOULD_HAPPEN_TO_THESE_CHANGES),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.EDIT_REVERTED: (
copy_text(BuilderCopyId.DECISION_HOW_SHOULD_BUILDER_CONTINUE_AFTER_THE_REVERT),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
PcState.FAILED: (
copy_text(BuilderCopyId.DECISION_HOW_SHOULD_BUILDER_RECOVER),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
}
_RECOVERY_DESCRIPTION: dict[str, BuilderText] = {
RecoveryClass.UNCHANGED: copy_text(BuilderCopyId.REPLIES_NO_CHANGES_SINCE_YOU_LAST_LEFT_RESUMING_WHERE_YOU_WERE),
RecoveryClass.CONFIG_ONLY: copy_text(
BuilderCopyId.REPLIES_ONLY_NODE_SETTINGS_CHANGED_SINCE_YOU_LEFT_CONTINUE_OR_RESTART
),
RecoveryClass.STRUCTURAL_COMPATIBLE: copy_text(BuilderCopyId.REPLIES_THE_WORKFLOW_STRUCTURE_CHANGED_BUT_THE),
RecoveryClass.STRUCTURAL_INVALIDATING: copy_text(BuilderCopyId.REPLIES_THE_NODES_THIS_TASK_WAS_WORKING),
}
def _decision_copy_for(state: PcState, actions: list[UiAction]) -> tuple[BuilderText, BuilderText]:
if state == PcState.FIX_AWAIT_APPROVAL and any(action.id == "retry_repair" for action in actions):
return NO_REPAIR_DECISION_COPY
if state in {PcState.BUILD_AWAIT_REPAIR, PcState.EDIT_AWAIT_REPAIR} and not any(
action.id == "approve_plan" for action in actions
):
return (
copy_text(BuilderCopyId.DECISION_HOW_SHOULD_BUILDER_CONTINUE),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
)
return _DECISION_COPY_FOR.get(
state,
(
copy_text(BuilderCopyId.DECISION_HOW_SHOULD_BUILDER_CONTINUE),
copy_text(BuilderCopyId.LABELS_CHOOSE_ONE_OPTION_TO_CONTINUE),
),
)
def _decision_for(state: PcState, actions: list[UiAction]) -> Decision | None:
"""Project a non-form gate as one blocking choice interaction."""
renderable = [a for a in actions if a.kind != ActionKind.AUTOMATIC]
if not renderable or any(action.id in _ACTIVE_INTERACTION_CARD_FOR_ACTION for action in renderable):
return None
# Only the first primary is badged: two recommendations is no recommendation.
default_index = next((i for i, a in enumerate(renderable) if a.kind == ActionKind.PRIMARY), -1)
options = [
CardOption(
id=a.id,
label=a.label,
is_default=(i == default_index),
tone="destructive" if a.kind == ActionKind.DESTRUCTIVE else "neutral",
next_state=a.next_state,
canvas_event=a.canvas_event,
)
for i, a in enumerate(renderable)
]
title, description = _decision_copy_for(state, actions)
return Decision(
title=title,
description=description,
options=options,
submit=UiAction(id=CONFIRM_ACTION_ID, label=copy_text(BuilderCopyId.ACTIONS_SUBMIT), kind=ActionKind.PRIMARY),
default_option_id=options[default_index].id if default_index >= 0 else "",
)
_ACTIVE_INTERACTION_CARD_FOR_ACTION: dict[str, tuple[str, str | None]] = {
"submit_requirements": ("form", "build_requirements"),
"submit_edit_rules": ("form", "edit_rules"),
"provide_testdata": ("form", "testdata"),
"confirm_resources": ("resource_select", None),
}
_FORM_COPY_FOR_VARIANT: dict[str, tuple[BuilderText, BuilderText]] = {
"build_requirements": (
copy_text(BuilderCopyId.FORMS_REQUIREMENTS_TITLE),
copy_text(BuilderCopyId.FORMS_REQUIREMENTS_DESCRIPTION),
),
"edit_rules": (
copy_text(BuilderCopyId.FORMS_EDIT_RULES_TITLE),
copy_text(BuilderCopyId.FORMS_EDIT_RULES_DESCRIPTION),
),
"testdata": (
copy_text(BuilderCopyId.FORMS_TEST_DATA_TITLE),
copy_text(BuilderCopyId.FORMS_TEST_DATA_DESCRIPTION),
),
}
def _display_interaction_value(value: object) -> BuilderText:
if value is None or value == "":
return copy_text(BuilderCopyId.VALUES_EMPTY)
if isinstance(value, bool):
return copy_text(BuilderCopyId.VALUES_YES if value else BuilderCopyId.VALUES_NO)
if isinstance(value, list):
return (
text_parts(
*[
part
for i, item in enumerate(value)
for part in ((", ", _display_interaction_value(item)) if i else (_display_interaction_value(item),))
]
)
if value
else copy_text(BuilderCopyId.VALUES_EMPTY)
)
if isinstance(value, dict):
name = value.get("name") or value.get("filename")
if isinstance(name, str) and name:
return name
return json.dumps(value, ensure_ascii=False, default=str, separators=(",", ":"))
return str(value)
def _selected_ui_action(actions: list[UiAction], action: Action) -> UiAction | None:
option_id = action.payload.get("option_id")
if isinstance(option_id, str) and option_id:
selected = next((candidate for candidate in actions if candidate.id == option_id), None)
if selected is not None:
return selected
matching = [candidate for candidate in actions if resolve_action_kind(candidate.id) == action.kind]
return matching[0] if len(matching) == 1 else None
def _interaction_response_for(
repo: Repository,
session: Session,
action: Action,
visible_actions: list[UiAction],
reply_language: str,
) -> InteractionResponseItem | None:
selected_action = _selected_ui_action(visible_actions, action)
if selected_action is None:
return None
card_contract = _ACTIVE_INTERACTION_CARD_FOR_ACTION.get(selected_action.id)
if card_contract is None:
title, _description = _decision_copy_for(session.current_state, visible_actions)
return InteractionResponseItem(
reply_language=reply_language,
interaction_kind="choice",
question=title,
answer=selected_action.label,
submitted_data={"option_id": selected_action.id},
)
card_kind, expected_variant = card_contract
card = repo.get_latest_conversation_item(session.id, frozenset({card_kind}))
if card is None:
return None
if card_kind == "resource_select":
raw_ids = action.payload.get("resource_ids")
selected_ids = [value for value in raw_ids if isinstance(value, str)] if isinstance(raw_ids, list) else []
resources = card.payload.get("recommended")
resource_options = resources if isinstance(resources, list) else []
selected_labels = [
str(resource.get("label") or resource.get("id") or "")
for resource in resource_options
if isinstance(resource, dict) and resource.get("id") in selected_ids
]
return InteractionResponseItem(
reply_language=reply_language,
interaction_kind="resource",
question=stored_text(card.payload.get("title") or copy_text(BuilderCopyId.RESOURCES_TITLE)),
answer=", ".join(label for label in selected_labels if label)
or copy_text(BuilderCopyId.RECEIPTS_NO_RESOURCES_SELECTED),
submitted_data={"resource_ids": selected_ids},
)
variant = str(card.payload.get("variant") or "")
if expected_variant is not None and variant != expected_variant:
return None
raw_values = action.payload.get("inputs") if selected_action.id == "provide_testdata" else action.payload
submitted_values = raw_values if isinstance(raw_values, dict) else {}
raw_fields = card.payload.get("fields")
fields = raw_fields if isinstance(raw_fields, list) else []
response_fields: list[InteractionResponseField] = []
normalized_values: dict[str, object] = {}
for raw_field in fields:
if not isinstance(raw_field, dict):
continue
key = raw_field.get("key")
if not isinstance(key, str) or not key:
continue
value = submitted_values.get(key)
normalized_values[key] = value
response_fields.append(
InteractionResponseField(
key=key,
label=stored_text(raw_field.get("label") or key),
value=value,
display_value=_display_interaction_value(value),
)
)
fallback_title, _fallback_description = _FORM_COPY_FOR_VARIANT.get(
variant,
(copy_text(BuilderCopyId.RECEIPTS_SUBMITTED_FORM), ""),
)
return InteractionResponseItem(
reply_language=reply_language,
interaction_kind="form",
question=stored_text(card.payload.get("title") or fallback_title),
fields=response_fields,
submitted_data=normalized_values,
)
# Handler-facing kinds accepted at each state. This is deliberately explicit:
# several handlers historically treated every unknown kind as a default branch
# (notably fix.await_decision -> keep_draft), so dispatch must never be the
# component that decides whether an action is valid.
_BACKEND_ACTIONS_FOR: dict[PcState, frozenset[str]] = {
PcState.FIX_AWAIT_APPROVAL: frozenset({"approve_repair", "reject_repair", "retry_repair", "finish_repair"}),
PcState.FIX_AWAIT_VERIFY: frozenset({"run_verify", "undo"}),
PcState.FIX_AWAIT_TESTDATA: frozenset({"provide_testdata"}),
PcState.FIX_AWAIT_DECISION: frozenset({"run_verify", "publish", "keep_draft", "re_fix", "undo"}),
PcState.CHECKLIST_AWAIT_RECHECK: frozenset({"recheck", "undo"}),
PcState.BUILD_GOAL_ANALYSIS: frozenset({"submit_requirements"}),
# BUILD_INITIAL_PLAN is a working/pass-through state now (state.py) -- it
# no longer gates on any action, so it has no entry here, matching every
# other working Build/Edit state (BUILD_TEST_AND_REPAIR, BUILD_PUBLISH, ...).
PcState.BUILD_RESOURCE_RECOMMENDATION: frozenset({"confirm_resources"}),
PcState.BUILD_PLAN_APPROVAL: frozenset({"approve_repair"}),
PcState.BUILD_EXECUTION: frozenset({"run_test", "undo"}),
PcState.BUILD_AWAIT_TESTDATA: frozenset({"provide_testdata"}),
PcState.BUILD_AWAIT_REPAIR: frozenset({"approve_repair", "keep_draft", "undo"}),
PcState.BUILD_REVIEW: frozenset({"run_test", "publish_workflow", "keep_draft", "re_fix", "undo"}),
PcState.BUILD_REVERTED: frozenset({"re_fix"}),
PcState.EDIT_CAPABILITY_CHECK: frozenset({"send_edit_goal"}),
PcState.EDIT_IMPACT_ANALYSIS: frozenset({"submit_edit_rules"}),
PcState.EDIT_PLAN_APPROVAL: frozenset({"approve_repair", "re_fix", "undo"}),
PcState.EDIT_APPLY_CHANGES: frozenset({"run_affected_tests", "undo"}),
PcState.EDIT_AWAIT_TESTDATA: frozenset({"provide_testdata"}),
PcState.EDIT_AWAIT_REPAIR: frozenset({"approve_repair", "keep_draft", "undo"}),
PcState.EDIT_REVIEW: frozenset({"run_affected_tests", "publish_workflow", "keep_draft", "re_fix", "undo"}),
PcState.EDIT_REVERTED: frozenset({"re_fix"}),
}
_TEST_AND_RUN_ACTIONS = frozenset(
{
"provide_testdata",
"recheck",
"run_affected_tests",
"run_test",
"run_verify",
"stop",
"resume",
}
)
_RELEASE_ACTIONS = frozenset({"publish", "publish_workflow"})
_REPAIR_THEN_RETEST_STATES = frozenset({PcState.BUILD_AWAIT_REPAIR, PcState.EDIT_AWAIT_REPAIR})
# Working states whose interrupted-step Retry (recovery_continue) re-runs a
# privileged op: publish (RELEASE) or a draft test run (TEST_AND_RUN). A Retry
# re-invokes the state's handler, so it must be gated at that op's tier, not the
# base EDIT the recovery actions otherwise carry.
_PUBLISH_WORKING_STATES = frozenset({PcState.BUILD_PUBLISH, PcState.FIX_PUBLISH, PcState.EDIT_PUBLISH})
_TEST_WORKING_STATES = frozenset({PcState.BUILD_TEST_AND_REPAIR, PcState.FIX_VERIFY, PcState.EDIT_TEST_AFFECTED_PATHS})
def _app_access_for_action(state: PcState, kind: str) -> AppAccess:
if kind in _RELEASE_ACTIONS:
return AppAccess.RELEASE
if kind in _TEST_AND_RUN_ACTIONS or (kind == "approve_repair" and state in _REPAIR_THEN_RETEST_STATES):
return AppAccess.TEST_AND_RUN
# A recovery_continue (Retry) re-runs the interrupted working handler, which
# re-invokes the state's privileged op -- enforce that op's tier, not base EDIT.
# (recovery_restart only resets + re-enters the entry state, so it stays EDIT.)
if kind == "recovery_continue":
if state in _PUBLISH_WORKING_STATES:
return AppAccess.RELEASE
if state in _TEST_WORKING_STATES:
return AppAccess.TEST_AND_RUN
return AppAccess.EDIT
def _internal_action_allowed(state: PcState, fc: DifyBuilderContext, kind: str) -> bool:
"""Validate runner/service short-circuits against their product state.
These commands do not appear in ``_ACTIONS_FOR``, but they are not global:
transcript/model commands operate only at a waiting gate, pause transitions
must change the current pause flag, and recovery choices must match the
current terminal/interrupted/drift-recovery condition.
"""
if state == PcState.FAILED:
# Durable, deterministic failure -- only a restart is meaningful.
return kind == "recovery_restart"
if is_terminal(state):
return kind == "message" and not fc.paused and not fc.recovery_class
if not (is_waiting(state) or is_working(state)):
return False
if fc.paused:
return kind == "resume"
recovery_ref = recovery.recovery_ref_for(fc.recovery_class)
if recovery_ref is not None:
if kind == "recovery_continue":
return recovery_ref.can_continue
if kind == "recovery_restart":
return recovery_ref.can_restart
return False
# Classifying a working-state interrupt must settle without re-running its
# handler. A live worker is protected by dispatch's advance lock.
if is_working(state):
return kind in {"check_recovery", "recovery_continue", "recovery_restart"}
if kind in {"check_recovery", "message", "update_model"}:
return True
if kind == "stop":
return not fc.paused
if kind == "resume":
return fc.paused
return False
def resolve_action_kind(raw: str) -> str:
"""Map a frontend action_id to the engine handler kind.
Action IDs that already match their handler kind pass through unchanged.
"""
return _ACTION_ID_TO_KIND.get(raw, raw)
def resolve_submitted_action(action_id: str, payload: dict | None) -> str:
"""Map what the client posted to the engine handler kind.
The interaction dock posts ``confirm`` and names its choice in
``payload["option_id"]``. Handler-facing callers may already use the
resolved action id. A ``confirm`` naming no option resolves to "", which
the per-state legality check then rejects.
"""
if action_id == CONFIRM_ACTION_ID:
action_id = str(payload.get("option_id") or "") if isinstance(payload, dict) else ""
return resolve_action_kind(action_id)
_WAITING_INPUT_STATES = frozenset(
{
PcState.FIX_AWAIT_TESTDATA,
PcState.BUILD_GOAL_ANALYSIS,
PcState.BUILD_AWAIT_TESTDATA,
PcState.EDIT_CAPABILITY_CHECK,
PcState.EDIT_IMPACT_ANALYSIS,
PcState.EDIT_AWAIT_TESTDATA,
}
)
def _run_status(state: PcState, paused: bool = False) -> RunStatus:
"""Port of Go ``runStatusFor``, widened to the ``RunStatus`` enum (spec
§2). Deliberate wire-value change from the old string: ``waiting-input``
(hyphen) -> ``RunStatus.WAITING_INPUT`` = ``"waiting_input"``
(underscore).
Terminal check comes before waiting/working: ``PcState.BUILD_COMPLETE``
and ``PcState.EDIT_COMPLETE`` are terminal (spec §7.1/§7.2, ``run_status:
complete``) but are not in ``_WORKING``/``_WAITING`` and are not
``SUCCESS``/``FAILED`` -- without this ordering they'd wrongly fall
through to PROCESSING.
A safely settled task Stop pauses working and waiting states while retaining
their program counter. Terminal outcomes always win over the paused flag.
"""
if state == PcState.FAILED:
return RunStatus.FAILED
if is_terminal(state): # SUCCESS, BUILD_COMPLETE, EDIT_COMPLETE
return RunStatus.COMPLETE
if paused:
return RunStatus.PAUSED
if state in _WAITING_INPUT_STATES:
return RunStatus.WAITING_INPUT
if is_waiting(state):
return RunStatus.WAITING_CONFIRMATION
if is_working(state):
return RunStatus.PROCESSING
return RunStatus.PROCESSING # defensive; unreachable for classified states
class DifyBuilderService:
"""Session lifecycle (create/view/action/message) + dispatch.
Port of Go ``usecase.go``'s ``Usecase``.
"""
def __init__(
self,
repo: Repository,
session_lock: SessionLock,
enqueue_fn: Callable[[str, Action, Actor, str], None],
subscribe_fn: Callable[[str], _SessionSubscription | None] | None = None,
authorize_app_fn: Callable[[Actor, str, AppAccess], None] | None = None,
get_app_revision_fn: Callable[[str, Actor], str] | None = None,
get_app_name_fn: Callable[[str, Actor], str] | None = None,
get_verification_identity_fn: Callable[[str, Actor], tuple[str, str]] | None = None,
stamp_http_fixtures_fn: Callable[..., HttpFixtureSetV1] | None = None,
real_call_consent: "BuilderRealCallConsentService | None" = None,
test_actions: TestActionPort | None = None,
read_task_dsl_fn: Callable | None = None,
stream_read_scope_fn: Callable[[], AbstractContextManager] | None = None,
) -> None:
# None: this instance cannot execute confirmed Tool tests (all existing callers).
self._real_call_consent = real_call_consent
self._test_actions = test_actions
if read_task_dsl_fn is None:
def read_task_dsl_fn(app_id, actor):
from services.dify_builder.dify_port import WorkflowServiceDifyPort
return WorkflowServiceDifyPort().read_task_dsl(app_id, actor)
self._read_task_dsl_fn = read_task_dsl_fn
self._stream_read_scope_fn = stream_read_scope_fn or nullcontext
self._stamp_http_fixtures_fn = stamp_http_fixtures_fn
self._repo = repo
self._session_lock = session_lock
self._enqueue_fn = enqueue_fn
self._subscribe_fn = subscribe_fn or (lambda _sid: None)
self._authorize_app_fn = authorize_app_fn or (lambda _actor, _app_id, _access: None)
self._get_app_revision_fn = get_app_revision_fn or (lambda _app_id, _actor: "")
self._get_verification_identity_fn = get_verification_identity_fn or (lambda _app_id, _actor: ("", ""))
self._get_app_name_fn = get_app_name_fn or (lambda _app_id, _actor: "")
def _get_app_revision(self, app_id: str, actor: Actor) -> str:
revision = self._get_app_revision_fn(app_id, actor)
return revision if isinstance(revision, str) else ""
def _get_app_name(self, app_id: str, actor: Actor) -> str:
name = self._get_app_name_fn(app_id, actor)
return name if isinstance(name, str) else ""
def _authorize_app(self, app_id: str, actor: Actor, access: AppAccess = AppAccess.EDIT) -> str:
if not isinstance(app_id, str) or not (app_id := app_id.strip()):
raise BadRequestError("app_id is required")
self._authorize_app_fn(actor, app_id, access)
return app_id
def _get_authorized_session(
self, session_id: str, actor: Actor, access: AppAccess = AppAccess.EDIT
) -> tuple[Session, DifyBuilderContext]:
s, fc = self._repo.get_session(session_id)
if s.owner_account_id != actor.account_id or s.tenant_id != actor.tenant_id:
raise NotFoundError("session not found")
self._authorize_app(s.app_id, actor, access)
from services.dify_builder.conversation_tasks import active_entry_mode
return replace(s, entry_mode=active_entry_mode(s, fc)), fc
def authorize_session(self, session_id: str, actor: Actor) -> None:
"""Authorize a session before opening an SSE subscription.
The stream route calls this once before subscribing, then reads the
bounded state after subscription through ``get_session_view`` to
preserve the no-lost-state-event ordering.
"""
self._get_authorized_session(session_id, actor)
@staticmethod
def _validate_model_config(actor: Actor, model_config: dict | None) -> dict:
if model_config is not None and not isinstance(model_config, dict):
raise BadRequestError("model_config must be an object")
return validate_model_config(actor.tenant_id, model_config)
@staticmethod
def _parse_checklist_errors(
checklist_errors: list[ChecklistError] | list[dict] | None,
) -> list[ChecklistError] | None:
if checklist_errors is None:
return None
if not isinstance(checklist_errors, list):
raise BadRequestError("checklist_errors must be a list")
expected_fields = {"messages", "node_id", "node_type", "plugin_missing", "title", "unconnected"}
parsed: list[ChecklistError] = []
for entry in checklist_errors:
if isinstance(entry, ChecklistError):
values = {
"node_id": entry.node_id,
"node_type": entry.node_type,
"title": entry.title,
"messages": entry.messages,
"unconnected": entry.unconnected,
"plugin_missing": entry.plugin_missing,
}
elif isinstance(entry, dict) and set(entry) == expected_fields:
values = entry
else:
raise BadRequestError("invalid checklist_errors item")
messages = values["messages"]
if (
not isinstance(values["node_id"], str)
or not isinstance(values["node_type"], str)
or not isinstance(values["title"], str)
or not isinstance(messages, list)
or not all(isinstance(message, str) for message in messages)
or type(values["unconnected"]) is not bool
or type(values["plugin_missing"]) is not bool
):
raise BadRequestError("invalid checklist_errors item")
parsed.append(
ChecklistError(
node_id=values["node_id"],
node_type=values["node_type"],
title=values["title"],
messages=list(messages),
unconnected=values["unconnected"],
plugin_missing=values["plugin_missing"],
)
)
return parsed
def _prepare_fix_session(
self,
app_id: str,
actor: Actor,
failed_run_id: str | None = None,
checklist_errors: list[ChecklistError] | list[dict] | None = None,
model_config: dict | None = None,
) -> tuple[str, Action]:
"""Shared setup for ``create_fix_session``/``create_fix_session_stream``:
everything through persisting the session (and the failed-run record)
up to -- but not including -- the ``request_fix`` dispatch.
``failed_run_id`` is the id of the **Dify workflow run** that failed
(what the frontend has). In fix mode we record it as an immutable
``original-failed`` ``DifyBuilderRun`` and point ``fc.failed_run_id`` at
that row, so the async ``diagnose`` step can resolve it
(``repo.get_run(fc.failed_run_id)`` -> ``run.dify_run_id`` ->
``dify.node_outputs``). Checklist takes precedence when errors are
present (no failed run on that path).
"""
if failed_run_id is not None and not isinstance(failed_run_id, str):
raise BadRequestError("failed_run_id must be a string")
failed_run_id = (failed_run_id or "").strip() or None
checklist_errors = self._parse_checklist_errors(checklist_errors)
if not failed_run_id and not checklist_errors:
raise BadRequestError("failed_run_id or checklist_errors is required")
app_id = self._authorize_app(app_id, actor)
model_config = self._validate_model_config(actor, model_config)
app_revision = self._get_app_revision(app_id, actor)
failed_run: Run | None = None
if checklist_errors:
entry_mode, state = EntryMode.FIX_CHECKLIST, PcState.CHECKLIST_DIAGNOSE
fc = DifyBuilderContext(
source="checklist",
checklist_errors=checklist_errors,
model_config=model_config,
last_snapshot_hash=app_revision,
)
else:
entry_mode, state = EntryMode.FIX, PcState.FIX_DIAGNOSE
failed_run = Run(
id=str(uuid4()),
kind="original-failed",
dify_run_id=failed_run_id or "",
status="failed",
immutable=True,
)
fc = DifyBuilderContext(
failed_run_id=failed_run.id,
source="run",
model_config=model_config,
last_snapshot_hash=app_revision,
)
s = Session(
app_id=app_id,
tenant_id=actor.tenant_id,
owner_account_id=actor.account_id,
entry_mode=entry_mode,
current_state=state,
)
if checklist_errors:
issues = [
PreflightIssue(
node_id=issue.node_id,
node_type=issue.node_type,
title=issue.title,
messages=list(issue.messages),
unconnected=issue.unconnected,
plugin_missing=issue.plugin_missing,
)
for issue in checklist_errors
]
context_card = PreflightContextCard(
node_count=len({issue.node_id for issue in checklist_errors if issue.node_id}),
issue_count=len(checklist_errors),
issues=issues,
)
else:
context_card = RunContextCard(
run_id=failed_run_id or "",
title="",
error_code="",
message="",
trace_ref="",
)
items = [context_card.to_item(seq=0, at_version=0)]
self._repo.create_session(s, fc, items) # assigns s.id, s.version = 1
if failed_run is not None:
# Persist the failed-run record BEFORE dispatch so the enqueued
# advance's diagnose can resolve fc.failed_run_id.
self._repo.save_run(s.id, failed_run)
return s.id, Action(kind="request_fix", base_version=1, base_app_revision=app_revision)
def create_fix_session(
self,
app_id: str,
actor: Actor,
failed_run_id: str | None = None,
checklist_errors: list[ChecklistError] | list[dict] | None = None,
model_config: dict | None = None,
) -> SessionView:
"""Port of Go ``CreateFixSession``, extended to record the failed run."""
session_id, action = self._prepare_fix_session(app_id, actor, failed_run_id, checklist_errors, model_config)
if action is not None:
self.dispatch(session_id, action, actor)
return self.get_session_view(session_id, actor)
def create_fix_session_stream(
self,
app_id: str,
actor: Actor,
failed_run_id: str | None = None,
checklist_errors: list[ChecklistError] | list[dict] | None = None,
model_config: dict | None = None,
) -> Iterator[str]:
"""Streaming counterpart of ``create_fix_session``: subscribes to the
session's progress channel BEFORE dispatching the initial
``request_fix`` advance, so no progress frames are lost."""
from services.dify_builder.wiring import stream_advance_frames
session_id, action = self._prepare_fix_session(app_id, actor, failed_run_id, checklist_errors, model_config)
initial_items = self._repo.list_conversation(session_id)
subscription = self._subscribe_fn(session_id) # BEFORE dispatch
try:
command_id = self.dispatch(session_id, action, actor)
view_dict = asdict(self.get_session_view(session_id, actor))
except Exception:
if subscription is not None:
subscription.close()
raise
return stream_advance_frames(
view_dict,
subscription,
expect_advance=True,
command_id=command_id,
initial_items=initial_items,
read_session_view=lambda: self._read_stream_session_view(session_id, actor),
)
def _prepare_build_session(
self,
app_id: str,
actor: Actor,
goal_text: str,
model_config: dict | None = None,
derive_app_name: bool = False,
) -> tuple[str, Action]:
"""Shared setup for ``create_build_session``/``create_build_session_stream``:
everything through persisting the session up to -- but not including --
the internal ``start_build`` dispatch."""
if not isinstance(goal_text, str) or not (goal_text := goal_text.strip()):
raise BadRequestError("goal_text is required")
app_id = self._authorize_app(app_id, actor)
model_config = self._validate_model_config(actor, model_config)
app_revision = self._get_app_revision(app_id, actor)
fc = DifyBuilderContext(
goal_text=goal_text,
model_config=model_config,
last_snapshot_hash=app_revision,
app_name=self._get_app_name(app_id, actor),
app_name_auto=derive_app_name,
)
s = Session(
app_id=app_id,
tenant_id=actor.tenant_id,
owner_account_id=actor.account_id,
entry_mode=EntryMode.BUILD,
current_state=PcState.BUILD_CAPABILITY_CHECK,
)
items = [UserItem(text=goal_text, turn_id=str(uuid4())).to_item(seq=0, at_version=0)]
self._repo.create_session(s, fc, items) # assigns s.id, s.version = 1
return s.id, Action(
kind="start_build",
base_version=1,
base_app_revision=app_revision,
)
def create_build_session(
self,
app_id: str,
actor: Actor,
goal_text: str,
model_config: dict | None = None,
derive_app_name: bool = False,
) -> SessionView:
"""Start a Build session at build.capability_check and dispatch the
initial ``start_build`` command (parallels ``create_fix_session``). The goal is
seeded as the user's opening bubble; the first advance's
``handle_capability_check`` analyzes it into requirements."""
session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config, derive_app_name)
if action is not None:
self.dispatch(session_id, action, actor)
return self.get_session_view(session_id, actor)
def create_build_session_stream(
self,
app_id: str,
actor: Actor,
goal_text: str,
model_config: dict | None = None,
derive_app_name: bool = False,
) -> Iterator[str]:
"""Streaming counterpart of ``create_build_session``: subscribes BEFORE
dispatching the initial ``start_build`` advance."""
from services.dify_builder.wiring import stream_advance_frames
session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config, derive_app_name)
initial_items = self._repo.list_conversation(session_id)
subscription = self._subscribe_fn(session_id) # BEFORE dispatch
try:
command_id = self.dispatch(session_id, action, actor)
view_dict = asdict(self.get_session_view(session_id, actor))
except Exception:
if subscription is not None:
subscription.close()
raise
return stream_advance_frames(
view_dict,
subscription,
expect_advance=True,
command_id=command_id,
initial_items=initial_items,
read_session_view=lambda: self._read_stream_session_view(session_id, actor),
)
def _prepare_edit_session(
self,
app_id: str,
actor: Actor,
goal_text: str,
model_config: dict | None = None,
) -> tuple[str, Action]:
"""Shared setup for ``create_edit_session``/``create_edit_session_stream``:
validate and persist the opening goal, then return the initial
``send_edit_goal`` action for the caller to dispatch.
"""
if not isinstance(goal_text, str) or not (goal_text := goal_text.strip()):
raise BadRequestError("goal_text is required")
app_id = self._authorize_app(app_id, actor)
model_config = self._validate_model_config(actor, model_config)
app_revision = self._get_app_revision(app_id, actor)
fc = DifyBuilderContext(
goal_text=goal_text,
model_config=model_config,
last_snapshot_hash=app_revision,
)
s = Session(
app_id=app_id,
tenant_id=actor.tenant_id,
owner_account_id=actor.account_id,
entry_mode=EntryMode.EDIT,
current_state=PcState.EDIT_CAPABILITY_CHECK,
)
items = [UserItem(text=goal_text, turn_id=str(uuid4())).to_item(seq=0, at_version=0)]
self._repo.create_session(s, fc, items) # assigns s.id, s.version = 1
return s.id, Action(
kind="send_edit_goal",
payload={"text": goal_text},
base_version=1,
base_app_revision=app_revision,
)
def create_edit_session(
self,
app_id: str,
actor: Actor,
goal_text: str,
model_config: dict | None = None,
) -> SessionView:
"""Start an Edit session and dispatch its opening goal in one command.
The goal is seeded as the opening user bubble and the initial
``send_edit_goal`` advance reads the graph and analyzes impact.
"""
session_id, action = self._prepare_edit_session(app_id, actor, goal_text, model_config)
self.dispatch(session_id, action, actor)
return self.get_session_view(session_id, actor)
def create_edit_session_stream(
self,
app_id: str,
actor: Actor,
goal_text: str,
model_config: dict | None = None,
) -> Iterator[str]:
"""Streaming counterpart of ``create_edit_session``.
Subscribe before dispatching ``send_edit_goal`` and relay through its
terminal frame.
"""
from services.dify_builder.wiring import stream_advance_frames
session_id, action = self._prepare_edit_session(app_id, actor, goal_text, model_config)
initial_items = self._repo.list_conversation(session_id)
subscription = self._subscribe_fn(session_id) # BEFORE dispatch
try:
command_id = self.dispatch(session_id, action, actor)
view_dict = asdict(self.get_session_view(session_id, actor))
except Exception:
if subscription is not None:
subscription.close()
raise
return stream_advance_frames(
view_dict,
subscription,
expect_advance=True,
command_id=command_id,
initial_items=initial_items,
read_session_view=lambda: self._read_stream_session_view(session_id, actor),
)
def _read_stream_session_view(self, session_id: str, actor: Actor) -> dict[str, object] | None:
with self._stream_read_scope_fn():
_, context = self._get_authorized_session(session_id, actor)
if self._test_actions is not None and self._test_actions.observation_initializing(
actor=actor, session_id=session_id, test_input_id=context.test_input_ref
):
return None
view = asdict(self.get_session_view(session_id, actor))
_, context = self._repo.get_session(session_id)
return {**view, "_task_paused": context.paused}
def stop_task(
self, session_id: str, actor: Actor, *, base_version: int, client_action_id: str, base_app_revision: str = ""
) -> SessionView:
from services.dify_builder.task_control_service import control
return control(
self,
session_id,
actor,
kind="stop",
base_version=base_version,
client_action_id=client_action_id,
base_app_revision=base_app_revision,
)
def resume_task(
self, session_id: str, actor: Actor, *, base_version: int, client_action_id: str, base_app_revision: str = ""
) -> SessionView:
from services.dify_builder.task_control_service import control
return control(
self,
session_id,
actor,
kind="resume",
base_version=base_version,
client_action_id=client_action_id,
base_app_revision=base_app_revision,
)
def get_session_view(self, session_id: str, actor: Actor) -> SessionView:
"""Return the bounded session projection; history has its own API."""
s, fc = self._get_authorized_session(session_id, actor)
if fc.paused and fc.task_control.get("settlement") in {"pending", "unknown"}:
from services.dify_builder.task_control_service import observe_stop
observe_stop(self._repo, self._test_actions, session_id, actor)
s, fc = self._get_authorized_session(session_id, actor)
return self._build_session_view(s, fc)
def get_conversation_page(
self,
session_id: str,
actor: Actor,
*,
limit: int,
before_seq: int | None = None,
after_seq: int | None = None,
) -> ConversationPage:
if type(limit) is not int or not 1 <= limit <= 100:
raise BadRequestError("limit must be between 1 and 100")
if before_seq is not None and (type(before_seq) is not int or before_seq < 0):
raise BadRequestError("before_seq must be a non-negative integer")
if after_seq is not None and (type(after_seq) is not int or after_seq < -1):
raise BadRequestError("after_seq must be an integer greater than or equal to -1")
if before_seq is not None and after_seq is not None:
raise BadRequestError("before_seq and after_seq are mutually exclusive")
self._get_authorized_session(session_id, actor)
return self._repo.list_conversation_page(
session_id,
limit=limit,
before_seq=before_seq,
after_seq=after_seq,
)
def _active_interaction_for(
self, session_id: str, version: int, actions: list[UiAction]
) -> ActiveInteraction | None:
for action in actions:
card_contract = _ACTIVE_INTERACTION_CARD_FOR_ACTION.get(action.id)
if card_contract is None:
continue
card_kind, expected_variant = card_contract
card = self._repo.get_latest_conversation_item(session_id, frozenset({card_kind}))
if card is None:
return None
if expected_variant is not None and card.payload.get("variant") != expected_variant:
return None
return ActiveInteraction(action_id=action.id, card=card, valid_at_version=version)
return None
def _publication_for_session(self, s: Session, fc: DifyBuilderContext, actor: Actor) -> PublicationDecision:
run = None
if fc.verify_run_id:
with suppress(NotFoundError):
run = self._repo.get_run(fc.verify_run_id)
revision, graph_revision = self._get_verification_identity_fn(s.app_id, actor)
return context_publication_decision(
run, s, fc, current_revision=revision, current_graph_revision=graph_revision, load_run=self._repo.get_run
)
def _build_session_view(self, s: Session, fc: DifyBuilderContext) -> SessionView:
from services.dify_builder.conversation_tasks import active_entry_mode
s = replace(s, entry_mode=active_entry_mode(s, fc))
st = s.current_state
continuation_pending = not fc.paused and fc.task_control.get("receipt", {}).get("enqueue") in {
"pending",
"dispatching",
"claimed",
}
lock_held = self._session_lock.exists(s.id)
current_app_revision = self._get_app_revision(
s.app_id,
Actor(account_id=s.owner_account_id, tenant_id=s.tenant_id),
)
app_revision_conflicted = bool(
fc.last_snapshot_hash and current_app_revision and fc.last_snapshot_hash != current_app_revision
)
stopped = fc.paused and not is_terminal(st) and fc.task_control.get("settlement", "settled") == "settled"
interrupted = is_working(st) and not lock_held and not fc.paused
actions = _actions_for(
st,
fc,
interrupted=interrupted,
app_revision_conflicted=app_revision_conflicted,
publication=self._publication_for_session(
s, fc, Actor(account_id=s.owner_account_id, tenant_id=s.tenant_id)
),
)
test_execution = None
receipt = None
if self._test_actions is not None:
receipt = self._test_actions.current(
actor=Actor(account_id=s.owner_account_id, tenant_id=s.tenant_id),
session_id=s.id,
test_input_id=fc.test_input_ref,
)
if receipt is not None:
test_execution = receipt.model_dump(mode="json")
if receipt.status != "terminal" or receipt.outcome == "unknown":
from services.dify_builder.test_actions import control_actions
actions = control_actions(receipt)
decision = _decision_for(st, actions)
if (
receipt is not None
and test_execution is not None
and (test_execution["status"] != "terminal" or test_execution["outcome"] == "unknown")
):
from services.dify_builder.test_actions import test_disclosure_text
if decision is not None:
decision.description = test_disclosure_text(receipt)
decision.default_option_id = "" # risk approval must be an explicit choice
for option in decision.options:
option.is_default = False
if decision is not None and fc.recovery_class:
decision.description = _RECOVERY_DESCRIPTION[fc.recovery_class]
checkpoint = (
CheckpointRef(
checkpoint_id=fc.checkpoint_id, label=copy_text(BuilderCopyId.LABELS_RESTORE_POINT), created_at=""
)
if fc.checkpoint_id
else None
)
recovery_ref = recovery.recovery_ref_for(fc.recovery_class)
diagnostics = trace_diagnostics.from_history(
self._repo.list_recent_conversation(s.id, limit=24), session_id=s.id, version=s.version
)
if self._test_actions is not None and receipt is not None:
with suppress(Exception):
diagnostics = trace_diagnostics.merge_current(
diagnostics,
self._test_actions.current_diagnostics(
actor=Actor(account_id=s.owner_account_id, tenant_id=s.tenant_id),
session_id=s.id,
test_input_id=fc.test_input_ref,
receipt=receipt,
),
)
return SessionView(
test_execution=test_execution,
diagnostics=diagnostics,
session_id=s.id,
app_id=s.app_id,
version=s.version,
state=str(st),
canvas_read_only=False
if stopped
else lock_held or canvas_read_only(st) or fc.paused or continuation_pending,
run_status=(
RunStatus.PAUSED
if stopped
else RunStatus.PROCESSING
if fc.paused and fc.task_control.get("settlement") == "pending"
else RunStatus.FAILED
if fc.paused and fc.task_control.get("settlement") == "unknown"
else RunStatus.WAITING_CONFIRMATION
if receipt is not None and receipt.status == "waiting"
else RunStatus.PROCESSING
if receipt is not None and receipt.status in {"prepared", "running"}
else RunStatus.FAILED
if receipt is not None and receipt.outcome == "unknown"
else RunStatus.PROCESSING
if lock_held or continuation_pending
else _run_status(st, paused=fc.paused)
),
interrupted=interrupted,
conversation_last_seq=fc.next_seq - 1,
entry_mode=s.entry_mode,
phase=_phase_for(st),
actions=actions,
decision=decision,
active_interaction=self._active_interaction_for(s.id, s.version, actions),
checkpoint=checkpoint,
recovery=recovery_ref,
model=(
SessionModel(
provider=fc.model_config.get("provider", ""),
name=fc.model_config.get("name", ""),
mode=fc.model_config.get("mode", ""),
completion_params=fc.model_config.get("completion_params", {}),
)
if fc.model_config
else None
),
app_revision=AppRevision(
observed=fc.last_snapshot_hash,
current=current_app_revision,
conflicted=app_revision_conflicted,
),
last_command_id=fc.last_command_id,
reply_language=fc.reply_language,
)
def _prepare_action(
self, session_id: str, actor: Actor, action: Action
) -> tuple[SessionView, bool, list[ConversationItem]]:
"""Shared synchronous validation + settle for ``submit_action`` and the
streaming submit methods. Returns ``(view, expect_advance, items)``:
``expect_advance`` is ``True`` only when the caller must still call
``dispatch``; ``items`` contains rows committed synchronously before a
worker is needed. This method never dispatches itself, so a streaming
caller can subscribe before the advance is enqueued."""
if not action.command_id and action.kind in TEST_ACTIONS | {"stop", "resume"}:
action.command_id = str(
uuid5(
NAMESPACE_URL,
canonical_digest(
{
"session": session_id,
"actor": actor.account_id,
"tenant": actor.tenant_id,
"kind": action.kind,
"payload": action.payload,
"base_version": action.base_version,
"base_app_revision": action.base_app_revision,
}
),
)
)
action.command_id = action.command_id or str(uuid4())
kind = action.kind.strip() if isinstance(action.kind, str) else ""
if not kind:
raise BadRequestError("action kind is required")
if kind in {CONFIRMED_TOOL_ACTION, "task_continue"}:
# Only the confirmed dispatch owner's reservation winner may enqueue it.
raise BadRequestError(f"action {kind} is not allowed")
action.kind = kind
if not isinstance(action.payload, dict):
raise BadRequestError("action payload must be an object")
if type(action.base_version) is not int:
raise BadRequestError("base_version must be an integer")
if not isinstance(action.base_app_revision, str):
raise BadRequestError("base_app_revision must be a string")
if action.kind == "message":
text = action.payload.get("text")
if not isinstance(text, str) or not (text := text.strip()):
raise BadRequestError("message text is required")
client_turn_id = action.payload.get("client_turn_id")
if not isinstance(client_turn_id, str) or not (client_turn_id := client_turn_id.strip()):
raise BadRequestError("client_turn_id is required")
if len(client_turn_id) > 128:
raise BadRequestError("client_turn_id is too long")
action.payload = {**action.payload, "text": text, "client_turn_id": client_turn_id}
s, fc = self._get_authorized_session(session_id, actor)
if action.kind in {"stop", "resume"}:
control = self.stop_task if action.kind == "stop" else self.resume_task
return (
control(
session_id,
actor,
base_version=action.base_version,
client_action_id=action.command_id,
base_app_revision=action.base_app_revision,
),
False,
[],
)
if fc.task_control.get("receipt", {}).get("enqueue") in {"pending", "dispatching", "claimed"}:
raise BusyError("task continuation is pending")
if action.kind in TEST_ACTIONS:
return self._prepare_test_action(s, fc, actor, action)
if self._test_actions is not None:
current_test = self._test_actions.current(actor=actor, session_id=s.id, test_input_id=fc.test_input_ref)
if current_test is not None and (current_test.status != "terminal" or current_test.outcome == "unknown"):
raise BusyError("observe or finish the current test before starting another action")
if action.kind == "message":
client_turn_id = action.payload["client_turn_id"]
turn_kinds = self._repo.get_conversation_turn_kinds(session_id, client_turn_id)
if "assistant_turn" in turn_kinds:
return self._build_session_view(s, fc), False, []
if "user" in turn_kinds:
action.base_version = s.version
lifecycle_limited = bool(
fc.paused or fc.recovery_class or is_working(s.current_state) or is_terminal(s.current_state)
)
internal_allowed = _internal_action_allowed(s.current_state, fc, action.kind)
if not internal_allowed and (
lifecycle_limited or action.kind not in _BACKEND_ACTIONS_FOR.get(s.current_state, frozenset())
):
raise BadRequestError(f"action {action.kind} is not allowed in state {s.current_state}")
if action.kind in _RELEASE_ACTIONS or (
action.kind == "recovery_continue" and s.current_state in _PUBLISH_WORKING_STATES
):
publication = self._publication_for_session(s, fc, actor)
if not publication.allowed:
raise BadRequestError(f"publication verification denied: {publication.reason}; run validation again")
access = _app_access_for_action(s.current_state, action.kind)
if access != AppAccess.EDIT:
self._authorize_app(s.app_id, actor, access)
fixture_revision: str | None = None
if action.kind == "provide_testdata" and "http_fixtures" in action.payload:
try:
fixtures = decode_http_fixture_submission(action.payload["http_fixtures"])
if not action.base_app_revision:
raise BuilderExecutionPolicyError("fixture_base_revision_required")
if self._stamp_http_fixtures_fn is None:
raise BuilderExecutionPolicyError("fixture_admission_unavailable")
envelope = self._stamp_http_fixtures_fn(
s.app_id, actor, base_app_revision=action.base_app_revision, fixtures=fixtures
)
fixture_revision = envelope.execution_revision
# Keep the public action JSON-safe through the native Celery serializer.
action.payload = {**action.payload, "http_fixtures": [f.model_dump(mode="json") for f in fixtures]}
except BuilderExecutionPolicyError as exc:
raise BadRequestError(str(exc)) from None
if action.base_version != s.version:
raise ConflictError(f"stale base_version {action.base_version} for session {session_id}")
current_app_revision = fixture_revision or self._get_app_revision(s.app_id, actor)
app_revision_conflicted = bool(
fc.last_snapshot_hash and current_app_revision and fc.last_snapshot_hash != current_app_revision
)
# Inputs survive draft drift while the user fills the form; fixtures
# remain bound to the exact revision stamped above. Saving either form
# variant never rebases a staged repair or accepts its baseline drift.
baseline_exempt = action.kind == "provide_testdata"
if requires_app_revision(action):
if current_app_revision and not action.base_app_revision:
raise BadRequestError("base_app_revision is required")
if action.base_app_revision and action.base_app_revision != current_app_revision:
raise ConflictError(f"stale app revision for app {s.app_id}")
if (
app_revision_conflicted
and not fc.recovery_class
and not baseline_exempt
and action.kind not in {"check_recovery", "recovery_restart", "resume"}
# A completed-task message can only prepare a fresh plan/form.
# Its owner reads the current graph and invalidates old approvals.
and not (action.kind == "message" and is_terminal(s.current_state))
):
raise ConflictError(f"draft changed outside Builder for app {s.app_id}")
if (
action.kind == "approve_repair"
and s.current_state in {PcState.BUILD_AWAIT_REPAIR, PcState.EDIT_AWAIT_REPAIR, PcState.FIX_AWAIT_APPROVAL}
and not fc.staged_repair
):
raise BadRequestError("no repair is staged for approval")
if s.current_state == PcState.FIX_AWAIT_APPROVAL and (
(fc.staged_repair and action.kind in {"retry_repair", "finish_repair"})
or (not fc.staged_repair and action.kind == "reject_repair")
):
raise BadRequestError(f"action {action.kind} is not allowed for the current repair proposal")
visible_actions = _actions_for(
s.current_state,
fc,
interrupted=is_working(s.current_state) and not self._session_lock.exists(session_id),
app_revision_conflicted=app_revision_conflicted,
publication=self._publication_for_session(
s, fc, Actor(account_id=s.owner_account_id, tenant_id=s.tenant_id)
),
)
interaction_response = _interaction_response_for(self._repo, s, action, visible_actions, fc.reply_language)
if interaction_response is not None and action.receipt_snapshot is not None:
apply_receipt_snapshot(interaction_response, action.receipt_snapshot)
action.interaction_response = asdict(interaction_response) if interaction_response is not None else None
if action.kind == "update_model":
if is_working(s.current_state) or self._session_lock.exists(session_id):
raise BusyError(f"session {session_id} is busy")
model_config = action.payload.get("model_config")
if not isinstance(model_config, dict) or not model_config:
raise BadRequestError("model_config is required")
fc.model_config = validate_model_config(actor.tenant_id, model_config)
fc.last_command_id = action.command_id
self._repo.compare_and_advance(session_id, s.version, s.current_state, fc, [])
return self.get_session_view(session_id, actor), False, []
return self._build_session_view(s, fc), True, []
@staticmethod
def _test_action_receipt_text(kind, receipt):
from runtime_contracts.builder.presentation import text_parts
from services.dify_builder.test_actions import control_label, test_disclosure_text
return asdict(text_parts(control_label(kind), "\n", test_disclosure_text(receipt)))
def _prepare_test_action(self, s: Session, fc: DifyBuilderContext, actor: Actor, action: Action):
if self._test_actions is None:
raise BadRequestError("native test controls unavailable")
self._authorize_app(s.app_id, actor, AppAccess.TEST_AND_RUN)
command = parse_test_action(action.kind, action.payload)
fingerprint = canonical_digest(
{
"kind": action.kind,
"payload": action.payload,
"base_version": action.base_version,
"base_app_revision": action.base_app_revision,
}
)
prior = self._test_actions.find_command(actor=actor, session_id=s.id, command_id=action.command_id)
if prior is not None:
if prior.get("test_action_digest") != fingerprint:
raise ConflictError("action identity was reused with different content")
return self.get_session_view(s.id, actor), False, []
token = self._session_lock.acquire(s.id)
if token is None:
raise BusyError("Builder is still preparing the current test")
try:
s, fc = self._get_authorized_session(s.id, actor)
if (fc.paused or fc.recovery_class) and command.method != "cancel":
raise ConflictError("resolve Builder recovery before continuing the test")
if action.base_version != s.version:
raise ConflictError("stale test action session version")
if self._get_app_revision(s.app_id, actor) != action.base_app_revision:
raise ConflictError("stale test action draft revision")
current = self._test_actions.current(actor=actor, session_id=s.id, test_input_id=fc.test_input_ref)
if current is None or current.request_id != command.request_id:
raise ConflictError("test request is no longer current")
saved_receipt = self._test_actions.execute(
actor=actor,
session_id=s.id,
test_input_id=fc.test_input_ref,
command=command,
idempotency_key=action.command_id,
)
# Native ownership has already committed; a crash here cannot authorize
# another dispatch. Repeating the same command reuses its durable key.
s, fc = self._get_authorized_session(s.id, actor)
fc.last_command_id = action.command_id
item = ConversationItem(
seq=fc.next_seq,
kind="decision",
at_version=s.version + 1,
payload={
"text": self._test_action_receipt_text(action.kind, saved_receipt),
"test_action_kind": action.kind,
"test_command_id": action.command_id,
"test_action_digest": fingerprint,
"request_id": command.request_id,
},
)
fc.next_seq += 1
self._repo.compare_and_advance(s.id, s.version, s.current_state, fc, [item])
self._session_lock.release(s.id, token)
self._test_actions.settle(actor=actor, session_id=s.id, request_id=command.request_id)
return self.get_session_view(s.id, actor), False, [item]
finally:
self._session_lock.release(s.id, token)
def submit_action(self, session_id: str, actor: Actor, action: Action) -> SessionView:
"""Port of Go ``SubmitAction``."""
view, expect_advance, _settled_items = self._prepare_action(session_id, actor, action)
if expect_advance:
self.dispatch(session_id, action, actor)
return self.get_session_view(session_id, actor)
def submit_message(
self,
session_id: str,
actor: Actor,
text: str,
base_version: int,
client_turn_id: str,
) -> SessionView:
"""Port of Go ``SubmitMessage``."""
if not isinstance(text, str) or not (text := text.strip()):
raise BadRequestError("message text is required")
return self.submit_action(
session_id,
actor,
Action(
kind="message",
payload={"text": text, "client_turn_id": client_turn_id},
base_version=base_version,
),
)
def submit_action_stream(self, session_id: str, actor: Actor, action: Action) -> Iterator[str]:
"""Streaming counterpart of ``submit_action``: performs the same eager
validation via ``_prepare_action`` (raising before any streaming begins),
then -- for the dispatch case -- subscribes BEFORE enqueuing the advance
so no progress frames are lost. Synchronously committed items are sent
individually before the terminal projection."""
from services.dify_builder.wiring import stream_advance_frames
view, expect_advance, settled_items = self._prepare_action(session_id, actor, action)
if not expect_advance:
# ``message`` (replay of a settled turn) and ``update_model`` both
# settle synchronously while changing the version, so they must emit
# a terminal ``command_finished`` frame carrying the new version --
# otherwise a client keeps a stale held version and its next action
# conflicts.
return stream_advance_frames(
asdict(view),
None,
expect_advance=False,
emit_command_finished_when_settled=action.kind
in {"message", "update_model", "stop", "resume"} | TEST_ACTIONS,
command_id=action.command_id,
initial_items=settled_items,
)
subscription = self._subscribe_fn(session_id) # BEFORE dispatch
try:
command_id = self.dispatch(session_id, action, actor)
view_dict = asdict(view)
except Exception:
if subscription is not None:
subscription.close()
raise
return stream_advance_frames(
view_dict,
subscription,
expect_advance=True,
command_id=command_id,
read_session_view=lambda: self._read_stream_session_view(session_id, actor),
)
def submit_message_stream(
self,
session_id: str,
actor: Actor,
text: str,
base_version: int,
client_turn_id: str,
) -> Iterator[str]:
if not isinstance(text, str) or not (text := text.strip()):
raise BadRequestError("message text is required")
return self.submit_action_stream(
session_id,
actor,
Action(
kind="message",
payload={"text": text, "client_turn_id": client_turn_id},
base_version=base_version,
),
)
def execute_confirmed_tool(
self,
session_id: str,
actor: Actor,
*,
consent_id: str,
scope_digest: str,
at_version: int,
idempotency_key: str,
) -> "ConsentReceipt":
"""Confirmed dispatch coordinator: one lease, one reservation, at most one enqueue.
Acquires the session's advance lease BEFORE the consent owner's atomic
SQL reservation and hands that same token only to the winning enqueue
of the already reserved command (no ``_prepare_action``, no second
command or state commit). A held lease answers a matching duplicate
from the consent owner's replay (current receipt, no reservation or
enqueue) and is busy otherwise. Refusal or a non-winning replay
releases only this call's own token. A failed enqueue keeps the lease:
its acknowledgement may be lost while the worker already holds the same
token, and the reservation can never be enqueued again; the reconcile
sweeper settles a launch that never starts.
"""
if self._real_call_consent is None:
raise BadRequestError("confirmed tool execution is unavailable")
consent = self._real_call_consent
s, _fc = self._get_authorized_session(session_id, actor, AppAccess.TEST_AND_RUN)
if _fc.paused:
raise ConflictError("task is paused")
token = self._session_lock.acquire(session_id)
if token is None:
replay = consent.replay_execution(
actor=actor,
session_id=session_id,
consent_id=consent_id,
scope_digest=scope_digest,
at_version=at_version,
idempotency_key=idempotency_key,
)
if replay is None:
raise BusyError(f"session {session_id} is busy")
return replay
try:
receipt, won = consent.reserve_execution(
actor=actor,
session_id=session_id,
consent_id=consent_id,
scope_digest=scope_digest,
at_version=at_version,
idempotency_key=idempotency_key,
)
except BaseException:
self._session_lock.release(session_id, token)
raise
if not won or receipt.command_id is None:
self._session_lock.release(session_id, token)
return receipt
action = Action(
kind=CONFIRMED_TOOL_ACTION,
payload={"consent_id": receipt.consent_id},
base_version=receipt.at_version,
command_id=receipt.command_id,
)
try:
self._enqueue_fn(session_id, action, actor, token)
except Exception:
logger.warning(
"dify_builder confirmed enqueue acknowledgement unavailable (session=%s, command=%s)",
s.id,
receipt.command_id,
)
return receipt
def dispatch(self, session_id: str, action: Action, actor: Actor) -> str:
"""Port of Go ``dispatch``: acquire-or-busy + enqueue, release on
enqueue failure."""
action.command_id = action.command_id or str(uuid4())
from services.dify_builder.task_controls import generation
_, context = self._repo.get_session(session_id)
if context.paused:
raise ConflictError("task is paused")
if action.execution_generation is None:
if action.base_version > 0:
_, observed = self._repo.get_session_at_version(session_id, action.base_version)
action.execution_generation = generation(observed)
else:
action.execution_generation = generation(context)
if generation(context) != action.execution_generation:
raise ConflictError("task dispatch stopped or superseded")
token = self._session_lock.acquire(session_id, generation=action.execution_generation)
if token is None:
raise BusyError(f"session {session_id} is busy")
try:
session, current = self._repo.get_session(session_id)
if current.paused or generation(current) != action.execution_generation:
raise ConflictError("task dispatch stopped or superseded")
if action.kind == "send_edit_goal" and session.current_state == PcState.EDIT_CAPABILITY_CHECK:
text = action.payload.get("text")
if isinstance(text, str) and text and text != current.goal_text:
from services.dify_builder.business_acceptance import clear_task_acceptance
# After acquiring the lease, preserve ordinary input before
# enqueue. Stop may fence delivery without losing this goal.
clear_task_acceptance(current)
current.goal_text = text
action.base_version = self._repo.compare_and_advance(
session_id, action.base_version, session.current_state, current, []
)
self._enqueue_fn(session_id, action, actor, token)
except Exception:
self._session_lock.release(session_id, token)
raise
return action.command_id