mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
Resume the original run after all approvals and restore terminal progress and traversed edges on reload. Show structured approval details and receipts, hide Stop while forms are open, and recover when a running conversation is cleared.
2644 lines
116 KiB
Python
2644 lines
116 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, dataclass, 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,
|
|
control_actions,
|
|
control_label,
|
|
control_title,
|
|
parse_test_action,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from core.dify_builder.real_call_consent import ConsentReceipt
|
|
from services.dify_builder.attachment_intake import BuilderAttachmentIntake
|
|
from services.dify_builder.fix_intents import FixIntentRepository
|
|
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"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommandSubmission:
|
|
"""Admission receipt. Pending work settles on the session stream."""
|
|
|
|
command_id: str
|
|
pending: bool
|
|
session: SessionView | None = None
|
|
|
|
|
|
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.initial_plan",
|
|
),
|
|
],
|
|
# 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 commits the abstract plan here
|
|
# before resource discovery), 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",
|
|
),
|
|
UiAction(
|
|
id="continue_adjusting",
|
|
label=copy_text(BuilderCopyId.ACTIONS_CONTINUE_ADJUSTING),
|
|
kind=ActionKind.SECONDARY,
|
|
next_state="build.goal_analysis",
|
|
),
|
|
],
|
|
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="graph_changed",
|
|
),
|
|
],
|
|
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="graph_changed",
|
|
),
|
|
],
|
|
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="graph_changed",
|
|
),
|
|
],
|
|
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="graph_changed",
|
|
),
|
|
],
|
|
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="graph_changed",
|
|
),
|
|
],
|
|
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="graph_changed",
|
|
),
|
|
],
|
|
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="graph_changed",
|
|
),
|
|
],
|
|
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,
|
|
),
|
|
]
|
|
|
|
if (
|
|
state == PcState.BUILD_RESOURCE_RECOMMENDATION
|
|
and fc is not None
|
|
and fc.resource_selection.get("refresh_required")
|
|
):
|
|
# The existing client renders generic decisions only when no active
|
|
# resource form is projected. A second button beside the resource form
|
|
# would be invisible in that client, so offer rediscovery alone here.
|
|
return [
|
|
UiAction(
|
|
id="rediscover_resources",
|
|
label=copy_text(BuilderCopyId.PROGRESS_FIND_COMPATIBLE_RESOURCES),
|
|
kind=ActionKind.PRIMARY,
|
|
)
|
|
]
|
|
|
|
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", "rediscover_resources"}),
|
|
PcState.BUILD_PLAN_APPROVAL: frozenset({"approve_repair", "re_fix"}),
|
|
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,
|
|
attachment_intake: "BuilderAttachmentIntake | None" = None,
|
|
cognition_policy_fn: Callable[[Actor, dict | None], dict] | None = None,
|
|
fix_intent_repo: "FixIntentRepository | None" = None,
|
|
) -> None:
|
|
# None: this instance cannot execute confirmed Tool tests (all existing callers).
|
|
self._fix_intent_repo = fix_intent_repo
|
|
self._cognition_policy_fn = cognition_policy_fn
|
|
self._real_call_consent = real_call_consent
|
|
self._attachment_intake = attachment_intake
|
|
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 _validate_create_material(self, actor: Actor, attachments: list[str] | None) -> list[str]:
|
|
from services.dify_builder.attachment_intake import validated_upload_ids
|
|
|
|
ids = validated_upload_ids(attachments)
|
|
if ids:
|
|
if self._attachment_intake is None:
|
|
raise BadRequestError("Attachment intake is unavailable")
|
|
self._attachment_intake.authorize_uploads(actor, ids)
|
|
return ids
|
|
|
|
def _seed_material(
|
|
self,
|
|
session: Session,
|
|
context: DifyBuilderContext,
|
|
actor: Actor,
|
|
ids: list[str],
|
|
*,
|
|
client_turn_id: str | None = None,
|
|
) -> None:
|
|
if ids:
|
|
assert self._attachment_intake is not None
|
|
self._attachment_intake.submit(
|
|
session,
|
|
context,
|
|
actor,
|
|
text="",
|
|
upload_file_ids=ids,
|
|
client_turn_id=client_turn_id or "create:" + session.id,
|
|
)
|
|
|
|
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)
|
|
|
|
def _validate_model_config(self, 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")
|
|
if self._cognition_policy_fn is not None:
|
|
return self._cognition_policy_fn(actor, model_config)
|
|
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,
|
|
attachments: list[str] | None = None,
|
|
session_id: str | None = None,
|
|
client_action_id: str | None = None,
|
|
defer_existing: bool = False,
|
|
) -> tuple[str, Action | None]:
|
|
"""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. When
|
|
``defer_existing`` is set, return a selected conversation without
|
|
submitting a Fix intent before its observer is connected.
|
|
|
|
``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).
|
|
"""
|
|
requested_model_config = model_config
|
|
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)
|
|
if self._fix_intent_repo is not None:
|
|
selected = self._fix_intent_repo.select_conversation(actor, app_id, session_id)
|
|
if selected is not None and defer_existing:
|
|
return selected, None
|
|
if selected is not None:
|
|
from services.dify_builder.fix_intents import prepare_fix_intent
|
|
from services.dify_builder.task_lifecycle import task_identity
|
|
|
|
selected_head, selected_context = self._repo.get_session(selected)
|
|
receipt = prepare_fix_intent(
|
|
self,
|
|
selected,
|
|
actor,
|
|
failed_run_id=failed_run_id,
|
|
checklist_errors=checklist_errors,
|
|
model_config=model_config,
|
|
client_action_id=client_action_id
|
|
or "fix:"
|
|
+ canonical_digest(
|
|
{
|
|
"task": task_identity(selected_head, selected_context),
|
|
"run": failed_run_id,
|
|
"checklist": [asdict(item) for item in (checklist_errors or [])],
|
|
"model": requested_model_config,
|
|
"attachments": attachments,
|
|
}
|
|
),
|
|
attachments=attachments,
|
|
)
|
|
return receipt.session.session_id, None
|
|
if self._fix_intent_repo is not None:
|
|
self._fix_intent_repo.validate_source(
|
|
actor,
|
|
app_id,
|
|
{"kind": "checklist"} if checklist_errors else {"kind": "run", "failed_run_id": failed_run_id},
|
|
)
|
|
attachment_ids = self._validate_create_material(actor, attachments)
|
|
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)]
|
|
if self._fix_intent_repo is not None:
|
|
selected, created = self._repo.create_fix_session_or_select(s, fc, items, source_run=failed_run)
|
|
if not created and defer_existing:
|
|
return selected.id, None
|
|
if not created:
|
|
from services.dify_builder.fix_intents import prepare_fix_intent
|
|
from services.dify_builder.task_lifecycle import task_identity
|
|
|
|
selected_head, selected_context = self._repo.get_session(selected.id)
|
|
receipt = prepare_fix_intent(
|
|
self,
|
|
selected.id,
|
|
actor,
|
|
failed_run_id=failed_run_id,
|
|
checklist_errors=checklist_errors,
|
|
model_config=requested_model_config,
|
|
client_action_id=client_action_id
|
|
or "fix:"
|
|
+ canonical_digest(
|
|
{
|
|
"task": task_identity(selected_head, selected_context),
|
|
"run": failed_run_id,
|
|
"checklist": [asdict(item) for item in (checklist_errors or [])],
|
|
"model": requested_model_config,
|
|
"attachments": attachments,
|
|
}
|
|
),
|
|
attachments=attachments,
|
|
)
|
|
return receipt.session.session_id, None
|
|
else:
|
|
self._repo.create_session(s, fc, items) # assigns s.id, s.version = 1
|
|
self._seed_material(s, fc, actor, attachment_ids)
|
|
if failed_run is not None and self._fix_intent_repo is 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,
|
|
attachments: list[str] | None = None,
|
|
session_id: str | None = None,
|
|
client_action_id: str | 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, attachments, session_id, client_action_id
|
|
)
|
|
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,
|
|
attachments: list[str] | None = None,
|
|
session_id: str | None = None,
|
|
client_action_id: str | 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, attachments, session_id, client_action_id
|
|
)
|
|
initial_items = self._repo.list_conversation(session_id) if action is not None else []
|
|
subscription = self._subscribe_fn(session_id) # BEFORE dispatch
|
|
try:
|
|
command_id = self.dispatch(session_id, action, actor) if action is not None else ""
|
|
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=action is not None,
|
|
emit_command_finished_when_settled=True,
|
|
command_id=command_id,
|
|
initial_items=initial_items,
|
|
read_session_view=lambda: self._read_stream_session_view(session_id, actor),
|
|
conversation_cleared=lambda: self.is_conversation_cleared(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,
|
|
attachments: list[str] | None = None,
|
|
) -> 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.strip() and not attachments):
|
|
raise BadRequestError("goal_text is required")
|
|
goal_text = goal_text.strip() or "Analyze the attached material and clarify the workflow goal."
|
|
app_id = self._authorize_app(app_id, actor)
|
|
attachment_ids = self._validate_create_material(actor, attachments)
|
|
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
|
|
self._seed_material(s, fc, actor, attachment_ids)
|
|
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,
|
|
attachments: list[str] | None = None,
|
|
) -> 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, attachments
|
|
)
|
|
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,
|
|
attachments: list[str] | None = None,
|
|
) -> 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, attachments
|
|
)
|
|
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),
|
|
conversation_cleared=lambda: self.is_conversation_cleared(session_id, actor),
|
|
)
|
|
|
|
def prepare_edit_session(
|
|
self,
|
|
app_id: str,
|
|
actor: Actor,
|
|
goal_text: str,
|
|
model_config: dict | None = None,
|
|
attachments: list[str] | 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.strip() and not attachments):
|
|
raise BadRequestError("goal_text is required")
|
|
goal_text = goal_text.strip() or "Analyze the attached material and clarify the workflow goal."
|
|
app_id = self._authorize_app(app_id, actor)
|
|
attachment_ids = self._validate_create_material(actor, attachments)
|
|
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
|
|
self._seed_material(s, fc, actor, attachment_ids)
|
|
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,
|
|
attachments: list[str] | 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, attachments)
|
|
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,
|
|
attachments: list[str] | 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, attachments)
|
|
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),
|
|
conversation_cleared=lambda: self.is_conversation_cleared(session_id, actor),
|
|
)
|
|
|
|
def start_session(self, session_id: str, actor: Actor, request: Action) -> CommandSubmission:
|
|
"""Dispatch the opening command for an authorized, untouched session.
|
|
|
|
The caller opens the session stream first. Derive the command and its
|
|
payload from the saved opening context. The writer lease and the
|
|
initial version fence prevent a second start from enqueueing.
|
|
"""
|
|
session, context = self._get_authorized_session(session_id, actor)
|
|
kind = {
|
|
(EntryMode.BUILD, PcState.BUILD_CAPABILITY_CHECK): "start_build",
|
|
(EntryMode.EDIT, PcState.EDIT_CAPABILITY_CHECK): "send_edit_goal",
|
|
(EntryMode.FIX, PcState.FIX_DIAGNOSE): "request_fix",
|
|
(EntryMode.FIX_CHECKLIST, PcState.CHECKLIST_DIAGNOSE): "request_fix",
|
|
}.get((session.entry_mode, session.current_state))
|
|
if kind is None or session.version != 1 or request.base_version != session.version:
|
|
raise ConflictError("opening command is no longer current")
|
|
revision = self._get_app_revision(session.app_id, actor)
|
|
if request.base_app_revision != revision or context.last_snapshot_hash != revision:
|
|
raise ConflictError("draft changed before starting Builder")
|
|
action = Action(
|
|
kind=kind,
|
|
payload={"text": context.goal_text} if kind == "send_edit_goal" else {},
|
|
base_version=session.version,
|
|
base_app_revision=revision,
|
|
command_id=request.command_id,
|
|
)
|
|
return CommandSubmission(self.dispatch(session_id, action, actor, opening=True), pending=True)
|
|
|
|
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)
|
|
from services.dify_builder.conversation_lifecycle import is_conversation_cleared
|
|
|
|
if (
|
|
not is_conversation_cleared(context)
|
|
and 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 clear_conversation(self, session_id: str, actor: Actor, **payload) -> dict:
|
|
from services.dify_builder.conversation_lifecycle import clear
|
|
|
|
return clear(self, session_id, actor, **payload)
|
|
|
|
def abandon_task(self, session_id: str, actor: Actor, **payload) -> dict:
|
|
from services.dify_builder.conversation_lifecycle import abandon
|
|
|
|
return abandon(self, session_id, actor, **payload)
|
|
|
|
def prepare_fix_intent(self, session_id: str, actor: Actor, *, base_version: int, **payload):
|
|
from services.dify_builder.fix_intents import prepare_fix_intent
|
|
from services.dify_builder.task_lifecycle import task_identity
|
|
|
|
current, context = self._get_authorized_session(session_id, actor)
|
|
if type(base_version) is not int or base_version < 1:
|
|
raise BadRequestError("base_version must be a positive integer")
|
|
observed, previous = self._repo.get_session_at_version(session_id, base_version)
|
|
if self._fix_intent_repo is None:
|
|
raise BadRequestError("Fix intake is unavailable")
|
|
client_action_id = payload.get("client_action_id")
|
|
saved = (
|
|
self._fix_intent_repo.find(actor, session_id, client_action_id)
|
|
if isinstance(client_action_id, str)
|
|
else None
|
|
)
|
|
target = task_identity(observed, previous)
|
|
if saved is not None:
|
|
if saved.target_task_id != target:
|
|
raise ConflictError("Fix replay base targets a different task")
|
|
# The use case still validates source/model/material and the saved
|
|
# body digest. A known key permits replay after its own activation,
|
|
# never arbitrary reuse of an old task's base for a new request.
|
|
elif task_identity(current, context) != target:
|
|
raise ConflictError("Fix intent targets a superseded task")
|
|
return prepare_fix_intent(self, session_id, actor, **payload)
|
|
|
|
def get_fix_control_receipt(
|
|
self,
|
|
session_id: str,
|
|
actor: Actor,
|
|
*,
|
|
operation: str,
|
|
client_action_id: str,
|
|
base_version: int,
|
|
fix_intent_id: str | None = None,
|
|
expected_task_id: str | None = None,
|
|
) -> dict:
|
|
"""Read an exact durable Fix admission/decision; never replay its effects."""
|
|
from services.dify_builder.fix_intents import get_fix_control_receipt
|
|
|
|
return get_fix_control_receipt(
|
|
self,
|
|
session_id,
|
|
actor,
|
|
operation=operation,
|
|
client_action_id=client_action_id,
|
|
base_version=base_version,
|
|
fix_intent_id=fix_intent_id,
|
|
expected_task_id=expected_task_id,
|
|
)
|
|
|
|
def is_conversation_cleared(self, session_id: str, actor: Actor) -> bool:
|
|
from services.dify_builder.conversation_lifecycle import is_conversation_cleared
|
|
|
|
with self._stream_read_scope_fn():
|
|
_, context = self._get_authorized_session(session_id, actor)
|
|
return is_conversation_cleared(context)
|
|
|
|
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)
|
|
from services.dify_builder.conversation_lifecycle import cleared_session_view, is_conversation_cleared
|
|
|
|
if is_conversation_cleared(fc):
|
|
view = cleared_session_view(s, fc)
|
|
if view.lifecycle_settlement == "pending" and self._repo.cleared_execution_settled(s):
|
|
view.lifecycle_settlement = "settled"
|
|
return view
|
|
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")
|
|
_, context = self._get_authorized_session(session_id, actor)
|
|
from services.dify_builder.conversation_lifecycle import is_conversation_cleared
|
|
|
|
if is_conversation_cleared(context):
|
|
return ConversationPage(data=[], has_more=False, first_seq=None, last_seq=None)
|
|
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_lifecycle import (
|
|
cleared_session_view,
|
|
is_conversation_cleared,
|
|
lifecycle_settlement,
|
|
)
|
|
from services.dify_builder.conversation_tasks import active_entry_mode
|
|
from services.dify_builder.task_lifecycle import task_identity
|
|
|
|
if is_conversation_cleared(fc):
|
|
return cleared_session_view(s, fc)
|
|
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
|
|
native_controls = False
|
|
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")
|
|
native_controls = receipt.status != "terminal" or receipt.outcome == "unknown"
|
|
if native_controls:
|
|
actions = control_actions(receipt)
|
|
decision = _decision_for(st, actions)
|
|
if native_controls and decision is not None:
|
|
decision.title = control_title(decision.options[0].id)
|
|
decision.description = (
|
|
copy_text(BuilderCopyId.DECISION_TEST_OPERATION_APPROVAL_DESCRIPTION)
|
|
if decision.options[0].id == "confirm_test_operation"
|
|
else ""
|
|
)
|
|
decision.default_option_id = ""
|
|
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,
|
|
),
|
|
)
|
|
if fc.task_status == "abandoned":
|
|
actions = []
|
|
decision = None
|
|
pending_context = None
|
|
if self._fix_intent_repo is not None:
|
|
from services.dify_builder.fix_intents import pending_fix_projection
|
|
|
|
projection = pending_fix_projection(
|
|
self, s, fc, Actor(account_id=s.owner_account_id, tenant_id=s.tenant_id)
|
|
)
|
|
if projection is not None:
|
|
actions, decision, pending_context = projection
|
|
return SessionView(
|
|
lifecycle_settlement=lifecycle_settlement(fc),
|
|
task_id=task_identity(s, fc),
|
|
task_status="completed"
|
|
if is_terminal(st)
|
|
else "paused"
|
|
if fc.paused and fc.task_status != "abandoned"
|
|
else fc.task_status or "active",
|
|
task_summaries=fc.task_summaries,
|
|
pending_fix_context=pending_context,
|
|
test_execution=test_execution,
|
|
diagnostics=diagnostics,
|
|
attachments=self._attachment_intake.receipts(s, Actor(account_id=s.owner_account_id, tenant_id=s.tenant_id))
|
|
if self._attachment_intake is not None
|
|
else [],
|
|
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,
|
|
command_pending=lock_held or continuation_pending,
|
|
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)
|
|
from services.dify_builder.conversation_lifecycle import is_conversation_cleared
|
|
|
|
if is_conversation_cleared(fc):
|
|
raise ConflictError("conversation was cleared")
|
|
if action.kind in {"dismiss_fix", "stop_and_switch_fix"}:
|
|
from services.dify_builder.fix_intents import submit_fix_intent
|
|
|
|
return submit_fix_intent(self, session_id, actor, action), False, []
|
|
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 action.kind == "message" and (
|
|
fc.task_status == "abandoned"
|
|
or fc.task_control.get("fresh_goal_receipt", {}).get("id") == action.payload["client_turn_id"]
|
|
):
|
|
from services.dify_builder.task_controls import generation
|
|
from services.dify_builder.task_lifecycle import task_identity
|
|
|
|
self._validate_model_config(actor, fc.model_config)
|
|
dsl, revision = self._read_task_dsl_fn(s.app_id, actor)
|
|
observed, previous = self._repo.get_session_at_version(session_id, action.base_version)
|
|
fresh, context, activated = self._repo.activate_abandoned_continuation(
|
|
session_id,
|
|
actor,
|
|
base_version=action.base_version,
|
|
client_turn_id=action.payload["client_turn_id"],
|
|
text=action.payload["text"],
|
|
app_revision=revision,
|
|
dsl=dsl,
|
|
expected_task_id=task_identity(observed, previous),
|
|
)
|
|
action.kind = "send_edit_goal"
|
|
action.base_version = fresh.version
|
|
action.base_app_revision = revision
|
|
action.execution_generation = generation(context)
|
|
action.command_id = action.payload["client_turn_id"]
|
|
return self._build_session_view(fresh, context), activated, []
|
|
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 turn_kinds and self._attachment_intake is not None:
|
|
receipt = self._attachment_intake.submit(
|
|
s,
|
|
fc,
|
|
actor,
|
|
text=action.payload["text"],
|
|
upload_file_ids=[],
|
|
client_turn_id=client_turn_id,
|
|
as_message=True,
|
|
)
|
|
if receipt.status not in {"chat_pending", "chat_consumed"}:
|
|
return self._build_session_view(s, fc), False, []
|
|
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 s.current_state == PcState.BUILD_RESOURCE_RECOMMENDATION and action.kind in {
|
|
"confirm_resources",
|
|
"rediscover_resources",
|
|
}:
|
|
expected = "rediscover_resources" if fc.resource_selection.get("refresh_required") else "confirm_resources"
|
|
if action.kind != expected:
|
|
raise BadRequestError(f"action {action.kind} is not allowed for the current resource selection")
|
|
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 = self._validate_model_config(actor, 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, []
|
|
if action.kind == "message" and self._attachment_intake is not None:
|
|
self._attachment_intake.submit(
|
|
s,
|
|
fc,
|
|
actor,
|
|
text=action.payload["text"],
|
|
upload_file_ids=[],
|
|
client_turn_id=action.payload["client_turn_id"],
|
|
as_message=True,
|
|
)
|
|
return self._build_session_view(s, fc), True, []
|
|
|
|
def _prepare_test_action(self, s: Session, fc: DifyBuilderContext, actor: Actor, action: Action):
|
|
"""Apply an authorized native control once and commit its choice receipt.
|
|
|
|
The command digest and native owner retain retry identity. Public
|
|
presentation records the chosen action, independently of its scope.
|
|
"""
|
|
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._acquire_workflow_writer(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_request_id = self._test_actions.current_request_id(
|
|
actor=actor, session_id=s.id, test_input_id=fc.test_input_ref
|
|
)
|
|
if current_request_id != command.request_id:
|
|
raise ConflictError("test request is no longer current")
|
|
response = InteractionResponseItem(
|
|
reply_language=fc.reply_language,
|
|
interaction_kind="choice",
|
|
question=control_title(action.kind),
|
|
answer=control_label(action.kind),
|
|
submitted_data={"option_id": action.kind},
|
|
)
|
|
if action.receipt_snapshot is not None:
|
|
apply_receipt_snapshot(response, action.receipt_snapshot)
|
|
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="interaction_response",
|
|
at_version=s.version + 1,
|
|
payload={
|
|
**asdict(response),
|
|
"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._release_workflow_writer(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._release_workflow_writer(s.id, token)
|
|
|
|
def submit_action(self, session_id: str, actor: Actor, action: Action) -> SessionView:
|
|
"""Submit an action and return the latest bounded session view."""
|
|
receipt = self.submit_action_command(session_id, actor, action)
|
|
return receipt.session or self.get_session_view(session_id, actor)
|
|
|
|
def submit_action_command(self, session_id: str, actor: Actor, action: Action) -> CommandSubmission:
|
|
"""Validate and admit once. Return the projection only for synchronous settlement."""
|
|
view, expect_advance, _ = self._prepare_action(session_id, actor, action)
|
|
if expect_advance:
|
|
return CommandSubmission(self.dispatch(session_id, action, actor), pending=True)
|
|
return CommandSubmission(action.command_id, pending=False, session=view)
|
|
|
|
def submit_message(
|
|
self,
|
|
session_id: str,
|
|
actor: Actor,
|
|
text: str,
|
|
base_version: int,
|
|
client_turn_id: str,
|
|
attachments: list[str] | None = None,
|
|
) -> SessionView:
|
|
"""Submit a message and return the latest bounded session view."""
|
|
receipt = self.submit_message_command(session_id, actor, text, base_version, client_turn_id, attachments)
|
|
return receipt.session or self.get_session_view(session_id, actor)
|
|
|
|
def submit_message_command(
|
|
self,
|
|
session_id: str,
|
|
actor: Actor,
|
|
text: str,
|
|
base_version: int,
|
|
client_turn_id: str,
|
|
attachments: list[str] | None = None,
|
|
) -> CommandSubmission:
|
|
"""Admit a chat turn or material. Material intake returns its durable receipt immediately."""
|
|
queued = self._prepare_material_message(session_id, actor, text, base_version, client_turn_id, attachments)
|
|
if queued is not None:
|
|
session, action = queued
|
|
command_id = self._dispatch_material_action(session.id, action, actor) if action is not None else ""
|
|
return CommandSubmission(
|
|
command_id, pending=bool(command_id), session=self.get_session_view(session.id, actor)
|
|
)
|
|
if not isinstance(text, str) or not (text := text.strip()):
|
|
raise BadRequestError("message text is required")
|
|
return self.submit_action_command(
|
|
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", "send_edit_goal", "update_model", "stop", "resume", "dismiss_fix", "stop_and_switch_fix"}
|
|
| TEST_ACTIONS,
|
|
command_id=action.command_id,
|
|
initial_items=settled_items,
|
|
conversation_cleared=lambda: self.is_conversation_cleared(session_id, actor),
|
|
)
|
|
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),
|
|
conversation_cleared=lambda: self.is_conversation_cleared(session_id, actor),
|
|
)
|
|
|
|
def submit_message_stream(
|
|
self,
|
|
session_id: str,
|
|
actor: Actor,
|
|
text: str,
|
|
base_version: int,
|
|
client_turn_id: str,
|
|
attachments: list[str] | None = None,
|
|
) -> Iterator[str]:
|
|
queued = self._prepare_material_message(session_id, actor, text, base_version, client_turn_id, attachments)
|
|
if queued is not None:
|
|
from services.dify_builder.wiring import stream_advance_frames
|
|
|
|
session, action = queued
|
|
subscription = self._subscribe_fn(session_id) if action is not None else None
|
|
try:
|
|
command_id = self._dispatch_material_action(session.id, action, actor) if action is not None else ""
|
|
if not command_id and subscription is not None:
|
|
subscription.close()
|
|
subscription = None
|
|
view = asdict(self.get_session_view(session.id, actor))
|
|
except Exception:
|
|
if subscription is not None:
|
|
subscription.close()
|
|
raise
|
|
return stream_advance_frames(
|
|
view,
|
|
subscription,
|
|
expect_advance=bool(command_id),
|
|
emit_command_finished_when_settled=not bool(command_id),
|
|
command_id=command_id,
|
|
read_session_view=lambda: self._read_stream_session_view(session_id, actor),
|
|
conversation_cleared=lambda: self.is_conversation_cleared(session_id, actor),
|
|
)
|
|
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 _prepare_material_message(self, session_id, actor, text, base_version, client_turn_id, attachments):
|
|
"""Queue new material without interrupting an in-flight plan or taking its lease."""
|
|
if not attachments and self._attachment_intake is None:
|
|
return None
|
|
session, context = self._get_authorized_session(session_id, actor)
|
|
from services.dify_builder.conversation_lifecycle import is_conversation_cleared
|
|
|
|
if is_conversation_cleared(context):
|
|
raise ConflictError("conversation was cleared")
|
|
working = self._session_lock.exists(session.id) or is_working(session.current_state)
|
|
if self._attachment_intake is None:
|
|
raise BadRequestError("Attachment intake is unavailable")
|
|
if type(base_version) is not int or base_version > session.version:
|
|
raise ConflictError("stale base_version")
|
|
existing = self._attachment_intake.find_turn(session, actor, client_turn_id)
|
|
if not attachments and not working and existing is None:
|
|
return None
|
|
receipt = self._attachment_intake.submit(
|
|
session,
|
|
context,
|
|
actor,
|
|
text=text,
|
|
upload_file_ids=attachments,
|
|
client_turn_id=client_turn_id,
|
|
as_message=not attachments and not working,
|
|
)
|
|
if receipt.status == "chat_pending":
|
|
return None
|
|
session, context = self._get_authorized_session(session_id, actor)
|
|
working = self._session_lock.exists(session.id) or is_working(session.current_state)
|
|
action = None
|
|
if receipt.status == "pending" and not working and not context.paused:
|
|
action = Action(
|
|
kind="consume_material",
|
|
base_version=session.version,
|
|
command_id=str(uuid5(NAMESPACE_URL, f"builder-material:{session.id}:{receipt.id}")),
|
|
)
|
|
return session, action
|
|
|
|
def _dispatch_material_action(self, session_id: str, action: Action, actor: Actor) -> str:
|
|
try:
|
|
return self.dispatch(session_id, action, actor)
|
|
except BusyError:
|
|
return "" # The existing owner consumes this already accepted queue.
|
|
|
|
def dispatch_pending_material(self, session_id: str, actor: Actor) -> str:
|
|
"""Handoff after lease release; a concurrent winner keeps the same lease owner."""
|
|
if self._attachment_intake is None:
|
|
return ""
|
|
session, context = self._get_authorized_session(session_id, actor)
|
|
if context.paused or is_working(session.current_state) or self._session_lock.exists(session_id):
|
|
return ""
|
|
pending = self._attachment_intake.pending(session, context, actor)
|
|
if not pending:
|
|
return ""
|
|
return self._dispatch_material_action(
|
|
session_id,
|
|
Action(
|
|
kind="consume_material",
|
|
base_version=session.version,
|
|
command_id=str(uuid5(NAMESPACE_URL, f"builder-material:{session.id}:{pending[-1].id}")),
|
|
),
|
|
actor,
|
|
)
|
|
|
|
def _acquire_workflow_writer(self, session_id: str, *, generation: int | None = None) -> str | None:
|
|
from configs import dify_config
|
|
from services.dify_builder.task_controls import generation as task_generation
|
|
|
|
if generation is None:
|
|
_, context = self._repo.get_session(session_id)
|
|
generation = task_generation(context)
|
|
token = self._session_lock.acquire(session_id, generation=generation)
|
|
if token is None:
|
|
return None
|
|
try:
|
|
reserved = self._repo.reserve_workflow_writer(
|
|
session_id, token, generation, dify_config.DIFY_BUILDER_MAX_ADVANCE_SECONDS
|
|
)
|
|
except BaseException:
|
|
self._session_lock.release(session_id, token)
|
|
raise
|
|
if not reserved:
|
|
self._session_lock.release(session_id, token)
|
|
return None
|
|
return token
|
|
|
|
def _release_workflow_writer(self, session_id: str, token: str) -> None:
|
|
self._repo.release_workflow_writer(session_id, token=token)
|
|
self._session_lock.release(session_id, token)
|
|
|
|
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._acquire_workflow_writer(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._release_workflow_writer(session_id, token)
|
|
raise
|
|
if not won or receipt.command_id is None:
|
|
self._release_workflow_writer(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, *, opening: bool = False) -> str:
|
|
"""Reserve one workflow writer and enqueue once; uncertain ACKs retain ownership."""
|
|
action.command_id = action.command_id or str(uuid4())
|
|
from services.dify_builder.task_controls import generation
|
|
|
|
_, context = self._repo.get_session(session_id)
|
|
from services.dify_builder.application.cognition_policy import cognition_admission_required
|
|
|
|
if self._cognition_policy_fn is not None and cognition_admission_required(action.kind):
|
|
self._validate_model_config(actor, context.model_config)
|
|
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._acquire_workflow_writer(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 opening and (session.version != action.base_version or current.last_command_id):
|
|
raise ConflictError("opening command is no longer current")
|
|
if action.kind == "consume_material":
|
|
action.base_version = session.version
|
|
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, []
|
|
)
|
|
except Exception:
|
|
self._release_workflow_writer(session_id, token)
|
|
raise
|
|
# A broker acknowledgement can be lost before worker activation. Retain
|
|
# both leases on any enqueue error; Stop/expiry supplies recovery, never replay.
|
|
from services.dify_builder.errors import EnqueueAcknowledgementError
|
|
|
|
try:
|
|
self._enqueue_fn(session_id, action, actor, token)
|
|
except Exception as error:
|
|
raise EnqueueAcknowledgementError(str(error)) from error
|
|
return action.command_id
|