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

2549 lines
118 KiB
Python

"""The real ``DifyPort`` adapter -- direct in-process calls into Dify's own services.
Port of dify-enterprise/server/pkg/enterprise/data/dify_builder_dify.go, minus the
token-forwarding (the P1 port plan's Global Constraints / ADR drop
``ForwardAuth`` entirely -- see ``services.dify_builder.application.ports.DifyPort``).
``WorkflowServiceDifyPort`` wires the pure P1 domain (``core.dify_builder``)
and the pure Task 1-3 helpers in this package (``graph_ops``, ``run_mapping``,
``identity``) to ``WorkflowService`` / ``AppGenerateService`` / the node-
execution repository. It satisfies ``DifyPort`` structurally (the Protocol is
``@runtime_checkable``) -- there is no explicit inheritance.
Each method opens its own ``Session`` via
``sessionmaker(bind=db.engine, expire_on_commit=False)`` and resolves
account/app through the Task 3 helpers. The OSS gotchas baked in here (see
the P2 plan's Global Constraints):
- ``sync_draft_workflow`` has no server-side patch primitive -- ``apply_repair``
reads the whole draft graph, mutates it client-side via
``graph_ops.apply_set_node_config``, and writes the whole mutated graph
back with ``graph_only=True``/``sync_agent_bindings=False``. Confirmed native
Agent selections use one binding owner in the same graph transaction; all
final Agent nodes must be explicitly selected and prepared before sync. A ``WorkflowHashNotEqualError`` (the draft
changed since it was read) is re-mapped to the domain ``HashMismatchError``.
- ``run_draft`` invokes ``AppGenerateService.generate`` with
``invoke_from=InvokeFrom.DEBUGGER`` (runs the *draft*, not the published
workflow) and ``streaming=True``, so ``on_event`` fires while the run is
still going instead of replaying finished rows afterwards. It selects
``workflow_execution_mode="in_process"`` so the native generator runs inside
the existing Builder task. Every Builder run is admitted and bound to saved
session/test-input identity before native generation; unsupported workflows
return a typed refusal without a native run ID.
Enqueuing a child task and waiting for its stream
deadlocks a worker that consumes both queues with only one execution slot.
If the stream ends WITHOUT a terminal frame, the
outcome is recovered from the workflow-run row rather than synthesised as a
failure -- reporting a run that actually succeeded as failed would feed the
repair loop a lie.
- ``publish`` MUST update ``app.workflow_id`` in the same transaction as
``publish_workflow``: ``publish_workflow`` only creates the new published
``Workflow`` row: it does not repoint the app at it. Skipping that update
makes publish a silent no-op. The agent-retirement tail
(``WorkflowAgentRetirementService.retire_unowned`` /
``enqueue_agent_resource_collection``) is intentionally skipped -- it only
matters for Agent-node workflows and is not part of this port's contract.
"""
import hashlib
import json
import logging
import threading
from collections.abc import Callable, Mapping, Sequence
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any, Literal
from uuid import NAMESPACE_URL, uuid4, uuid5
from pydantic import JsonValue
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.app.app_config.workflow_ui_based_app.variables.manager import WorkflowVariablesConfigManager
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.dify_builder.changes import describe_changed_nodes
from core.dify_builder.contract import CanvasEvent
from core.dify_builder.input_schema import SYSTEM_QUERY, validate_query
from core.dify_builder.models import (
Actor,
ApplyResult,
DifyBuilderContext,
Graph,
Inputs,
MutationIntent,
NodeEvent,
NodeOutput,
PublishResult,
Run,
RunVerification,
)
from extensions.ext_database import db
from graphon.enums import BuiltinNodeTypes
from libs.datetime_utils import naive_utc_now
from libs.flask_utils import set_login_user
from models.account import Account
from models.dify_builder import DifyBuilderRun, DifyBuilderSession, DifyBuilderSessionCommit, DifyBuilderTestInput
from models.enums import CreatorUserRole
from models.model import App
from models.tools import BuiltinToolProvider
from models.workflow import Workflow, WorkflowRun
from repositories.factory import DifyAPIRepositoryFactory
from runtime_contracts.builder.execution import canonical_digest
from services.app_generate_service import AppGenerateService
from services.dify_builder import graph_ops
from services.dify_builder.application.execution_policy import (
BuilderExecutionContext,
BuilderExecutionObservation,
BuilderExecutionPolicyError,
BuilderExecutionRecorder,
BuilderExecutionRefusal,
ConfirmedToolExecutionContext,
ExecutionEvidenceSummary,
HttpFixtureSetV1,
HttpResponseFixtureV1,
JsonToolExecutionContext,
TrustedExecutionCompletion,
admitted_node_bindings,
scalar_inputs_digest,
)
from services.dify_builder.application.handlers_fix import run_finished_without_output
from services.dify_builder.effect_disclosure import EffectDisclosure, disclose
from services.dify_builder.errors import HashMismatchError, PreflightError, WorkflowNotInitializedError
from services.dify_builder.execution_policy_service import (
BuilderExecutionPolicyService,
_is_json_parse_candidate,
admit_execution_workflow,
restricted_admission_snapshot,
)
from services.dify_builder.identity import load_app, resolve_account
from services.dify_builder.json_tool_execution import admit_json_workflow
from services.dify_builder.node_policy import proposal_policy_rejections
from services.dify_builder.output_evidence import collect_output_findings
from services.dify_builder.preflight import new_preflight_problems
from services.dify_builder.real_call_consent_service import BuilderRealCallConsentService, ServiceConfirmedToolGuard
from services.dify_builder.repository import SqlDifyBuilderRepository
from services.dify_builder.revision import executable_graph_revision, execution_revision, merge_canvas_presentation
from services.dify_builder.run_mapping import (
error_from_stream_chunk,
is_unfinished_run_status,
map_error_frame_run,
map_run_result,
map_unknown_run_outcome,
node_event_from_stream_chunk,
run_id_from_stream_chunk,
run_result_data_from_run_row,
run_result_data_from_terminal_chunk,
sanitize_confirmed_node_event,
sanitize_confirmed_run,
sanitize_confirmed_stream_event,
stream_chunk_as_mapping,
)
from services.errors.app import WorkflowHashNotEqualError
from services.workflow.draft_coordination_service import WorkflowDraftCoordinationService
from services.workflow_draft_sync_service import notify_workflow_draft_changed
from services.workflow_generation import graph_normalizers
from services.workflow_service import WorkflowService
if TYPE_CHECKING:
from services.dify_builder.native_testing.agent_binding import (
AgentBindingAuthorization,
AgentBindingSelection,
AgentBindingService,
)
__all__ = ["ConfirmedLaunchRecorder", "WorkflowServiceDifyPort"]
_CREATE_NODE_CANVAS_EVENTS: dict[str, CanvasEvent] = {
BuiltinNodeTypes.START: CanvasEvent.ADD_START_NODE,
BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: CanvasEvent.ADD_KNOWLEDGE_NODE,
BuiltinNodeTypes.LLM: CanvasEvent.ADD_LLM_NODE,
BuiltinNodeTypes.END: CanvasEvent.ADD_OUTPUT_NODE,
}
def _canvas_event_for_intent(intent: MutationIntent) -> CanvasEvent:
"""Map an applied ``MutationIntent`` to the canvas event that narrates it
(spec Sec 6). ``create_node`` dispatches by ``node_type`` to the matching
``add_*_node`` event; ``set_node_config`` (Fix's only verb today) maps to
``apply_error_fix``; every other verb (``delete_node``/``connect``/
``insert_between``) and any unmapped ``node_type`` default to the
generic ``apply_edit_plan`` batch-mutation event.
"""
if intent.op == "create_node":
return _CREATE_NODE_CANVAS_EVENTS.get(intent.args.get("node_type", ""), CanvasEvent.APPLY_EDIT_PLAN)
if intent.op == "set_node_config":
return CanvasEvent.APPLY_ERROR_FIX
return CanvasEvent.APPLY_EDIT_PLAN
def _canvas_payload(intent: MutationIntent, changed: list[str]) -> dict[str, Any]:
"""Build the ``{"event": ..., "node_id"?/"edge"?}`` dict passed to ``on_canvas``."""
payload: dict[str, Any] = {"event": str(_canvas_event_for_intent(intent))}
if intent.op == "connect":
payload["edge"] = {"source": intent.args.get("from_node"), "target": intent.args.get("to_node")}
elif intent.op == "delete_node":
payload["node_id"] = intent.args.get("node_id")
else: # create_node | insert_between | set_node_config
payload["node_id"] = changed[0] if changed else None
return payload
def _session_factory() -> sessionmaker[Session]:
"""One session per adapter call -- never shared/held across calls."""
return sessionmaker(bind=db.engine, expire_on_commit=False)
def _unwrap_status(status: Any) -> Any:
"""Unwrap a ``graphon`` ``StrEnum``'s ``.value`` if present, else pass through.
Mirrors ``run_mapping._status_value`` locally so this module doesn't reach
into another module's private helper.
"""
return status.value if isinstance(status, Enum) else status
def _load_draft_workflow_or_raise(app: App, *, session: Session) -> Workflow:
workflow = WorkflowService().get_draft_workflow(app, session=session)
if workflow is None:
raise WorkflowNotInitializedError(f"app has no draft workflow: {app.id}")
return workflow
def _confirmed_agent_selections(
graph: Graph,
resource_ids: Sequence[str] | None,
*,
retained: Mapping[str, "AgentBindingSelection"] | None = None,
) -> dict[str, "AgentBindingSelection"]:
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from services.dify_builder.native_testing.agent_binding import AgentBindingSelection, has_native_roster_binding
confirmed = {}
for resource_id in resource_ids or ():
if resource_id.startswith("agent:"):
selection = AgentBindingSelection.from_resource_id(resource_id)
identity = (selection.agent_id, selection.snapshot_id)
if identity in confirmed and confirmed[identity] != selection:
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
confirmed[identity] = selection
result = {}
for node_id, data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph):
if not has_native_roster_binding(data):
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
binding = data["agent_binding"]
selection = confirmed.get((binding["agent_id"], binding["current_snapshot_id"]))
if selection is None:
selection = (retained or {}).get(node_id)
if selection is None or (selection.agent_id, selection.snapshot_id) != (
binding["agent_id"],
binding["current_snapshot_id"],
):
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
result[node_id] = selection
return result
def _agent_binding_stamp(binding) -> str:
return canonical_digest(
{
"id": binding.id,
"agent_id": binding.agent_id,
"snapshot_id": binding.current_snapshot_id,
"binding_type": str(binding.binding_type),
"job": binding.node_job_config_dict,
}
)
def _retained_native_agent_selections(*, session: Session, actor: Actor, workflow: Workflow, graph: Graph):
"""Retain only an unchanged same-node SQL binding, never a graph-supplied pin.
Current catalog access/publication is resolved before the writer. The binding
stamp is rechecked under its draft lock before the normal prepare/sync owner.
"""
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from models.agent import WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent_config_entities import WorkflowNodeJobConfig
from services.dify_builder.native_testing.agent_binding import AgentBindingSelection, native_node_data
from services.dify_builder.native_testing.agent_catalog import AgentCatalogService
from services.dify_builder.revision import node_config_digest
def execution_data(data):
# Native Agent title/description and revision-owned canvas transients
# carry no Agent job, published Soul, binding or capability authority.
return node_config_digest({key: value for key, value in data.items() if key not in {"title", "desc"}})
baseline = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(workflow.graph_dict))
unchanged = {
node_id: data
for node_id, data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph)
if node_id in baseline and execution_data(data) == execution_data(baseline[node_id])
}
if not unchanged:
return {}, {}
rows = session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == actor.tenant_id,
WorkflowAgentNodeBinding.app_id == workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == workflow.id,
WorkflowAgentNodeBinding.workflow_version == workflow.version,
WorkflowAgentNodeBinding.node_id.in_(unchanged),
)
).all()
by_node = {row.node_id: row for row in rows}
for node_id, data in unchanged.items():
row = by_node.get(node_id)
if (
row is None
or row.binding_type != WorkflowAgentBindingType.ROSTER_AGENT
or row.agent_id is None
or row.current_snapshot_id is None
):
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
expected = native_node_data(
agent_id=row.agent_id,
snapshot_id=row.current_snapshot_id,
node_job=WorkflowNodeJobConfig.model_validate(row.node_job_config_dict),
)
if any(data.get(key) != value for key, value in expected.items() if key != "title"):
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
candidates = AgentCatalogService(session).resolve_candidates(
tenant_id=actor.tenant_id,
actor_id=actor.account_id,
app_id=workflow.app_id,
agent_ids=frozenset(row.agent_id for row in rows if row.agent_id is not None),
)
by_agent = {candidate.agent_id: candidate for candidate in candidates}
retained, stamps = {}, {}
for node_id, row in by_node.items():
if row.agent_id is None or row.current_snapshot_id is None:
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
candidate = by_agent.get(row.agent_id)
if candidate is None or candidate.snapshot_id != row.current_snapshot_id:
raise BuilderExecutionPolicyError("agent_not_available")
retained[node_id] = AgentBindingSelection(
agent_id=row.agent_id, snapshot_id=row.current_snapshot_id, capability_digest=candidate.capability_digest
)
stamps[node_id] = _agent_binding_stamp(row)
return retained, stamps
def _prepare_native_agent_graph(
*,
session: Session,
actor: Actor,
workflow: Workflow,
graph: Graph,
selections: Mapping[str, "AgentBindingSelection"],
authorization: "AgentBindingAuthorization | None",
retained_binding_stamps: Mapping[str, str] | None = None,
) -> tuple[Graph, "AgentBindingService | None"]:
"""Prepare all final Agent projections through one owner, in the caller's transaction."""
from copy import deepcopy
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from models.agent import WorkflowAgentNodeBinding
from services.agent.workflow_publish_service import WorkflowAgentPublishService
from services.dify_builder.native_testing.agent_binding import AgentBindingService, has_native_roster_binding
graph = deepcopy(graph)
nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph))
existing = {
row.node_id: row
for row in session.scalars(
select(WorkflowAgentNodeBinding)
.where(
WorkflowAgentNodeBinding.tenant_id == actor.tenant_id,
WorkflowAgentNodeBinding.app_id == workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == workflow.id,
WorkflowAgentNodeBinding.workflow_version == workflow.version,
)
.order_by(WorkflowAgentNodeBinding.node_id)
.with_for_update()
.execution_options(populate_existing=True)
).all()
}
for node_id, stamp in (retained_binding_stamps or {}).items():
if node_id not in existing or _agent_binding_stamp(existing[node_id]) != stamp:
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
if not nodes and not existing and not selections:
return graph, None
if (
not selections
or set(selections) != set(nodes)
or not set(existing).issubset(nodes)
or any(not has_native_roster_binding(data) for data in nodes.values())
):
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
service = AgentBindingService(session, authorization=authorization)
workflow.graph = json.dumps(graph)
for node_id in sorted(nodes):
data = nodes[node_id]
selection = selections[node_id]
if (data["agent_binding"]["agent_id"], data["agent_binding"]["current_snapshot_id"]) != (
selection.agent_id,
selection.snapshot_id,
):
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
job = WorkflowAgentPublishService._node_job_config_from_node_data(
node_data=data,
existing_binding=existing.get(node_id),
)
prepared = service.prepare_node(
actor=actor,
workflow=workflow,
node_id=node_id,
selection=selection,
node_job=job,
existing_node_data=data if node_id in (retained_binding_stamps or {}) else None,
)
for node in graph["nodes"]:
if node["id"] == node_id:
node["data"] = prepared
workflow.graph = json.dumps(graph)
return graph, service
def _sync_graph_only(
app: App,
graph: Graph,
workflow: Workflow,
unique_hash: str,
account: Account,
session: Session,
app_id: str,
*,
commit: bool = True,
coordination_id: str | None = None,
) -> Workflow:
"""Write ``graph`` back to the draft via the graph-only sync both
``apply_repair`` and ``restore_graph`` use (no server-side patch
primitive -- see the module docstring). Remaps
``WorkflowHashNotEqualError`` (the draft changed since it was read) to
the domain ``HashMismatchError``."""
try:
return WorkflowService().sync_draft_workflow(
app_model=app,
graph=graph,
features=dict(workflow.features_dict),
unique_hash=unique_hash,
account=account,
environment_variables=[],
conversation_variables=workflow.conversation_variables,
session=session,
graph_only=True,
sync_agent_bindings=False,
commit=commit,
coordination_id=coordination_id,
preserve_environment_variables=True,
)
except WorkflowHashNotEqualError as exc:
raise HashMismatchError(f"draft workflow changed since read: {app_id}") from exc
@dataclass
class ConfirmedLaunchRecorder:
"""The launch recorder of one confirmed Run, which also issues its service-owned guard.
Built only by the launch admission after ``claim_launch`` captured this
Run's identity. ``confirmed_guard`` is called by the native Tool runtime at
Graphon's actual node binding: it claims the grant through the consent
owner for exactly this native Run and returns ``ServiceConfirmedToolGuard``.
It never caches, rebuilds or deserializes a guard; a second claim refuses
in the consent owner. A confirmed run records no restricted observations.
"""
base: BuilderExecutionRecorder
consent: BuilderRealCallConsentService
context: ConfirmedToolExecutionContext
native_run_id: str
@property
def healthy(self) -> bool:
return self.base.healthy
def record(self, observation: BuilderExecutionObservation) -> None:
self.base.record(observation)
def confirmed_guard(
self, *, node_id: str, native_run_id: str, native_node_execution_id: str
) -> ServiceConfirmedToolGuard:
if not self.base.healthy:
raise BuilderExecutionPolicyError("execution_recorder_unhealthy")
if native_run_id != self.native_run_id:
raise BuilderExecutionPolicyError("execution_launch_mismatch")
binding = self.consent.claim_tool(
context=self.context,
node_id=node_id,
native_run_id=native_run_id,
native_node_execution_id=native_node_execution_id,
)
return ServiceConfirmedToolGuard(self.consent, binding)
@dataclass
class _BuilderExecutionLaunch:
"""One invocation's admission and recorder, retained by the completion owner.
``consent`` is set exactly for a confirmed context; the admission then
hands the native factory a ``ConfirmedLaunchRecorder``.
"""
service: BuilderExecutionPolicyService
context: BuilderExecutionContext | ConfirmedToolExecutionContext | JsonToolExecutionContext
consent: BuilderRealCallConsentService | None = None
recorder: BuilderExecutionRecorder | None = None
refusal: BuilderExecutionPolicyError | None = None
_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
_launch_thread: threading.Thread = field(default_factory=threading.current_thread, init=False)
_preworker_refusal: BuilderExecutionPolicyError | None = field(default=None, init=False)
_claim: tuple[str, str] | None = field(default=None, init=False)
_candidate: tuple[threading.Thread, str, str, Literal["returned", "policy_refused", "stopped", "failed"]] | None = (
field(default=None, init=False)
)
_invalid: bool = field(default=False, init=False)
_completion: TrustedExecutionCompletion | None = field(default=None, init=False)
finalization_closed: bool = field(default=False, init=False)
def on_worker_finished(
self,
*,
context: BuilderExecutionContext | ConfirmedToolExecutionContext | JsonToolExecutionContext,
native_run_id: str,
task_id: str,
recorder: BuilderExecutionRecorder | None,
worker_thread: threading.Thread,
exit_kind: Literal["returned", "policy_refused", "stopped", "failed"],
) -> None:
with self._lock:
try:
if (
context != self.context
or worker_thread is not threading.current_thread()
or recorder is not self.recorder
or (self._claim is not None and self._claim != (native_run_id, task_id))
or self._preworker_refusal is not None
or exit_kind not in {"returned", "policy_refused", "stopped", "failed"}
):
raise BuilderExecutionPolicyError("invalid_worker_completion")
candidate = (worker_thread, native_run_id, task_id, exit_kind)
if self._candidate is not None and self._candidate != candidate:
raise BuilderExecutionPolicyError("conflicting_worker_completion")
if not self.finalization_closed:
self._candidate = candidate
except BaseException:
self._invalid = True
raise
def finish(
self, *, response_completed: bool, setup_refusal: BuilderExecutionPolicyError | None = None
) -> TrustedExecutionCompletion:
"""One nonblocking snapshot after the native response's existing close/join."""
with self._lock:
if self._completion is not None:
return self._completion
self.finalization_closed = True
candidate = self._candidate
finished = candidate is not None and not self._invalid and not candidate[0].is_alive()
preworker = (
self._preworker_refusal is not None
and self._preworker_refusal is self.refusal
and not self._invalid
and self._claim is None
and self.recorder is None
and candidate is None
)
self._completion = TrustedExecutionCompletion(
context=self.context,
native_run_id=self._claim[0] if self._claim else None,
task_id=self._claim[1] if self._claim else None,
worker_finished=finished,
worker_exit=candidate[3] if candidate is not None else "not_observed",
recorder_healthy=self.recorder is not None and self.recorder.healthy and not self._invalid,
response_completed=(
response_completed if setup_refusal is None else preworker and self.refusal is setup_refusal
),
preworker_refusal=preworker,
refusal=BuilderExecutionRefusal(reason_code=self.refusal.reason_code) if self.refusal else None,
)
return self._completion
def on_refusal(self, error: BuilderExecutionPolicyError, *, preworker: bool = False) -> None:
with self._lock:
if preworker and (
threading.current_thread() is not self._launch_thread
or self._claim is not None
or self.recorder is not None
or self._candidate is not None
or self._invalid
or self.finalization_closed
or (self.refusal is not None and self.refusal is not error)
):
self._invalid = True
raise BuilderExecutionPolicyError("invalid_preworker_refusal")
if self._preworker_refusal is not None and self._preworker_refusal is not error:
self._invalid = True
if not self.finalization_closed:
self.refusal = error
if preworker:
self._preworker_refusal = error
def admit_raw(self, workflow: Workflow, app: App) -> None:
admit_execution_workflow(self.context, restricted_admission_snapshot(workflow, app))
def __call__(
self,
workflow: Workflow,
app: App,
entity: WorkflowAppGenerateEntity,
root_node_id: str,
resume: bool,
) -> BuilderExecutionRecorder:
try:
if isinstance(self.context, ConfirmedToolExecutionContext) and self.consent is None:
raise BuilderExecutionPolicyError("missing_confirmed_tool_guard")
config = entity.app_config
if (
entity.builder_execution != self.context
or entity.files
or entity.single_iteration_run is not None
or entity.single_loop_run is not None
or entity.invoke_from != InvokeFrom.DEBUGGER
or (config.app_id, config.tenant_id, config.workflow_id, config.app_mode)
!= (app.id, app.tenant_id, workflow.id, "workflow")
):
raise BuilderExecutionPolicyError("execution_owner_mismatch")
self.service.revalidate(
context=self.context,
workflow=workflow,
app=app,
actor_id=entity.user_id,
effective_inputs=dict(entity.inputs),
root_node_id=root_node_id,
call_depth=entity.call_depth,
transport="in_process",
resume=resume,
)
self.service.claim_launch(
context=self.context,
native_run_id=entity.workflow_execution_id,
task_id=entity.task_id,
)
with self._lock:
if self._preworker_refusal is not None:
self._invalid = True
self._claim = (entity.workflow_execution_id, entity.task_id)
recorder: BuilderExecutionRecorder = self.service.recorder(
context=self.context,
native_run_id=entity.workflow_execution_id,
task_id=entity.task_id,
)
if isinstance(self.context, ConfirmedToolExecutionContext):
assert self.consent is not None # refused before the launch claim below
recorder = ConfirmedLaunchRecorder(recorder, self.consent, self.context, entity.workflow_execution_id)
with self._lock:
self.recorder = recorder
return recorder
except BuilderExecutionPolicyError as error:
self.on_refusal(error)
raise
# Refusals meaning another delivery already owns this reserved command's launch.
_DUPLICATE_LAUNCH_REASONS = frozenset({"execution_already_launched", "execution_already_claimed"})
def _tool_account_metadata(
session: Session, tenant_id: str, graph: Mapping[str, Any]
) -> tuple[dict[str, str], dict[str, str]]:
"""Resolve display aliases and opaque identity/version bindings without decrypting credentials."""
selections: dict[str, tuple[str, str]] = {}
for node in graph.get("nodes") or []:
data = node.get("data") if isinstance(node, Mapping) else None
if not isinstance(data, Mapping) or data.get("type") != BuiltinNodeTypes.TOOL:
continue
if data.get("provider_type") not in {"builtin", "plugin"} or not isinstance(data.get("provider_id"), str):
continue
selections[str(node.get("id"))] = (data["provider_id"], str(data.get("credential_id") or ""))
if not selections:
return {}, {}
from models.provider_ids import ToolProviderID
provider_aliases: dict[str, set[str]] = {}
for provider, _ in selections.values():
aliases = {provider}
try:
native_id = ToolProviderID(provider)
if str(ToolProviderID(native_id.provider_name)) == str(native_id):
aliases.add(native_id.provider_name)
except (ValueError, TypeError):
pass
provider_aliases[provider] = aliases
rows = session.scalars(
select(BuiltinToolProvider).where(
BuiltinToolProvider.tenant_id == tenant_id,
BuiltinToolProvider.provider.in_(set().union(*provider_aliases.values())),
)
).all()
names: dict[str, str] = {}
bindings: dict[str, str] = {}
for node_id, (provider, credential_id) in selections.items():
candidates = [row for row in rows if row.provider in provider_aliases[provider]]
if credential_id:
chosen = next((row for row in candidates if row.id == credential_id), None)
else:
chosen = next((row for row in candidates if row.is_default), None) or min(
candidates, key=lambda row: row.created_at, default=None
)
if chosen is not None:
names[node_id] = chosen.name or ""
from services.tools.draft_tool_setup import tool_credential_fingerprint
bindings[node_id] = tool_credential_fingerprint(chosen)
return names, bindings
def _effect_scope_binding(workflow: Workflow) -> str:
"""Hash semantic namespace values, as execution_revision does; expose only the digest.
Secret re-encryption during an unchanged autosave must not invalidate a
disclosure. Resolved values exist only in this local hash input.
"""
state = [
sorted(
(variable.model_dump(mode="json") for variable in variables),
key=lambda variable: str(variable.get("name") or variable.get("id") or ""),
)
for variables in (workflow.environment_variables, workflow.conversation_variables)
]
if not any(state):
return ""
return hashlib.sha256(json.dumps(state, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _unsupported_run(error: BuilderExecutionPolicyError) -> Run:
return Run(status="failed", execution_refusal=BuilderExecutionRefusal(reason_code=error.reason_code))
def _code_scope_binding(workflow: Workflow) -> str:
"""Code explanations bind execution features as well as namespace values."""
return canonical_digest({"features": workflow.features_dict, "namespace": _effect_scope_binding(workflow)})
class WorkflowServiceDifyPort:
"""``DifyPort`` implementation backed directly by Dify's own services."""
def stamp_http_fixtures(
self, app_id: str, actor: Actor, *, base_app_revision: str, fixtures: tuple[HttpResponseFixtureV1, ...]
) -> HttpFixtureSetV1:
from services.dify_builder.execution_policy_service import BuilderExecutionPolicyService
return BuilderExecutionPolicyService(_session_factory()).stamp_http_fixtures(
app_id, actor, base_app_revision=base_app_revision, fixtures=fixtures
)
def get_app_mode(self, app_id: str, actor: Actor) -> str:
"""Read mode from the persisted app using the same tenant guard as graphs."""
with _session_factory()() as session:
return load_app(session, app_id, actor).mode
def read_task_dsl(self, app_id: str, actor: Actor) -> tuple[dict, str]:
"""Capture execution DSL for continuation, with credentials withheld."""
from services.dify_builder.credentials import REDACTED, redact_node_config
with _session_factory()() as sql:
app = load_app(sql, app_id, actor)
workflow = _load_draft_workflow_or_raise(app, session=sql)
graph = dict(workflow.graph_dict)
graph["nodes"] = [
{**node, "data": redact_node_config(dict(node.get("data") or {}))} for node in graph.get("nodes", [])
]
variables = []
for variable in workflow.environment_variables:
value = variable.model_dump(mode="json")
if value.get("value_type") == "secret":
value["value"] = REDACTED
variables.append(value)
from services.dify_builder.task_controls import draft_binding
return {
"_draft_binding": {"workflow_id": workflow.id, "digest": draft_binding(workflow)},
"graph": graph,
"features": dict(workflow.features_dict),
"environment_variables": variables,
"conversation_variables": [v.model_dump(mode="json") for v in workflow.conversation_variables],
}, execution_revision(workflow)
def read_graph(self, app_id: str, actor: Actor) -> tuple[Graph, str]:
with _session_factory()() as session:
app = load_app(session, app_id, actor)
workflow = _load_draft_workflow_or_raise(app, session=session)
return dict(workflow.graph_dict), execution_revision(workflow)
def prepare_code_risks(
self,
app_id: str,
actor: Actor,
*,
session_id: str,
inputs: Mapping[str, Any],
context: DifyBuilderContext,
) -> dict[str, dict[str, Any]]:
"""Reason once outside DB sessions; revalidate facts before exposing the snapshot."""
from services.dify_builder.code_risk_assessment import (
assess_code,
builder_code_reasoner,
code_facts,
unavailable,
validated_assessments,
)
if not dify_config.DIFY_BUILDER_CONFIRMED_RUN_ENABLED or self._restricted_admissible(app_id, actor):
return {}
def current_facts():
with _session_factory()() as session:
app = load_app(session, app_id, actor)
owner = session.get(DifyBuilderSession, session_id)
if owner is None or (owner.app_id, owner.tenant_id, owner.owner_account_id) != (
app_id,
actor.tenant_id,
actor.account_id,
):
raise BuilderExecutionPolicyError("execution_owner_mismatch")
draft = _load_draft_workflow_or_raise(app, session=session)
return code_facts(
dict(draft.graph_dict),
inputs=inputs,
goal_text=context.goal_text,
model_config=context.model_config,
scope_binding=_code_scope_binding(draft),
reply_language=context.reply_language,
)
before = current_facts()
prior = validated_assessments(context.code_risk_assessments)
results = {}
for node_id, facts in before.items():
saved = prior.get(node_id)
if saved is not None and saved["facts_digest"] == facts.digest:
results[node_id] = saved
elif not context.goal_text or not context.model_config:
results[node_id] = unavailable(facts).model_dump(mode="json")
else:
results[node_id] = assess_code(
facts,
reason=builder_code_reasoner(
tenant_id=actor.tenant_id,
model_config=context.model_config,
),
).model_dump(mode="json")
after = current_facts()
# A concurrent edit cannot inherit reasoning about the previous draft.
# Do not retry the model automatically; show unavailable/Mock instead.
return {
node_id: results[node_id]
if node_id in before and before[node_id].digest == facts.digest
else unavailable(facts).model_dump(mode="json")
for node_id, facts in after.items()
}
def disclose_effects(
self,
app_id: str,
actor: Actor,
*,
inputs: Mapping[str, Any] | None = None,
code_risk_context: DifyBuilderContext | None = None,
) -> EffectDisclosure:
"""What a Test Run of the current draft would touch, with each Tool's credential name.
Empty unless the run takes the confirmed full run: the restricted
runtime keeps its own fixture and consent rules.
"""
if not dify_config.DIFY_BUILDER_CONFIRMED_RUN_ENABLED or self._restricted_admissible(app_id, actor):
return EffectDisclosure()
with _session_factory()() as session:
app = load_app(session, app_id, actor)
workflow = _load_draft_workflow_or_raise(app, session=session)
graph = dict(workflow.graph_dict)
names, bindings = _tool_account_metadata(session, app.tenant_id, graph)
from services.dify_builder.code_risk_assessment import code_facts
context = code_risk_context or DifyBuilderContext()
facts = code_facts(
graph,
inputs=inputs or {},
goal_text=context.goal_text,
model_config=context.model_config,
scope_binding=_code_scope_binding(workflow),
reply_language=context.reply_language,
)
return disclose(
graph,
names,
account_bindings=bindings,
inputs=inputs,
scope_binding=_effect_scope_binding(workflow),
code_risk_assessments=context.code_risk_assessments,
code_facts_digests={node_id: fact.digest for node_id, fact in facts.items()},
)
def node_outputs(self, app_id: str, actor: Actor, run_id: str) -> list[NodeOutput]:
with _session_factory()() as session:
app = load_app(session, app_id, actor)
tenant_id = app.tenant_id
node_execs = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
sessionmaker(bind=db.engine)
).get_executions_by_workflow_run(tenant_id, app_id, run_id)
return [
NodeOutput(
node_id=node_exec.node_id,
title=node_exec.title,
status=_unwrap_status(node_exec.status),
error=node_exec.error or "",
inputs=node_exec.inputs_dict or {},
outputs=node_exec.outputs_dict or {},
outputs_available=isinstance(node_exec.outputs_dict, Mapping) and not node_exec.outputs_truncated,
)
for node_exec in node_execs
]
def failed_run_context(self, app_id: str, actor: Actor, run_id: str) -> tuple[Graph | None, list[NodeOutput], str]:
"""Read native graph and rows only after complete owner/Run validation.
Missing graph/Run is unavailable. A mismatched owner or execution is a
denial, never an unknown that permits handling another tenant's data.
Offloaded content is not fetched and iteration/retry evidence remains
unknown even if only one successful row happens to be present.
"""
from core.dify_builder.errors import NotFoundError
with _session_factory()() as session:
app = load_app(session, app_id, actor)
tenant_id = app.tenant_id
if not run_id:
return None, [], ""
run_row = DifyAPIRepositoryFactory.create_api_workflow_run_repository(
sessionmaker(bind=db.engine)
).get_workflow_run_by_id(tenant_id=tenant_id, app_id=app_id, run_id=run_id)
if run_row is None:
return None, [], ""
if (run_row.tenant_id, run_row.app_id, run_row.id) != (tenant_id, app_id, run_id):
raise NotFoundError("failed Run not found")
if _unwrap_status(run_row.status) != "failed":
return None, [], run_row.error or ""
rows = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
sessionmaker(bind=db.engine)
).get_executions_by_workflow_run(tenant_id, app_id, run_id)
# Validate every row before reading even one inputs/outputs property.
for row in rows:
if (row.tenant_id, row.app_id, row.workflow_run_id, row.workflow_id) != (
tenant_id,
app_id,
run_id,
run_row.workflow_id,
):
raise NotFoundError("failed Run execution not found")
try:
graph = run_row.graph_dict
except (TypeError, ValueError):
graph = None
historical = dict(graph) if isinstance(graph, Mapping) and isinstance(graph.get("nodes"), list) else None
metadata_ambiguous = False
for row in rows:
try:
metadata = row.execution_metadata_dict
except (TypeError, ValueError):
metadata = None
if not isinstance(metadata, Mapping) or any(
value is not None
for key, value in metadata.items()
if any(token in str(key).lower() for token in ("iteration", "loop", "retry"))
):
metadata_ambiguous = True
outputs = []
for row in rows:
raw_outputs = row.outputs_dict
outputs.append(
NodeOutput(
node_id=row.node_id,
title=row.title,
status=_unwrap_status(row.status),
error=row.error or "",
inputs=row.inputs_dict or {},
outputs=raw_outputs or {},
outputs_available=isinstance(raw_outputs, Mapping)
and row.outputs_truncated is False
and not metadata_ambiguous
and row.finished_at is not None
and bool(row.node_execution_id)
and _unwrap_status(run_row.status) == "failed",
)
)
return historical, outputs, run_row.error or ""
def _business_repair_evidence(
self,
session: Session,
*,
app_id: str,
actor: Actor,
builder_run_id: str,
workflow: Workflow,
graph: Graph,
revision: str,
lock: bool = False,
) -> dict[str, JsonValue]:
"""Reconstruct business authority from persisted, fully owned Run facts.
The final write holds the workflow and Builder session locks, so a
changed task, confirmed case or native provenance cannot authorize an
already prepared candidate. No execution or effect recovery occurs.
"""
from services.dify_builder.business_acceptance import business_failure_context
from services.dify_builder.revision import executable_graph_revision
owner_query = (
select(DifyBuilderSession)
.join(DifyBuilderRun, DifyBuilderRun.session_id == DifyBuilderSession.id)
.where(
DifyBuilderRun.id == builder_run_id,
DifyBuilderSession.app_id == app_id,
DifyBuilderSession.tenant_id == actor.tenant_id,
DifyBuilderSession.owner_account_id == actor.account_id,
)
)
if lock:
owner_query = owner_query.with_for_update(of=DifyBuilderSession)
owner = session.execute(owner_query).scalar_one_or_none()
if owner is None:
raise PreflightError("Business repair Run is not owned by this Builder session")
repository = SqlDifyBuilderRepository(_session_factory())
run = repository.get_run(builder_run_id)
builder_session, context = repository.get_session(owner.id)
native = session.execute(
select(WorkflowRun).where(
WorkflowRun.id == run.dify_run_id,
WorkflowRun.tenant_id == actor.tenant_id,
WorkflowRun.app_id == app_id,
WorkflowRun.workflow_id == workflow.id,
WorkflowRun.created_by == actor.account_id,
WorkflowRun.created_by_role == CreatorUserRole.ACCOUNT,
WorkflowRun.status == "succeeded",
)
).scalar_one_or_none()
graph_revision = executable_graph_revision(graph)
if (
native is None
or run.session_id != owner.id
or builder_session.app_id != app_id
or builder_session.tenant_id != actor.tenant_id
or builder_session.owner_account_id != actor.account_id
or executable_graph_revision(dict(native.graph_dict)) != graph_revision
):
raise PreflightError("Business repair native Run provenance is unavailable or stale")
projection = business_failure_context(
run,
builder_session,
context,
current_revision=revision,
current_graph_revision=graph_revision,
)
if projection is None:
raise PreflightError("Business repair requires a current confirmed failed business case")
return {"business_failure": projection.model_dump(mode="json")}
def apply_repair(
self,
app_id: str,
actor: Actor,
intents: list[MutationIntent],
on_canvas: Callable[[dict], None] | None = None,
*,
expected_revision: str,
failed_run_id: str | None = None,
business_failure_run_id: str | None = None,
confirmed_resource_ids: Sequence[str] | None = None,
expected_resource_fingerprints: dict[str, str] | None = None,
expected_node_resources: dict[str, str] | None = None,
) -> ApplyResult:
from services.dify_builder.task_controls import check_current, lock_execution
check_current()
# Build and validate against a snapshot outside the write transaction.
# The final row lock rechecks this revision before replacing the graph.
before_graph, revision = self.read_graph(app_id, actor)
if revision != expected_revision:
raise HashMismatchError(f"workflow execution configuration changed: {app_id}")
# A direct write must enforce the same boundary as candidate preflight.
# Check after the owner/revision read and before any mutation or events.
policy_rejections = proposal_policy_rejections(before_graph, intents)
if policy_rejections:
raise PreflightError("Builder node policy: " + "; ".join(policy_rejections))
if failed_run_id is not None and business_failure_run_id is not None:
raise PreflightError("Choose one persisted repair evidence source")
graph: Graph = before_graph
# Idempotent re-entry (interrupted-step Retry): a re-applied fix.apply
# re-sends the same intents against an already-mutated draft. Drop
# create_node for an id already present and connect for an edge already
# present so the re-apply is a clean no-op. Uses the ORIGINAL draft graph
# (before_graph) as the reference, so an in-batch duplicate of a NEW id
# still reaches graph_ops and raises (validation preserved).
#
# The rule itself lives in graph_ops (including handlers_build.py's M2
# guard for a delete_node + create_node of the same id in one batch),
# because graph_ops.filter_applicable's dry run has to skip exactly what
# this drops: otherwise the dry run rejects a duplicate create_node the
# write would have quietly no-op'd, and burns the corrective re-prompt.
_already_present = graph_ops.already_present_predicate(before_graph, intents)
intents = [intent for intent in intents if not _already_present(intent)]
changed_nodes: list[str] = []
canvas_events: list[dict[str, Any]] = []
# The nodes whose DATA this batch wrote, which is a narrower set
# than the change set and is kept separately for that reason: the
# preflight below gives a node in it no exemption at all, so an id
# that lands here wrongly turns someone else's pre-existing defect
# into a veto. ``connect`` writes an EDGE -- ``apply_connect``
# reports both endpoints, correctly for a diff, but adding an edge
# cannot change either endpoint's ``validate_node_config`` verdict.
# Mirrors ``graph_ops.filter_applicable``, which must produce the
# same set or the dry run would be predicting a different write.
written_nodes: list[str] = []
for intent in intents:
apply_fn = graph_ops.APPLY_FNS.get(intent.op)
if apply_fn is None:
continue
graph_ops.validate_intent_args(intent)
graph, changed = apply_fn(graph, **intent.args)
changed_nodes.extend(changed)
if intent.op != "connect" and not (
intent.op == "insert_between" and intent.args.get("reuse_existing") is True
):
written_nodes.extend(changed)
if on_canvas is not None:
canvas_events.append(_canvas_payload(intent, changed))
from services.dify_builder.resource_grounding import validate_workspace_resources
if business_failure_run_id is not None:
from services.dify_builder.condition_evidence import repair_evidence_rejections
with _session_factory()() as session:
app = load_app(session, app_id, actor)
workflow = _load_draft_workflow_or_raise(app, session=session)
evidence = self._business_repair_evidence(
session,
app_id=app_id,
actor=actor,
builder_run_id=business_failure_run_id,
workflow=workflow,
graph=before_graph,
revision=revision,
)
evidence_problems = repair_evidence_rejections(before_graph, graph, written_nodes, evidence)
if evidence_problems:
raise PreflightError("; ".join(evidence_problems))
if not changed_nodes:
validate_workspace_resources(
before_graph,
graph,
tenant_id=actor.tenant_id,
account_id=actor.account_id,
confirmed_ids=confirmed_resource_ids,
expected_fingerprints=expected_resource_fingerprints,
expected_node_resources=expected_node_resources,
)
with _session_factory()() as session:
app = load_app(session, app_id, actor)
workflow = _load_draft_workflow_or_raise(app, session=session)
from services.dify_builder.task_controls import lock_execution
lock_execution(session, app_id=app_id)
session.refresh(workflow, with_for_update=True)
if execution_revision(workflow) != revision:
raise HashMismatchError(f"workflow execution configuration changed: {app_id}")
return ApplyResult(
changed_nodes=[],
new_hash=revision,
changes=[],
scope="",
structure_fingerprint=graph_ops.structural_fingerprint(before_graph),
)
# ``written_nodes`` is complete at this point and is deliberately
# NOT extended by the heal below: the healer scans the whole graph,
# so a node it merely normalized is not one this batch is
# answerable for.
#
# The deterministic heal set every pre-preflight caller shares
# (services.workflow_generation.graph_normalizers.heal_nodes_for_preflight), for
# the intents the generator never saw: a Fix/Edit repair that
# writes an ASCII comparison operator (F4's ``>=``) or a condition
# value as a JSON number (ESQ1-285's flip-flop), an http body item
# without ``type``, or a parameter-extractor ``query`` written as
# an array of selector arrays (Blocker A). Must run BEFORE the
# preflight, which would reject them. It scans every node in
# ``graph``, not just the ones the intents named, so a node it
# heals may not be in ``changed_nodes`` yet -- fold its returned
# ids in (order-preserving, deduped) so it agrees with
# ``diff_graphs`` below, which sees the healed node too.
healed_ids = graph_normalizers.heal_nodes_for_preflight(graph.get("nodes", []))
for node_id in healed_ids:
if node_id not in changed_nodes:
changed_nodes.append(node_id)
# Read-only workspace metadata is resolved before the write transaction.
# Unchanged historical resource identities do not veto unrelated edits.
validate_workspace_resources(
before_graph,
graph,
tenant_id=actor.tenant_id,
account_id=actor.account_id,
confirmed_ids=confirmed_resource_ids,
expected_fingerprints=expected_resource_fingerprints,
expected_node_resources=expected_node_resources,
)
# Dry-validate what the draft would become. Whatever raises here
# would raise at Graph.init and kill the very first test run
# (ESQ1-302 and ESQ1-303 both died there). A node this batch WROTE
# must be startable -- a repair that leaves its own culprit refused
# is not a repair, however little it changed about why. Every other
# node keeps the new-problems-only exemption: one the repair did not
# touch must not veto an unrelated fix, and a repair that heals it
# passes. The same function the Edit/Fix dry run predicts this
# refusal with (``preflight.vet_intents``), on the same ``touched``
# set, so the two cannot answer differently.
if failed_run_id is not None:
from services.dify_builder.condition_evidence import build_evidence, repair_evidence_rejections
historical, outputs, native_error = self.failed_run_context(app_id, actor, failed_run_id)
_, _, _, evidence = build_evidence(
Run(dify_run_id=failed_run_id, error=native_error), before_graph, historical, outputs
)
evidence_problems = repair_evidence_rejections(before_graph, graph, written_nodes, evidence)
if evidence_problems:
raise PreflightError("; ".join(evidence_problems))
new_problems = new_preflight_problems(before_graph, graph, written_nodes)
if new_problems:
raise PreflightError("the draft would not start: " + "; ".join(new_problems))
changes, scope = graph_ops.diff_graphs(before_graph, graph)
nodes = describe_changed_nodes(changed_nodes, before_graph, graph)
fingerprint = graph_ops.structural_fingerprint(graph)
from services.dify_builder.native_testing.agent_binding import AgentBindingService
retained, retained_stamps = {}, {}
if any(node.get("data", {}).get("type") == "agent" for node in graph.get("nodes", [])):
with _session_factory()() as session:
app = load_app(session, app_id, actor)
workflow = _load_draft_workflow_or_raise(app, session=session)
if execution_revision(workflow) != revision:
raise HashMismatchError(f"workflow execution configuration changed: {app_id}")
retained, retained_stamps = _retained_native_agent_selections(
session=session, actor=actor, workflow=workflow, graph=graph
)
selections = _confirmed_agent_selections(graph, confirmed_resource_ids, retained=retained)
retirements: set[str] = set()
authorization = (
AgentBindingService.authorize(
actor=actor,
app_id=app_id,
agent_ids=frozenset(item.agent_id for item in selections.values()),
)
if selections
else None
)
with _session_factory()() as session:
from services.dify_builder.task_controls import lock_execution
lock_execution(session, app_id=app_id)
account = resolve_account(session, actor)
app = load_app(session, app_id, actor)
workflow = _load_draft_workflow_or_raise(app, session=session)
session.refresh(workflow, with_for_update=True)
if execution_revision(workflow) != revision:
raise HashMismatchError(f"workflow execution configuration changed: {app_id}")
if business_failure_run_id is not None:
from services.dify_builder.condition_evidence import repair_evidence_rejections
evidence = self._business_repair_evidence(
session,
app_id=app_id,
actor=actor,
builder_run_id=business_failure_run_id,
workflow=workflow,
graph=before_graph,
revision=revision,
lock=True,
)
evidence_problems = repair_evidence_rejections(before_graph, graph, written_nodes, evidence)
if evidence_problems:
raise PreflightError("; ".join(evidence_problems))
committed_previous_graph = dict(workflow.graph_dict)
graph = merge_canvas_presentation(before_graph, graph, committed_previous_graph)
graph, binding_service = _prepare_native_agent_graph(
session=session,
actor=actor,
workflow=workflow,
graph=graph,
selections=selections,
authorization=authorization,
retained_binding_stamps=retained_stamps,
)
coordination_id = str(uuid4())
updated = _sync_graph_only(
app,
graph,
workflow,
workflow.unique_hash,
account,
session,
app_id,
commit=binding_service is None,
coordination_id=coordination_id,
)
if binding_service is not None:
retirements = binding_service.sync_prepared_bindings(
actor=actor, workflow=updated, selections=selections
)
WorkflowDraftCoordinationService.stage(
session,
coordination_id=coordination_id,
workflow=updated,
account_id=actor.account_id,
retirement_agent_ids=retirements,
emit_draft_event=True,
)
session.commit()
new_hash = execution_revision(updated)
if binding_service is not None:
WorkflowDraftCoordinationService.kick(coordination_id)
notify_workflow_draft_changed(updated, previous_graph=committed_previous_graph)
if on_canvas is not None:
for event in canvas_events:
on_canvas(event)
return ApplyResult(
changed_nodes=changed_nodes,
nodes=nodes,
new_hash=new_hash,
changes=changes,
scope=scope,
structure_fingerprint=fingerprint,
)
def structural_fingerprint(self, graph: Graph) -> str:
return graph_ops.structural_fingerprint(graph)
def graph_node_ids(self, graph: Graph) -> list[str]:
return graph_ops.node_ids(graph)
def restore_graph(self, app_id: str, actor: Actor, graph: Graph, *, expected_revision: str) -> str:
"""Restore the draft to a prior snapshot graph (Slice 4 revert): write
the whole graph back via the same graph-only sync apply_repair uses.
Returns the new execution revision. Raises HashMismatchError if the
execution configuration changed since the caller read it. Native Agent
graph/binding state requires an admitted restore owner and is refused."""
with _session_factory()() as session:
from services.dify_builder.task_controls import lock_execution
lock_execution(session, app_id=app_id)
account = resolve_account(session, actor)
app = load_app(session, app_id, actor)
workflow = _load_draft_workflow_or_raise(app, session=session)
session.refresh(workflow, with_for_update=True)
if execution_revision(workflow) != expected_revision:
raise HashMismatchError(f"workflow execution configuration changed: {app_id}")
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from models.agent import WorkflowAgentNodeBinding
# Legacy Undo only restores a graph. It cannot restore or authorize
# Agent binding state; reject before either side can diverge.
if (
any(WorkflowAgentNodeValidator.iter_agent_v2_nodes(workflow.graph_dict))
or any(WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph))
or any(
session.scalars(
select(WorkflowAgentNodeBinding.id)
.where(
WorkflowAgentNodeBinding.tenant_id == actor.tenant_id,
WorkflowAgentNodeBinding.app_id == app_id,
WorkflowAgentNodeBinding.workflow_id == workflow.id,
WorkflowAgentNodeBinding.workflow_version == workflow.version,
)
.limit(1)
)
)
):
raise BuilderExecutionPolicyError("native_restore_unavailable")
updated = _sync_graph_only(app, graph, workflow, workflow.unique_hash, account, session, app_id)
notify_workflow_draft_changed(updated)
return execution_revision(updated)
def run_draft(
self,
app_id: str,
actor: Actor,
inputs: Inputs,
on_event: Callable[[NodeEvent], None],
*,
session_id: str,
test_input_id: str,
on_workflow_event: Callable[[Mapping[str, object]], None] | None = None,
confirmed_consent_id: str | None = None,
command_id: str | None = None,
effect_confirmation: Mapping[str, str] | None = None,
) -> Run:
from services.dify_builder.task_controls import check_current
check_current()
if confirmed_consent_id is not None or command_id is not None:
if not confirmed_consent_id or not command_id:
return _unsupported_run(BuilderExecutionPolicyError("confirmed_launch_incomplete"))
return self._run_confirmed_draft(
app_id,
actor,
on_event,
session_id=session_id,
test_input_id=test_input_id,
on_workflow_event=on_workflow_event,
consent_id=confirmed_consent_id,
command_id=command_id,
)
if self._has_full_run_reservation(session_id, test_input_id) or (
dify_config.DIFY_BUILDER_CONFIRMED_RUN_ENABLED and not self._restricted_admissible(app_id, actor)
):
return self._run_confirmed_full_draft(
app_id,
actor,
inputs,
on_event,
session_id=session_id,
test_input_id=test_input_id,
on_workflow_event=on_workflow_event,
effect_confirmation=effect_confirmation or {},
)
launch: _BuilderExecutionLaunch | None = None
try:
with _session_factory()() as session:
from services.dify_builder.task_controls import lock_execution
lock_execution(session, app_id=app_id)
account = resolve_account(session, actor)
app = load_app(session, app_id, actor)
tenant_id = app.tenant_id
draft = _load_draft_workflow_or_raise(app, session=session)
snapshot = restricted_admission_snapshot(draft, app)
if _is_json_parse_candidate(snapshot):
admit_json_workflow(snapshot)
else:
admitted_node_bindings(snapshot)
scalar_inputs_digest(inputs)
try:
effective_inputs = dict(
BaseAppGenerator()._prepare_user_inputs(
user_inputs=inputs,
variables=WorkflowVariablesConfigManager.convert(draft),
tenant_id=tenant_id,
)
)
except ValueError:
raise BuilderExecutionPolicyError("unsupported_input_value") from None
policy = BuilderExecutionPolicyService(_session_factory())
context = policy.prepare(
session_id=session_id,
test_input_id=test_input_id,
app_id=app_id,
actor=actor,
workflow=draft,
submitted_inputs=inputs,
effective_inputs=effective_inputs,
)
launch = _BuilderExecutionLaunch(policy, context)
set_login_user(account)
# The caller's read session has closed before qualified daemon metadata.
with _session_factory()() as session:
response = AppGenerateService.generate(
app_model=app,
user=account,
args={"inputs": effective_inputs},
invoke_from=InvokeFrom.DEBUGGER,
session=session,
streaming=True,
workflow_execution_mode="in_process",
builder_execution=context,
builder_execution_admit=launch,
)
except BuilderExecutionPolicyError as error:
if launch is None:
return _unsupported_run(error)
launch.on_refusal(error)
return self._finalize_launch(launch, actor, response_completed=False, setup_refusal=error)
except BaseException:
if launch is not None:
self._close_failed_launch(launch)
raise
completed, candidate_ids = self._consume_launch(launch, response, on_event, on_workflow_event, sanitize=False)
return self._finalize_launch(launch, actor, response_completed=completed, candidate_ids=candidate_ids)
def _restricted_admissible(self, app_id: str, actor: Actor) -> bool:
"""Whether the audited restricted (or JSON) runtime can run the whole draft."""
with _session_factory()() as session:
app = load_app(session, app_id, actor)
draft = _load_draft_workflow_or_raise(app, session=session)
try:
snapshot = restricted_admission_snapshot(draft, app)
if _is_json_parse_candidate(snapshot):
admit_json_workflow(snapshot)
else:
admitted_node_bindings(snapshot)
except BuilderExecutionPolicyError:
return False
return True
@staticmethod
def _has_full_run_reservation(session_id: str, test_input_id: str) -> bool:
# Runtime/configuration drift cannot reroute an interrupted full run
# into a fresh restricted launch with the same saved attempt identity.
reservation_id = str(uuid5(NAMESPACE_URL, f"dify-builder-full-run:{session_id}:{test_input_id}"))
with _session_factory()() as session:
row = session.get(DifyBuilderRun, reservation_id)
return isinstance(row, DifyBuilderRun) and row.kind == "native_test_launch"
def _reserve_full_run(
self,
session_id: str,
test_input_id: str,
before_revision: str,
before_graph_revision: str,
mocked: frozenset[str],
) -> tuple[Run, bool]:
"""Reserve one saved-input attempt using the existing Run primary key.
A new explicit test choice gets a fresh TestInput. Re-entering a working
handler keeps that input, so even a crash before native acknowledgement
cannot launch it twice. The durable reservation intentionally survives
an ambiguous launch; this is not provider-side exactly-once execution.
"""
reservation_id = str(uuid5(NAMESPACE_URL, f"dify-builder-full-run:{session_id}:{test_input_id}"))
verification = RunVerification(
execution_revision=before_revision,
executed_graph_revision=before_graph_revision,
terminal_outputs=None,
output_findings=[],
executed_node_ids=[],
no_output_dead_branch=False,
execution_evidence=ExecutionEvidenceSummary(
request_id=reservation_id,
policy_version="builder-confirmed-run-v1",
mode="confirmed_run",
sealed=False,
safety_outcome="execution_evidence_unknown",
simulated_node_ids=tuple(sorted(mocked)),
sandbox_profile="native",
),
)
assert verification.execution_evidence is not None
row = DifyBuilderRun(
id=reservation_id,
session_id=session_id,
kind="native_test_launch",
inputs_ref=test_input_id,
status="running",
immutable=False,
per_node=[],
verification={
**asdict(verification),
"execution_evidence": verification.execution_evidence.model_dump(mode="json"),
},
)
# The unique primary key, rather than a read-before-write or dispatch
# lease, elects the only invocation allowed to call the native generator.
try:
with _session_factory()() as session, session.begin():
from services.dify_builder.task_controls import lock_execution
lock_execution(session)
session.add(row)
session.flush()
return SqlDifyBuilderRepository._to_domain_run(row), True
except IntegrityError:
with _session_factory()() as session:
existing = session.get(DifyBuilderRun, reservation_id)
if existing is None or (existing.session_id, existing.inputs_ref, existing.kind) != (
session_id,
test_input_id,
"native_test_launch",
):
raise
return SqlDifyBuilderRepository._to_domain_run(existing), False
@staticmethod
def _bind_full_native_id(reservation_id: str, native_run_id: str) -> None:
"""Keep the first native identity durable before exposing any event."""
if not native_run_id:
return
with _session_factory()() as session, session.begin():
row = session.get(DifyBuilderRun, reservation_id, with_for_update=True)
if row is None or row.dify_run_id not in (None, "", native_run_id):
raise BuilderExecutionPolicyError("execution_launch_mismatch")
row.dify_run_id = native_run_id
@staticmethod
def _save_full_run_result(reservation_id: str, run: Run) -> None:
with _session_factory()() as session, session.begin():
row = session.get(DifyBuilderRun, reservation_id, with_for_update=True)
assert row is not None
if row.immutable:
return
row.dify_run_id = run.dify_run_id or row.dify_run_id
row.status = run.status
row.error = run.error
row.per_node = [asdict(node) for node in run.per_node]
row.verification = (
{
**asdict(run.verification),
"execution_evidence": run.verification.execution_evidence.model_dump(mode="json")
if run.verification.execution_evidence
else None,
}
if run.verification is not None
else None
)
row.tokens = run.tokens
row.elapsed_ms = run.elapsed_ms
preparation_failed = (
run.verification is not None
and run.verification.execution_evidence is not None
and run.verification.execution_evidence.sealed
and run.verification.execution_evidence.safety_outcome == "preparation_failed"
)
row.immutable = run.status in {"succeeded", "failed"} and (bool(run.dify_run_id) or preparation_failed)
@staticmethod
def _prepare_confirmed_agent_execution(*, actor, session_id, test_input_id, expected_version, reservation_id):
from runtime_contracts.builder.native.agent_context import BuilderAgentExecutionContext
from services.dify_builder.native_testing.agent_operations import admit_confirmed_agent_execution
native_run_id = str(uuid4())
context = admit_confirmed_agent_execution(
actor=actor,
session_id=session_id,
test_input_id=test_input_id,
expected_version=expected_version,
native_run_id=native_run_id,
idempotency_key=reservation_id,
limits={"calls": 32, "dispatches": 32, "cost_units": 32},
)
return BuilderAgentExecutionContext(ref=context.ref, native_run_id=native_run_id)
@staticmethod
def _recover_confirmed_agent_parent_id(*, actor, app_id, session_id, test_input_id, graph):
"""A planned ID is a locator, never proof that a Run was launched."""
if not any(
isinstance(node, dict) and node.get("data", {}).get("type") == "agent" for node in graph.get("nodes", [])
):
return None
from models.dify_builder import DifyBuilderExecutionRequest
with _session_factory()() as session:
requests = session.scalars(
select(DifyBuilderExecutionRequest).where(
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
DifyBuilderExecutionRequest.actor_id == actor.account_id,
DifyBuilderExecutionRequest.app_id == app_id,
DifyBuilderExecutionRequest.session_id == session_id,
DifyBuilderExecutionRequest.test_input_id == test_input_id,
)
).all()
actual = []
for request in requests:
if request.context.get("profile", {}).get("profile_id") != "builder-agent-confirmed-v1":
continue
if canonical_digest(request.context) != request.context_digest:
raise BuilderExecutionPolicyError("native_context_mismatch")
row = session.scalar(
select(WorkflowRun).where(
WorkflowRun.id == request.context.get("native_run_id"),
WorkflowRun.tenant_id == actor.tenant_id,
WorkflowRun.created_by == actor.account_id,
WorkflowRun.created_by_role == CreatorUserRole.ACCOUNT,
WorkflowRun.app_id == app_id,
WorkflowRun.workflow_id == request.workflow_id,
)
)
if row is not None:
actual.append(row.id)
if len(actual) > 1:
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
return actual[0] if actual else None
@staticmethod
def _settle_confirmed_agent_parent(*, actor, app_id, session_id, test_input_id, native_run_id, paused):
from models.dify_builder import DifyBuilderExecutionRequest
from runtime_contracts.builder.native.contracts import NativeTestContextRef
from services.dify_builder.wiring import build_confirmed_agent_command_service
with _session_factory()() as session:
rows = session.scalars(
select(DifyBuilderExecutionRequest).where(
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
DifyBuilderExecutionRequest.actor_id == actor.account_id,
DifyBuilderExecutionRequest.app_id == app_id,
DifyBuilderExecutionRequest.session_id == session_id,
DifyBuilderExecutionRequest.test_input_id == test_input_id,
)
).all()
selected = [
row
for row in rows
if row.context.get("profile", {}).get("profile_id") == "builder-agent-confirmed-v1"
and row.context.get("native_run_id") == native_run_id
]
if not selected:
return None
if len(selected) != 1:
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
row = selected[0]
ref = NativeTestContextRef(
request_id=row.id, invocation_id=row.context["invocation_id"], context_digest=row.context_digest
)
commands = build_confirmed_agent_command_service(actor=actor)
return commands.persist_parent_wait(ref=ref) if paused else commands.settle_parent(ref=ref)
@staticmethod
def _saved_code_risks(session, *, owner, graph, inputs, draft):
"""Reload server-only assessment at worker boundaries; never call reasoning."""
from services.dify_builder.code_risk_assessment import code_facts, validated_assessments
from services.dify_builder.serde import context_from_dict
if not any(node.get("data", {}).get("type") == "code" for node in graph.get("nodes", [])):
return {}, {}
commit = session.scalar(
select(DifyBuilderSessionCommit)
.where(
DifyBuilderSessionCommit.session_id == owner.id,
DifyBuilderSessionCommit.version == owner.version,
)
.order_by(DifyBuilderSessionCommit.created_at.desc())
.limit(1)
)
if commit is None:
raise BuilderExecutionPolicyError("code_risk_assessment_stale")
context = context_from_dict(commit.context)
facts = code_facts(
graph,
inputs=inputs,
goal_text=context.goal_text,
model_config=context.model_config,
scope_binding=_code_scope_binding(draft),
reply_language=context.reply_language,
)
risks = validated_assessments(context.code_risk_assessments)
if any(
node_id not in risks or risks[node_id]["facts_digest"] != fact.digest for node_id, fact in facts.items()
):
raise BuilderExecutionPolicyError("code_risk_assessment_stale")
return risks, {node_id: fact.digest for node_id, fact in facts.items()}
def _run_confirmed_full_draft(
self,
app_id: str,
actor: Actor,
inputs: Inputs,
on_event: Callable[[NodeEvent], None],
*,
session_id: str,
test_input_id: str,
on_workflow_event: Callable[[Mapping[str, object]], None] | None,
effect_confirmation: Mapping[str, str],
) -> Run:
"""Run one saved-input attempt through the ordinary native debugger.
Reserve before generation and record its native ID before callbacks.
Recovery reads that same Run, never dispatches a second native attempt.
A missing acknowledgement remains unknown and needs an explicit new
test choice; a previous confirmation cannot authorize that new attempt.
"""
with _session_factory()() as session:
from services.dify_builder.task_controls import lock_execution
lock_execution(session, app_id=app_id)
account = resolve_account(session, actor)
app = load_app(session, app_id, actor)
tenant_id = app.tenant_id
owner = session.get(DifyBuilderSession, session_id)
if owner is None or (owner.app_id, owner.tenant_id, owner.owner_account_id) != (
app_id,
actor.tenant_id,
actor.account_id,
):
return _unsupported_run(BuilderExecutionPolicyError("execution_owner_mismatch"))
saved = session.get(DifyBuilderTestInput, test_input_id) if test_input_id else None
if saved is None or saved.session_id != session_id or saved.inputs != dict(inputs):
return _unsupported_run(BuilderExecutionPolicyError("test_input_binding_mismatch"))
draft = _load_draft_workflow_or_raise(app, session=session)
graph = dict(draft.graph_dict)
if any(node.get("data", {}).get("type") == "code" for node in graph.get("nodes", [])) and (
canonical_digest(saved.inputs) != canonical_digest(dict(inputs))
):
return _unsupported_run(BuilderExecutionPolicyError("test_input_binding_mismatch"))
owner_version = owner.version
before_revision = execution_revision(draft)
before_graph_revision = executable_graph_revision(graph)
names, bindings = _tool_account_metadata(session, tenant_id, graph)
reservation_id = str(uuid5(NAMESPACE_URL, f"dify-builder-full-run:{session_id}:{test_input_id}"))
existing = session.get(DifyBuilderRun, reservation_id)
# Recovery observes its old mock set/Run even if a later edit makes
# the preparation stale. It must never reassess or redispatch.
if existing is None:
try:
risks, digests = self._saved_code_risks(
session, owner=owner, graph=graph, inputs=inputs, draft=draft
)
except BuilderExecutionPolicyError as error:
return _unsupported_run(error)
mocked = disclose(
graph,
names,
account_bindings=bindings,
inputs=inputs,
scope_binding=_effect_scope_binding(draft),
code_risk_assessments=risks,
code_facts_digests=digests,
).mocked_node_ids(effect_confirmation)
else:
mocked = frozenset() # _reserve_full_run returns the immutable old reservation below.
# Only New Agent owns the nested-call confirmation transport. A
# legacy strategy Agent must not fall through to its ordinary tools.
if existing is None and any(
isinstance(node, dict)
and isinstance(node.get("data"), dict)
and node["data"].get("type") == "agent"
and node.get("id") not in mocked
and (node["data"].get("version"), node["data"].get("agent_node_kind")) != ("2", "dify_agent")
for node in graph.get("nodes", [])
):
return _unsupported_run(BuilderExecutionPolicyError("unsupported_node_implementation"))
args: dict[str, Any] = {"inputs": dict(inputs)}
if app.mode == "advanced-chat":
validate_query(inputs)
args = {
"query": inputs[SYSTEM_QUERY],
"inputs": {key: value for key, value in inputs.items() if key != SYSTEM_QUERY},
}
reservation, launch = self._reserve_full_run(
session_id, test_input_id, before_revision, before_graph_revision, mocked
)
if not launch:
if (
reservation.verification is not None
and reservation.verification.execution_evidence is not None
and reservation.verification.execution_evidence.safety_outcome == "preparation_failed"
):
return reservation
if not reservation.dify_run_id:
recovered = self._recover_confirmed_agent_parent_id(
actor=actor,
app_id=app_id,
session_id=session_id,
test_input_id=test_input_id,
graph=graph,
)
if recovered:
self._bind_full_native_id(reservation.id, recovered)
reservation.dify_run_id = recovered
if reservation.immutable or not reservation.dify_run_id:
if not reservation.dify_run_id and reservation.verification is not None:
evidence = reservation.verification.execution_evidence
if evidence is not None:
# The reservation retains mock candidates for recovery;
# no acknowledgement means none are observed steps yet.
reservation.verification.execution_evidence = evidence.model_copy(
update={"simulated_node_ids": ()}
)
return reservation
assert reservation.verification is not None
before_revision = reservation.verification.execution_revision
before_graph_revision = reservation.verification.executed_graph_revision
evidence = reservation.verification.execution_evidence
mocked = frozenset(evidence.simulated_node_ids if evidence is not None else ())
final: dict[str, Any] = {}
stream_run_id = reservation.dify_run_id
error_frame: dict[str, str] | None = None
pending_pause_event = None
preparation_failure = None
failed_resource = None
from services.dify_builder.native_launch_observation import NativeLaunchObservation, resource_node_ids
if launch:
observation = NativeLaunchObservation()
response = None
try:
real_agent = any(
isinstance(node, dict)
and isinstance(node.get("data"), dict)
and node["data"].get("type") == "agent"
and node.get("id") not in mocked
for node in graph.get("nodes", [])
)
agent_context = (
self._prepare_confirmed_agent_execution(
actor=actor,
session_id=session_id,
test_input_id=test_input_id,
expected_version=owner_version,
reservation_id=reservation.id,
)
if real_agent
else None
)
set_login_user(account)
# Only native generation owns this session; Builder reads and
# the durable reservation committed before any provider I/O.
with _session_factory()() as session:
if any(node.get("data", {}).get("type") == "code" for node in graph.get("nodes", [])):
current_owner = session.get(DifyBuilderSession, session_id)
current_input = session.get(DifyBuilderTestInput, test_input_id)
current_draft = _load_draft_workflow_or_raise(app, session=session)
if (
current_owner is None
or current_owner.version != owner_version
or (current_owner.app_id, current_owner.tenant_id, current_owner.owner_account_id)
!= (app_id, actor.tenant_id, actor.account_id)
or current_input is None
or current_input.session_id != session_id
or canonical_digest(current_input.inputs) != canonical_digest(dict(inputs))
or execution_revision(current_draft) != before_revision
):
return _unsupported_run(BuilderExecutionPolicyError("code_risk_assessment_stale"))
try:
self._saved_code_risks(
session,
owner=current_owner,
graph=dict(current_draft.graph_dict),
inputs=inputs,
draft=current_draft,
)
except BuilderExecutionPolicyError as error:
return _unsupported_run(error)
with observation.track():
response = AppGenerateService.generate(
app_model=app,
user=account,
args=args,
invoke_from=InvokeFrom.DEBUGGER,
session=session,
streaming=True,
workflow_execution_mode="in_process",
builder_mocked_node_ids=mocked,
builder_expected_revision=before_revision,
builder_tool_credential_bindings={
node_id: binding for node_id, binding in bindings.items() if node_id not in mocked
},
builder_agent_execution=agent_context,
)
if isinstance(response, Mapping):
final = dict(response.get("data") or {})
if not final.get("id"):
final["id"] = str(response.get("workflow_run_id") or "")
stream_run_id = str(final.get("id") or "")
self._bind_full_native_id(reservation.id, stream_run_id)
else:
for chunk in response:
payload = stream_chunk_as_mapping(chunk)
if payload is None:
continue
native_id = run_id_from_stream_chunk(payload)
if native_id:
self._bind_full_native_id(reservation.id, native_id)
stream_run_id = native_id
if on_workflow_event is not None:
if agent_context is not None and payload.get("event") == "workflow_paused":
pending_pause_event = payload
else:
on_workflow_event(payload)
node_event = node_event_from_stream_chunk(payload)
if node_event is not None:
on_event(node_event)
continue
terminal = run_result_data_from_terminal_chunk(payload)
if terminal is not None:
final = terminal
error_frame = error_frame or error_from_stream_chunk(payload)
except Exception as exc:
from services.dify_builder import trace_diagnostics
if observation.failed_before_dispatch:
preparation_failure = trace_diagnostics.failure_reason(exc)
failed_resource = observation.resource_for(exc)
trace_diagnostics.report("native_preparation_failed", error=exc)
else:
trace_diagnostics.report("native_outcome_unknown", error=exc)
# A dispatch/stream exception cannot prove that no effect ran.
# Retain the attempt and recover only the observed native ID.
logging.getLogger(__name__).warning("Builder native test acknowledgement was interrupted")
final = {}
error_frame = None
finally:
try:
close = response.close if response is not None else None
except AttributeError:
close = None
if callable(close):
close()
if not stream_run_id and preparation_failure is None:
recovered = self._recover_confirmed_agent_parent_id(
actor=actor,
app_id=app_id,
session_id=session_id,
test_input_id=test_input_id,
graph=graph,
)
if recovered:
self._bind_full_native_id(reservation.id, recovered)
stream_run_id = recovered
# Without a native ID, no terminal claim can establish what launched.
result = (
self._finish_run(tenant_id, app_id, final, stream_run_id, error_frame=error_frame)
if stream_run_id or (launch and error_frame is not None)
else (map_unknown_run_outcome({"id": ""}, []), None)
)
run, _ = result
if preparation_failure is not None:
run.status = "failed"
run.error = "Native test preparation failed before workflow worker dispatch."
# A real Agent control wait is neither a failed workflow nor permission
# to diagnose/rebuild it. C04 exposes waiting only after the native
# WorkflowPause has actually persisted; Builder retains its same Run.
native_row = result[1]
native_graph = native_row.graph_dict if native_row is not None else {}
has_agent = (
any(
isinstance(node, dict) and isinstance(node.get("data"), dict) and node["data"].get("type") == "agent"
for node in native_graph.get("nodes", [])
)
if isinstance(native_graph, dict)
else False
)
if native_row is not None and has_agent:
try:
paused = str(native_row.status) == "paused"
receipt = self._settle_confirmed_agent_parent(
actor=actor,
app_id=app_id,
session_id=session_id,
test_input_id=test_input_id,
native_run_id=run.dify_run_id,
paused=paused,
)
if paused and receipt is not None:
run.status = "running"
run.error = "Agent test is waiting for explicit call confirmation; continue the same Run."
if pending_pause_event is not None and on_workflow_event is not None:
on_workflow_event(pending_pause_event)
elif receipt is not None and receipt.status == "unknown":
run.status = "running"
run.error = "Agent effect acknowledgement is unknown; automatic replay is disabled."
except Exception:
run.status = "running"
run.error = "Agent test recovery is pending; automatic replay is disabled."
if run.verification is None:
run.verification = RunVerification(
execution_revision="",
executed_graph_revision="",
terminal_outputs=None,
output_findings=[],
executed_node_ids=[],
no_output_dead_branch=False,
)
determinate = bool(run.dify_run_id) and run.status in {"succeeded", "failed"}
prepared_failure = preparation_failure is not None and not run.dify_run_id
from services.dify_builder.native_runtime_failure import plugin_service_failure
runtime_failure = plugin_service_failure(run, native_graph) if isinstance(native_graph, Mapping) else None
run.verification.execution_evidence = ExecutionEvidenceSummary(
request_id=reservation.id,
policy_version="builder-confirmed-run-v1",
mode="confirmed_run",
sealed=determinate or prepared_failure,
safety_outcome=(
"preparation_failed"
if prepared_failure
else "execution_evidence_unknown"
if not determinate
else "confirmed_run_completed"
if run.status == "succeeded"
else "native_failed"
),
simulated_node_ids=tuple(sorted(mocked & {node.node_id for node in run.per_node})),
sandbox_profile="native",
native_run_id=run.dify_run_id or None,
failure_resource=failed_resource if prepared_failure else runtime_failure[0] if runtime_failure else None,
failure_node_ids=(
resource_node_ids(graph, failed_resource)
if prepared_failure
else runtime_failure[1]
if runtime_failure
else ()
),
failure_phase="preparation" if prepared_failure else "execution" if runtime_failure else None,
failure_reason=preparation_failure
if prepared_failure
else "service_unavailable"
if runtime_failure
else None,
)
bound = self._bind_run(result, app_id, actor, before_revision, before_graph_revision, restricted=False)
# Pending attempts keep the original revision/mock facts for a later
# native-row recovery. A terminal snapshot survives a lost Builder CAS.
if determinate or prepared_failure:
self._save_full_run_result(reservation.id, bound)
return bound
def _run_confirmed_draft(
self,
app_id: str,
actor: Actor,
on_event: Callable[[NodeEvent], None],
*,
session_id: str,
test_input_id: str,
on_workflow_event: Callable[[Mapping[str, object]], None] | None,
consent_id: str,
command_id: str,
) -> Run:
"""Launch one server-reserved confirmed command through the same native generator.
Authority is reloaded from the consent owner (reserved command, sidecar,
lineage), never taken from this call's arguments; the saved approved
input is the only input. Builder preparation sessions are closed before
generation starts. Every Builder-side projection is sanitized; the
native authorized Run keeps the message content.
"""
consent = BuilderRealCallConsentService(_session_factory())
policy = BuilderExecutionPolicyService(_session_factory())
try:
context = consent.reserved_launch(
actor=actor, session_id=session_id, consent_id=consent_id, command_id=command_id
)
except BuilderExecutionPolicyError as error:
# Not this invocation's launchable command: the grant is not touched.
return _unsupported_run(error)
try:
if (context.app_id, context.test_input_id) != (app_id, test_input_id):
raise BuilderExecutionPolicyError("execution_owner_mismatch")
receipt = consent.get(actor=actor, session_id=session_id, consent_id=consent_id)
if receipt.state != "confirmed":
raise BuilderExecutionPolicyError(receipt.reason_code or "consent_not_confirmed")
context = policy.prepare_confirmed(context)
with _session_factory()() as session:
from services.dify_builder.task_controls import lock_execution
lock_execution(session, app_id=app_id)
account = resolve_account(session, actor)
app = load_app(session, app_id, actor)
draft = _load_draft_workflow_or_raise(app, session=session)
saved = session.get(DifyBuilderTestInput, context.test_input_id)
if saved is None or saved.session_id != session_id:
raise BuilderExecutionPolicyError("test_input_binding_mismatch")
inputs = dict(saved.inputs)
try:
effective_inputs = dict(
BaseAppGenerator()._prepare_user_inputs(
user_inputs=inputs,
variables=WorkflowVariablesConfigManager.convert(draft),
tenant_id=app.tenant_id,
)
)
except ValueError:
raise BuilderExecutionPolicyError("unsupported_input_value") from None
if (
scalar_inputs_digest(inputs) != context.submitted_inputs_digest
or scalar_inputs_digest(effective_inputs) != context.effective_inputs_digest
):
raise BuilderExecutionPolicyError("test_input_binding_mismatch")
except BuilderExecutionPolicyError as error:
if error.reason_code not in _DUPLICATE_LAUNCH_REASONS:
# A refusal about this grant itself; a duplicate never settles another launch's grant.
self._settle_confirmed(consent, context)
return _unsupported_run(error)
launch = _BuilderExecutionLaunch(policy, context, consent=consent)
try:
set_login_user(account)
# A fresh session for the native generator only; no Builder read is open.
with _session_factory()() as session:
response = AppGenerateService.generate(
app_model=app,
user=account,
args={"inputs": effective_inputs},
invoke_from=InvokeFrom.DEBUGGER,
session=session,
streaming=True,
workflow_execution_mode="in_process",
builder_execution=context,
builder_execution_admit=launch,
)
except BuilderExecutionPolicyError as error:
launch.on_refusal(error)
return self._finalize_launch(launch, actor, response_completed=False, setup_refusal=error)
except BaseException:
self._close_failed_launch(launch)
raise
completed, candidate_ids = self._consume_launch(launch, response, on_event, on_workflow_event, sanitize=True)
return self._finalize_launch(launch, actor, response_completed=completed, candidate_ids=candidate_ids)
def _consume_launch(
self,
launch: _BuilderExecutionLaunch,
response: Any,
on_event: Callable[[NodeEvent], None],
on_workflow_event: Callable[[Mapping[str, object]], None] | None,
*,
sanitize: bool,
) -> tuple[bool, set[str]]:
"""Consume the native stream outside any Builder session; close or seal it on every exit.
``sanitize`` reduces forwarded frames and node events to IDs and statuses.
"""
completed = False
primary: BaseException | None = None
candidate_ids: set[str] = set()
try:
if isinstance(response, Mapping):
candidate = str((response.get("data") or {}).get("id") or response.get("workflow_run_id") or "")
if candidate:
candidate_ids.add(candidate)
else:
for chunk in response:
payload = stream_chunk_as_mapping(chunk)
if payload is None:
continue
if on_workflow_event is not None:
on_workflow_event(sanitize_confirmed_stream_event(payload) if sanitize else payload)
candidate = run_id_from_stream_chunk(payload)
if candidate:
candidate_ids.add(candidate)
node_event = node_event_from_stream_chunk(payload)
if node_event is not None:
on_event(sanitize_confirmed_node_event(node_event) if sanitize else node_event)
completed = True
except BaseException as error:
primary = error
raise
finally:
try:
try:
close = response.close if response is not None else None
except AttributeError:
close = None
if callable(close):
close()
except BaseException:
completed = False
if primary is None:
self._close_failed_launch(launch)
raise
logging.getLogger(__name__).warning("Builder response close failed after primary failure")
finally:
if primary is not None:
self._close_failed_launch(launch)
return completed, candidate_ids
@staticmethod
def _settle_confirmed(consent: BuilderRealCallConsentService, context: ConfirmedToolExecutionContext) -> None:
"""Close the grant from its durable facts; a failure leaves it for the reconcile sweeper."""
try:
consent.settle_launch(context=context)
except Exception:
logging.getLogger(__name__).warning("Builder confirmed grant could not be settled")
@classmethod
def _settle_owned_launch(cls, launch: _BuilderExecutionLaunch, completion: TrustedExecutionCompletion) -> None:
"""Settle only a launch this invocation claimed, or one refused for a grant-level reason.
A delivery that lost the sidecar launch claim to another one must not
settle the winner's grant while it may still be running.
"""
if launch.consent is None or not isinstance(launch.context, ConfirmedToolExecutionContext):
return
lost = completion.native_run_id is None and (
completion.refusal is None or completion.refusal.reason_code in _DUPLICATE_LAUNCH_REASONS
)
if not lost:
cls._settle_confirmed(launch.consent, launch.context)
@classmethod
def _close_failed_launch(cls, launch: _BuilderExecutionLaunch) -> None:
completion = launch.finish(response_completed=False)
try:
launch.service.seal(request_id=launch.context.request_id, completion=completion)
except Exception:
logging.getLogger(__name__).warning("Builder incomplete execution could not be sealed")
cls._settle_owned_launch(launch, completion)
def _finalize_launch(
self,
launch: _BuilderExecutionLaunch,
actor: Actor,
*,
response_completed: bool,
candidate_ids: set[str] | None = None,
setup_refusal: BuilderExecutionPolicyError | None = None,
) -> Run:
completion = launch.finish(response_completed=response_completed, setup_refusal=setup_refusal)
if candidate_ids and candidate_ids != {completion.native_run_id}:
completion = completion.model_copy(update={"response_completed": False})
context = launch.context
try:
summary = launch.service.seal(request_id=context.request_id, completion=completion)
except Exception:
logging.getLogger(__name__).warning("Builder execution seal unavailable")
match context:
case ConfirmedToolExecutionContext():
summary = ExecutionEvidenceSummary(
request_id=context.request_id,
policy_version=context.policy_version,
mode=context.mode,
sealed=False,
safety_outcome="execution_evidence_unknown",
sandbox_profile=context.sandbox_profile,
consent_id=context.consent_id,
)
case BuilderExecutionContext():
summary = ExecutionEvidenceSummary(
request_id=context.request_id,
mode=context.mode,
sealed=False,
safety_outcome="execution_evidence_unknown",
sandbox_profile=context.sandbox_profile,
fixture_digest=context.fixture_digest,
)
case JsonToolExecutionContext():
summary = ExecutionEvidenceSummary(
request_id=context.request_id,
policy_version=context.policy_version,
mode=context.mode,
sealed=False,
safety_outcome="execution_evidence_unknown",
sandbox_profile=context.sandbox_profile,
)
# After the seal, so completion is read from this Run's own sealed proof.
self._settle_owned_launch(launch, completion)
# SQL status/outputs are authoritative; terminal SSE is only a consistency candidate.
result = self._finish_run(launch.context.tenant_id, launch.context.app_id, {}, summary.native_run_id or "")
run, _ = result
if run.verification is None:
from core.dify_builder.models import RunVerification
run.verification = RunVerification(
execution_revision="",
executed_graph_revision="",
terminal_outputs=None,
output_findings=[],
executed_node_ids=[],
no_output_dead_branch=False,
)
run.verification.execution_evidence = summary
if completion.refusal is not None:
run.execution_refusal = completion.refusal
if not summary.native_run_id:
run.error = ""
if summary.safety_outcome == "unsupported_safe_execution":
run.status = "failed"
bound = self._bind_run(
result, launch.context.app_id, actor, launch.context.execution_revision, launch.context.graph_revision
)
if isinstance(context, ConfirmedToolExecutionContext):
# Output findings were derived from native rows above; Builder keeps no content.
return sanitize_confirmed_run(bound)
return bound
def graph_revision(self, graph: Graph) -> str:
return executable_graph_revision(graph)
def _bind_run(
self,
result: tuple[Run, Any],
app_id: str,
actor: Actor,
before_revision: str,
before_graph_revision: str,
*,
execution_recorder: BuilderExecutionRecorder | None = None,
restricted: bool = True,
) -> Run:
"""Bind only the actual persisted run graph and equal before/after full revisions.
Native persistence does not snapshot non-graph configuration: a transient
non-graph A→B→A change is outside this guarantee. No execution transaction
is held open across stream consumption or provider I/O.
"""
run, row = result
if execution_recorder is not None and not execution_recorder.healthy:
run.execution_refusal = BuilderExecutionRefusal(reason_code="execution_recorder_unhealthy")
return run
native_graph = row.graph_dict if row is not None else None
evidence = run.verification
if evidence is None or not isinstance(native_graph, dict) or not isinstance(native_graph.get("nodes"), list):
return run
native_revision = executable_graph_revision(native_graph)
evidence.executed_graph_revision = native_revision
evidence.output_findings = collect_output_findings(native_graph, run.per_node)
evidence.no_output_dead_branch = run_finished_without_output(native_graph, run.per_node)
with _session_factory()() as session:
app = load_app(session, app_id, actor)
draft = _load_draft_workflow_or_raise(app, session=session)
if restricted:
try:
restricted_admission_snapshot(draft, app)
except BuilderExecutionPolicyError as error:
run.execution_refusal = BuilderExecutionRefusal(reason_code=error.reason_code)
return run
graph, after_revision = dict(draft.graph_dict), execution_revision(draft)
if (
before_revision
and before_revision == after_revision
and native_revision == before_graph_revision == executable_graph_revision(graph)
):
evidence.execution_revision = before_revision
return run
def _finish_run(
self,
tenant_id: str,
app_id: str,
final: dict[str, Any],
stream_run_id: str,
*,
error_frame: dict[str, str] | None = None,
) -> tuple[Run, Any]:
"""Turn the run's terminal data into a ``Run``. Shared by the blocking
and streaming paths so they can never disagree about the outcome.
Precedence: terminal frame > error frame > nothing. A stream that ended
on an explicit error frame is a FAILED run carrying that error; only a
stream that ended with neither is an unknown outcome.
"""
run_id = str(final.get("id") or stream_run_id or "")
run_row = (
DifyAPIRepositoryFactory.create_api_workflow_run_repository(
sessionmaker(bind=db.engine)
).get_workflow_run_by_id(tenant_id=tenant_id, app_id=app_id, run_id=run_id)
if run_id
else None
)
# Backend diagnosis still uses persisted node-execution rows to build
# ``per_node[].outputs``; the frontend consumes the full event stream.
node_execs = (
DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
sessionmaker(bind=db.engine)
).get_executions_by_workflow_run(tenant_id, app_id, run_id)
if run_id
else []
)
if final:
return map_run_result(final, node_execs), run_row
# The pipeline said the run threw (ESQ1-302: Graph.init rejected a node
# before workflow_started, so there is no run row at all). That is a
# failure with a reason, not an outcome we lost sight of.
if error_frame is not None:
return map_error_frame_run(error_frame, run_id, node_execs), run_row
# The stream ended without a terminal frame. The stream is no longer
# the authority on how the run went; the database is. Synthesising
# "failed" here would report a run that may well have SUCCEEDED as a
# failed build and feed the repair loop a lie.
return self._run_result_without_terminal_frame(run_id, node_execs, run_row), run_row
@staticmethod
def _run_result_without_terminal_frame(run_id: str, node_execs: Sequence[Any], run_row: Any) -> Run:
"""Recover a truncated stream's outcome from the scoped workflow-run row."""
if run_row is None:
# No id, or no row: nothing can say how this went.
return map_unknown_run_outcome({"id": run_id}, node_execs)
recovered = run_result_data_from_run_row(run_row)
if is_unfinished_run_status(recovered["status"]):
# The row says the run is still going, so its outcome is genuinely
# unknown rather than bad.
return map_unknown_run_outcome(recovered, node_execs)
return map_run_result(recovered, node_execs)
def publish(
self, app_id: str, actor: Actor, *, expected_revision: str, expected_graph_revision: str
) -> PublishResult:
"""Read public Tool declarations before locking the verified publication draft."""
with _session_factory()() as read_session:
app_to_prepare = load_app(read_session, app_id, actor)
workflow_service = WorkflowService()
workflow_service.prepare_draft_tool_publish(app_model=app_to_prepare)
with _session_factory()() as session:
from services.dify_builder.task_controls import lock_execution
lock_execution(session, app_id=app_id)
account = resolve_account(session, actor)
app = load_app(session, app_id, actor)
# Publication writes the App pointer after locking the draft. Keep
# metadata writers App-first while graph-only callers stay row-first.
owner_id = session.scalar(
select(App.id).where(App.id == app_id, App.tenant_id == actor.tenant_id).with_for_update()
)
if owner_id is None:
from core.dify_builder.errors import NotFoundError
raise NotFoundError(f"app not found: {app_id}")
draft = _load_draft_workflow_or_raise(app, session=session)
session.refresh(draft, with_for_update=True)
if (
not expected_revision
or not expected_graph_revision
or execution_revision(draft) != expected_revision
or executable_graph_revision(dict(draft.graph_dict)) != expected_graph_revision
):
raise HashMismatchError(f"workflow verification is stale: {app_id}")
# Reselect only this locked identity, even if duplicate draft rows exist.
workflow = workflow_service.publish_workflow(
session=session,
app_model=app,
account=account,
expected_draft_id=draft.id,
)
app_in_session = session.get(App, app_id)
assert app_in_session is not None, f"app disappeared mid-transaction: {app_id}"
app_in_session.workflow_id = workflow.id
app_in_session.updated_by = account.id
app_in_session.updated_at = naive_utc_now()
session.commit()
return PublishResult(
version_name=workflow.marked_name or f"# {workflow.version_number}",
status="live",
)