"""Native Agent admission in the existing caller-owned draft transaction. Call prepare_node for every selected node before installing its graph data, then sync_prepared_bindings in the SAME transaction. The authoritative roster row is locked until graph and native bindings commit/roll back together. No method here commits, dispatches a capability, or materializes runtime credentials. """ from collections.abc import Mapping from copy import deepcopy from dataclasses import dataclass from pydantic import JsonValue from sqlalchemy import select from sqlalchemy.orm import Session from core.agent.publish_visibility import workflow_callable_active_snapshot_filter from core.dify_builder.models import Actor from core.workflow.graph_topology import WorkflowGraphTopology from core.workflow.nodes.agent_v2.entities import DifyAgentNodeData from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator from models.agent import ( Agent, AgentConfigSnapshot, AgentScope, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) from models.agent_config_entities import WorkflowNodeJobConfig from models.workflow import Workflow from runtime_contracts.builder.execution import BuilderExecutionPolicyError, Digest from runtime_contracts.builder.native.contracts import ID, FrozenDTO from services.agent import access_service from services.agent.composer_validator import ComposerConfigValidator from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.dify_builder.native_testing.agent_catalog import project_published_agent class AgentSnapshotChanged(ValueError): # noqa: N818 - frozen A1 domain contract def __init__(self, *, effective_snapshot_id: str): self.effective_snapshot_id = effective_snapshot_id super().__init__("Published Agent snapshot changed; reconfirm the selection.") def require_selected_snapshot(*, selected_snapshot_id: str, effective_snapshot_id: str) -> None: if selected_snapshot_id != effective_snapshot_id: raise AgentSnapshotChanged(effective_snapshot_id=effective_snapshot_id) class AgentBindingSelection(FrozenDTO): agent_id: ID snapshot_id: ID capability_digest: Digest @classmethod def from_resource_id(cls, resource_id: str) -> "AgentBindingSelection": prefix, agent_id, snapshot_id, digest = resource_id.split(":") if prefix != "agent": raise ValueError("Invalid Agent selection") return cls(agent_id=agent_id, snapshot_id=snapshot_id, capability_digest=digest) def native_node_data(*, agent_id: str, snapshot_id: str, node_job: WorkflowNodeJobConfig) -> dict[str, JsonValue]: """Render only native graph fields consumed by the publication owner.""" return { "type": "agent", "version": "2", "agent_node_kind": "dify_agent", "title": "Workspace Agent", "agent_binding": {"binding_type": "roster_agent", "agent_id": agent_id, "current_snapshot_id": snapshot_id}, "agent_task": node_job.workflow_prompt, "agent_declared_outputs": [item.model_dump(mode="json") for item in node_job.declared_outputs], "agent_output_routes": node_job.output_routes.model_dump(mode="json"), } def has_native_roster_binding(data: Mapping) -> bool: binding = data.get("agent_binding") return ( data.get("type") == "agent" and data.get("version") == "2" and data.get("agent_node_kind") == "dify_agent" and isinstance(binding, Mapping) and binding.get("binding_type") == "roster_agent" and isinstance(binding.get("agent_id"), str) and bool(binding["agent_id"]) and isinstance(binding.get("current_snapshot_id"), str) and bool(binding["current_snapshot_id"]) ) @dataclass(frozen=True) class AgentBindingAuthorization: """Ephemeral current permission result; never a persisted/public authority.""" actor_id: str tenant_id: str app_id: str agent_ids: frozenset[str] @dataclass(frozen=True) class ConfirmedAgentNodeSelection: agent_id: str snapshot_id: str authorization: AgentBindingAuthorization def authorize_graph_agent_selection(*, actor: Actor, app_id: str, node_data: Mapping) -> ConfirmedAgentNodeSelection: """Authorize the exact native graph selection before opening resolver SQL. This is only a current permission check. The binding resolver must still verify the complete owner chain and selected generation at execution. """ if not has_native_roster_binding(node_data): raise BuilderExecutionPolicyError("agent_binding_not_prepared") binding = node_data["agent_binding"] agent_id, snapshot_id = binding["agent_id"], binding["current_snapshot_id"] authorization = AgentBindingService.authorize(actor=actor, app_id=app_id, agent_ids=frozenset({agent_id})) return ConfirmedAgentNodeSelection(agent_id, snapshot_id, authorization) class AgentBindingService: def __init__(self, session: Session, *, authorization: AgentBindingAuthorization | None = None): self.session = session self.authorization = authorization self._preparation_transaction = None self._prepared: dict[ tuple[str, str], tuple[AgentBindingSelection, dict[str, JsonValue], WorkflowNodeJobConfig] ] = {} @staticmethod def authorize(*, actor: Actor, app_id: str, agent_ids: frozenset[str]) -> AgentBindingAuthorization: """Call immediately BEFORE opening the bounded draft transaction. RBAC RPC runs outside DB transactions/row locks. Local membership and publication are checked again in prepare_node under caller ownership. """ from configs import dify_config from core.rbac import RBACPermission, RBACResourceScope from services.enterprise.rbac_service import RBACService if dify_config.RBAC_ENABLED: if not RBACService.CheckAccess.check( actor.tenant_id, actor.account_id, scene=RBACPermission.APP_EDIT, resource_type=RBACResourceScope.APP, resource_id=app_id, ): raise BuilderExecutionPolicyError("agent_access_denied") access = access_service.resolve_current_agent_access_filter( tenant_id=actor.tenant_id, actor_id=actor.account_id ) for agent_id in agent_ids: if ( access.accessible_agent_ids is not None and agent_id not in access.accessible_agent_ids ) or not RBACService.CheckAccess.check( actor.tenant_id, actor.account_id, scene=RBACPermission.AGENT_PREVIEW, resource_type=RBACResourceScope.AGENT, resource_id=agent_id, ): raise BuilderExecutionPolicyError("agent_access_denied") return AgentBindingAuthorization(actor.account_id, actor.tenant_id, app_id, agent_ids) def recheck_access(self, *, actor: Actor, app_id: str, agent_id: str) -> None: """Check the pre-transaction RBAC result plus current local identity.""" from configs import dify_config grant = self.authorization if dify_config.RBAC_ENABLED and ( grant is None or grant.actor_id != actor.account_id or grant.tenant_id != actor.tenant_id or grant.app_id != app_id or agent_id not in grant.agent_ids ): raise BuilderExecutionPolicyError("agent_access_denied") access_service.require_builder_agent_access( session=self.session, tenant_id=actor.tenant_id, actor_id=actor.account_id, app_id=app_id ) def prepare_node( self, *, actor: Actor, workflow: Workflow, node_id: str, selection: AgentBindingSelection, node_job: WorkflowNodeJobConfig, existing_node_data: Mapping[str, JsonValue] | None = None, ) -> dict[str, JsonValue]: if not self.session.in_transaction(): raise ValueError("Agent preparation requires the caller's draft transaction") if self._prepared and self._preparation_transaction is not self.session.get_transaction(): raise BuilderExecutionPolicyError("agent_binding_not_prepared") self._preparation_transaction = self.session.get_transaction() if workflow.tenant_id != actor.tenant_id or workflow.version != Workflow.VERSION_DRAFT or not node_id: raise BuilderExecutionPolicyError("agent_binding_owner_mismatch") owned_workflow = self.session.scalar( select(Workflow).where( Workflow.id == workflow.id, Workflow.tenant_id == actor.tenant_id, Workflow.app_id == workflow.app_id, Workflow.version == Workflow.VERSION_DRAFT, ) ) if owned_workflow is None: raise BuilderExecutionPolicyError("agent_binding_owner_mismatch") self.recheck_access(actor=actor, app_id=workflow.app_id, agent_id=selection.agent_id) # Refresh the identity map after acquiring the publication pointer lock. agent = self.session.scalar( select(Agent) .where( Agent.id == selection.agent_id, Agent.tenant_id == actor.tenant_id, Agent.scope == AgentScope.ROSTER, Agent.status == AgentStatus.ACTIVE, Agent.active_config_has_model.is_(True), workflow_callable_active_snapshot_filter(), ) .with_for_update() .execution_options(populate_existing=True) ) if agent is None or agent.active_config_snapshot_id is None: raise BuilderExecutionPolicyError("agent_not_available") access_service.require_builder_agent_access( session=self.session, tenant_id=actor.tenant_id, actor_id=actor.account_id, app_id=workflow.app_id ) require_selected_snapshot( selected_snapshot_id=selection.snapshot_id, effective_snapshot_id=agent.active_config_snapshot_id ) snapshot = self.session.scalar( select(AgentConfigSnapshot).where( AgentConfigSnapshot.id == selection.snapshot_id, AgentConfigSnapshot.tenant_id == actor.tenant_id, AgentConfigSnapshot.agent_id == agent.id, ) ) if snapshot is None: raise BuilderExecutionPolicyError("agent_not_available") if project_published_agent(agent=agent, snapshot=snapshot).capability_digest != selection.capability_digest: raise BuilderExecutionPolicyError("agent_capability_changed") node_job = WorkflowNodeJobConfig.model_validate(node_job.model_dump(mode="json")) ComposerConfigValidator.validate_node_job(node_job) # The native graph owner derives input refs from task markers on sync. derived = WorkflowAgentPublishService._previous_node_output_refs_from_prompt(node_job.workflow_prompt) if node_job.previous_node_output_refs and node_job.previous_node_output_refs != derived: raise ValueError("Node-job input selectors must match native task references") node_job.previous_node_output_refs = derived data = native_node_data(agent_id=agent.id, snapshot_id=snapshot.id, node_job=node_job) if existing_node_data is not None: # The caller has retained this exact same-node configuration under # its draft/binding locks. Keep generic retry/error behavior and # canvas data, while the native projection remains owned here. if any(existing_node_data.get(key) != value for key, value in data.items() if key != "title"): raise BuilderExecutionPolicyError("agent_binding_not_prepared") data = deepcopy(dict(existing_node_data)) DifyAgentNodeData.model_validate(data) binding = WorkflowAgentNodeBinding( tenant_id=actor.tenant_id, app_id=workflow.app_id, workflow_id=workflow.id, workflow_version=workflow.version, node_id=node_id, binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id=agent.id, current_snapshot_id=snapshot.id, node_job_config=node_job, ) graph = deepcopy(dict(workflow.graph_dict)) nodes = graph.setdefault("nodes", []) nodes[:] = [item for item in nodes if item.get("id") != node_id] nodes.append({"id": node_id, "data": data}) WorkflowAgentNodeValidator.validate_node_job( session=self.session, binding=binding, node_job=node_job, topology=WorkflowGraphTopology.from_graph(graph) ) self._prepared[(workflow.id, node_id)] = (selection, deepcopy(data), node_job.model_copy(deep=True)) return data def sync_prepared_bindings( self, *, actor: Actor, workflow: Workflow, selections: Mapping[str, AgentBindingSelection] ) -> set[str]: """Require preparation for the complete graph before whole-graph native sync. Every native graph Agent must have a confirmed prepared roster selection. Existing bindings omitted from the graph are protected from implicit deletion. Inline/unprepared nodes and removals require their own admitted owner path rather than piggybacking on this roster binding operation. """ if not self.session.in_transaction(): raise ValueError("Agent sync requires the caller's draft transaction") if self.session.get_transaction() is not self._preparation_transaction: raise BuilderExecutionPolicyError("agent_binding_not_prepared") nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(workflow.graph_dict)) existing_node_ids = set( self.session.scalars( select(WorkflowAgentNodeBinding.node_id).where( WorkflowAgentNodeBinding.tenant_id == actor.tenant_id, WorkflowAgentNodeBinding.app_id == workflow.app_id, WorkflowAgentNodeBinding.workflow_id == workflow.id, WorkflowAgentNodeBinding.workflow_version == workflow.version, ) ).all() ) prepared_node_ids = {node_id for workflow_id, node_id in self._prepared if workflow_id == workflow.id} if ( not selections or set(selections) != set(nodes) or prepared_node_ids != set(nodes) or not existing_node_ids.issubset(nodes) ): raise BuilderExecutionPolicyError("agent_binding_not_prepared") for node_id, selection in selections.items(): prepared = self._prepared.get((workflow.id, node_id)) if prepared is None or prepared[0] != selection or nodes.get(node_id) != prepared[1]: raise BuilderExecutionPolicyError("agent_binding_not_prepared") self.recheck_access(actor=actor, app_id=workflow.app_id, agent_id=selection.agent_id) retirements = WorkflowAgentPublishService.sync_agent_bindings_for_draft( session=self.session, draft_workflow=workflow, account_id=actor.account_id ) for node_id, selection in selections.items(): binding = self.session.scalar( 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 == node_id, WorkflowAgentNodeBinding.agent_id == selection.agent_id, ) ) if binding is None or binding.current_snapshot_id is None: raise BuilderExecutionPolicyError("agent_binding_owner_mismatch") require_selected_snapshot( selected_snapshot_id=selection.snapshot_id, effective_snapshot_id=binding.current_snapshot_id ) # Preserve the complete validated native job (mode, contacts and # metadata included) in its existing binding owner, in this same # transaction. The graph's task/output projection is not a second # persistence format for these fields. binding.node_job_config = self._prepared[(workflow.id, node_id)][2].model_copy(deep=True) return retirements