Files
dify/api/services/app_dsl_service.py
T

1402 lines
62 KiB
Python

import base64
import hashlib
import json
import logging
import uuid
from collections.abc import Mapping
from typing import Any, cast
from urllib.parse import urlparse
from uuid import uuid4
import yaml
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from packaging.version import parse as parse_version
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from core.app.app_config.features.suggested_questions_after_answer.manager import (
SuggestedQuestionsAfterAnswerConfigManager,
)
from core.app.app_config.features.text_to_speech.manager import TextToSpeechConfigManager
from core.file import remote_fetcher
from core.plugin.entities.plugin import PluginDependency
from core.rbac import RBACPermission, RBACResourceScope
from core.trigger.constants import (
TRIGGER_PLUGIN_NODE_TYPE,
TRIGGER_SCHEDULE_NODE_TYPE,
TRIGGER_WEBHOOK_NODE_TYPE,
)
from core.workflow.llm_environment_variable import (
LLMEnvironmentVariable,
parse_llm_model_selector,
resolve_llm_model_config,
should_resolve_llm_model_selector,
)
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from core.workflow.nodes.knowledge_retrieval.entities import KnowledgeRetrievalNodeData
from core.workflow.nodes.trigger_schedule.trigger_schedule_node import TriggerScheduleNode
from enums import DeploymentEdition
from events.app_event import app_model_config_was_updated, app_was_created
from extensions.ext_redis import redis_client
from factories import variable_factory
from graphon.enums import BuiltinNodeTypes
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.nodes.llm.entities import LLMNodeData, ModelConfig
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType
from libs.datetime_utils import naive_utc_now
from libs.url_utils import github_blob_to_raw_url
from models import Account, App, AppMode
from models.agent import AgentScope, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent_config_entities import WorkflowNodeJobConfig
from models.model import AppModelConfig, AppModelConfigDict, IconType, load_annotation_reply_config
from models.workflow import Workflow
from services.agent.dsl_entities import AGENT_NODE_JOB_DSL_KEY, AgentPackage, make_agent_app_dsl
from services.agent.dsl_service import AgentDslService, is_agent_v2_graph
from services.agent.package_resource_exporter import AgentPackageResourceExporter
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.agent.workflow_publish_service import WorkflowAgentPublishService
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
from services.dsl_version import check_version_compatibility
from services.enterprise import rbac_service
from services.enterprise.rbac_service import RBACService
from services.entities.dsl_entities import (
AppDslExportData,
AppImportPackage,
CheckDependenciesResult,
DslImportWarning,
Import,
ImportMode,
ImportStatus,
PendingImportOwner,
make_app_dsl,
)
from services.entities.site_dsl import SiteDsl, apply_site_dsl
from services.errors.app import AppQuotaCheckError, DraftChangedError, WorkflowNotFoundError
from services.errors.base import NoPermissionError
from services.feature_service import FeatureService
from services.icon_configuration import (
DEFAULT_ICON,
DEFAULT_ICON_TYPE,
is_valid_image_icon,
)
from services.knowledge_fs.knowledge_retrieval_v2.validation import missing_control_space_ids
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.workflow.graph_diff import draft_token
from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService
logger = logging.getLogger(__name__)
IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
CURRENT_DSL_VERSION = CURRENT_APP_DSL_VERSION
# Other provider types reference workspace records by id, so they never map to a plugin.
PLUGIN_TOOL_PROVIDER_TYPES = frozenset({ToolProviderType.BUILT_IN, ToolProviderType.PLUGIN})
def initialize_imported_app_creator_access(result: Import, *, tenant_id: str, account_id: str) -> None:
"""Initialize ordinary App creator access only after the caller commits the import."""
if (
result.status in (ImportStatus.COMPLETED, ImportStatus.COMPLETED_WITH_WARNINGS)
and result.app_created
and result.app_id
and result.app_mode
in (
AppMode.CHAT,
AppMode.ADVANCED_CHAT,
AppMode.COMPLETION,
AppMode.WORKFLOW,
AppMode.AGENT_CHAT,
)
):
rbac_service.try_sync_creator_access_policy_member_bindings(
tenant_id, account_id, rbac_service.RBACResourceType.APP, result.app_id
)
def missing_app_section_error(top_level_keys: list[str]) -> str:
"""Explain a YAML that has no top-level ``app`` mapping.
The found keys are the caller's actual document, so a sketch of nodes is
not reported as a blank import failure.
"""
found = ", ".join(key for key in top_level_keys if key != "app")
if len(found) > 80:
found = found[:80].rstrip(", ") + "…"
return (
"Missing app data in YAML content. "
"Not a valid Dify app DSL: the top-level 'app' section is required "
f"(found: {found or 'none'})."
)
class PendingData(PendingImportOwner):
import_mode: str
yaml_content: str
name: str | None = None
description: str | None = None
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
app_id: str | None = None
draft_hash: str | None = None
preserve_agent_bindings: bool = False
warnings: list[DslImportWarning] = Field(default_factory=list)
class CheckDependenciesPendingData(BaseModel):
dependencies: list[PluginDependency]
app_id: str | None = None
class AppDslService:
_warnings: list[DslImportWarning]
def __init__(self, session: Session):
self._session = session
self._warnings = []
@staticmethod
def _check_app_quota(account: Account) -> None:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
AppDslService.check_app_quota(tenant_id=tenant_id, new_apps=1)
@staticmethod
def check_app_quota(*, tenant_id: str, new_apps: int) -> None:
"""Reject an import on Cloud when creating ``new_apps`` more Apps would exceed the plan's limit."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return
try:
apps = FeatureService.get_features(tenant_id, exclude_vector_space=True).apps
except Exception as error:
# A failed lookup is a server failure, not an invalid DSL or exhausted quota.
raise AppQuotaCheckError("Unable to check the app quota.") from error
if 0 < apps.limit < apps.size + new_apps:
raise NoPermissionError("The number of apps has reached the limit of your subscription.")
def import_app(
self,
*,
account: Account,
import_mode: str,
yaml_content: str | None = None,
yaml_url: str | None = None,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
app_id: str | None = None,
import_app_id: str | None = None,
package: AppImportPackage | None = None,
preserve_agent_bindings: bool = False,
draft_hash: str | None = None,
) -> Import:
"""Import an App DSL after validating applicable entitlements and staging archive resources.
With ``draft_hash``, an overwrite saves only while the draft's ``draft_token`` still equals it and
raises DraftChangedError otherwise; a pending import keeps it for the confirm.
"""
self._warnings = []
import_id = str(uuid.uuid4())
# Validate import mode
try:
mode = ImportMode(import_mode)
except ValueError as caught_error:
raise ValueError(f"Invalid import_mode: {import_mode}") from caught_error
# Get YAML content
content: str = ""
if mode == ImportMode.YAML_URL:
if not yaml_url:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="yaml_url is required when import_mode is yaml-url",
)
try:
parsed_url = urlparse(yaml_url)
if parsed_url.path.endswith((".yml", ".yaml")):
yaml_url = github_blob_to_raw_url(yaml_url)
response = remote_fetcher.make_request("GET", yaml_url.strip(), follow_redirects=True, timeout=(10, 10))
response.raise_for_status()
raw_content = response.content
if dsl_content_size(raw_content) > DSL_MAX_SIZE:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="File size exceeds the limit of 10MB",
)
content = raw_content.decode("utf-8")
if not content:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="Empty content from url",
)
except Exception as e:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error=f"Error fetching YAML from URL: {str(e)}",
)
elif mode == ImportMode.YAML_CONTENT:
if not yaml_content:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="yaml_content is required when import_mode is yaml-content",
)
content = yaml_content
if dsl_content_size(content) > DSL_MAX_SIZE:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="File size exceeds the limit of 10MB",
)
# Process YAML content
try:
# Parse YAML to validate format
data = yaml.safe_load(content)
if not isinstance(data, dict):
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="Invalid YAML format: content must be a mapping",
)
original_top_level_keys = [key for key in data if isinstance(key, str)]
# Validate and fix DSL version
if not data.get("version"):
data["version"] = "0.1.0"
if not data.get("kind") or data.get("kind") != "app":
data["kind"] = "app"
imported_version = data.get("version", "0.1.0")
# check if imported_version is a float-like string
if not isinstance(imported_version, str):
raise ValueError(f"Invalid version type, expected str, got {type(imported_version)}")
status = check_version_compatibility(imported_version, CURRENT_DSL_VERSION)
# Extract app data
app_data = data.get("app")
if not app_data:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error=missing_app_section_error(original_top_level_keys),
)
if not app_id:
self._check_app_quota(account)
allow_premium_site_settings = True
if status != ImportStatus.PENDING and data.get("site") is not None:
# Resolve billing before archive uploads or database writes.
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
allow_premium_site_settings = FeatureService.can_import_premium_site_settings(tenant_id)
if package is not None and package.has_resources:
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
# Authorize overwrites in a separate read session before storage I/O.
# The normal import below reloads and rechecks the target after upload.
if app_id:
with Session(self._session.get_bind()) as authorization_session:
target = AppDslService(authorization_session)._load_app_for_overwrite(account, app_id)
if target is None:
raise ValueError("App not found")
self._validate_workflow_overwrite(target, data)
package.materialize_icons(data=data, tenant_id=tenant_id, account_id=account.id)
agents, self._warnings = package.materialize_agents(tenant_id=tenant_id, account_id=account.id)
if agents:
data["agent_packages"] = agents
content = yaml.safe_dump(data, allow_unicode=True)
# If app_id is provided, check if it exists
app = None
if app_id:
app = self._load_app_for_overwrite(account, app_id)
if not app:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="App not found",
)
if app.mode not in [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="Only workflow or advanced chat apps can be overwritten",
)
self._validate_workflow_overwrite(app, data)
# If major version mismatch, store import info in Redis
if status == ImportStatus.PENDING:
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
pending_data = PendingData(
tenant_id=tenant_id,
account_id=account.id,
import_mode=import_mode,
yaml_content=content,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
app_id=app_id,
draft_hash=draft_hash,
preserve_agent_bindings=preserve_agent_bindings,
warnings=self._warnings,
)
redis_client.setex(
f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}",
IMPORT_INFO_REDIS_EXPIRY,
pending_data.model_dump_json(),
)
return Import(
id=import_id,
status=status,
app_id=app_id,
imported_dsl_version=imported_version,
)
# Extract dependencies
dependencies = data.get("dependencies", [])
check_dependencies_pending_data = None
if dependencies:
check_dependencies_pending_data = [PluginDependency.model_validate(d) for d in dependencies]
elif parse_version(imported_version) <= parse_version("0.1.5"):
if "workflow" in data:
graph = data.get("workflow", {}).get("graph", {})
dependencies_list = self._extract_dependencies_from_workflow_graph(graph)
else:
dependencies_list = self._extract_dependencies_from_model_config(data.get("model_config", {}))
check_dependencies_pending_data = DependenciesAnalysisService.generate_latest_dependencies(
dependencies_list
)
# Create or update app
app_created = app is None
app = self._create_or_update_app(
app=app,
data=data,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
dependencies=check_dependencies_pending_data,
import_app_id=import_app_id,
allow_premium_site_settings=allow_premium_site_settings,
preserve_agent_bindings=preserve_agent_bindings,
draft_hash=draft_hash,
)
draft_var_srv = WorkflowDraftVariableService(session=self._session)
draft_var_srv.delete_app_workflow_variables(app_id=app.id)
result_status = self._status_with_warnings(status)
return Import(
id=import_id,
status=result_status,
app_id=app.id,
app_created=app_created,
app_mode=app.mode,
imported_dsl_version=imported_version,
warnings=self._warnings,
)
except yaml.YAMLError as e:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error=f"Invalid YAML format: {str(e)}",
)
except (NoPermissionError, DraftChangedError, AppQuotaCheckError):
raise
except Exception as e:
logger.exception("Failed to import app")
return Import(
id=import_id,
status=ImportStatus.FAILED,
error=str(e),
)
def confirm_import(self, *, import_id: str, account: Account) -> Import:
"""
Confirm an import that requires confirmation
"""
self._warnings = []
redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}"
pending_data = redis_client.get(redis_key)
if not pending_data:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="Import information expired or does not exist",
)
try:
if not isinstance(pending_data, str | bytes):
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="Invalid import information",
)
pending_data = PendingData.model_validate_json(pending_data)
if not pending_data.is_accessible_by(
tenant_id=account.current_tenant_id,
account_id=account.id,
):
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="Import information expired or does not exist",
)
if not pending_data.app_id:
# Workspace usage may have changed since the pending import was staged.
self._check_app_quota(account)
data = yaml.safe_load(pending_data.yaml_content)
self._warnings = list(pending_data.warnings)
allow_premium_site_settings = True
if data.get("site") is not None:
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
allow_premium_site_settings = FeatureService.can_import_premium_site_settings(tenant_id)
app = None
if pending_data.app_id:
app = self._load_app_for_overwrite(account, pending_data.app_id)
if not app:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="App not found",
)
if app is not None:
self._validate_workflow_overwrite(app, data)
# Create or update app
app_created = app is None
app = self._create_or_update_app(
app=app,
data=data,
account=account,
name=pending_data.name,
description=pending_data.description,
icon_type=pending_data.icon_type,
icon=pending_data.icon,
icon_background=pending_data.icon_background,
allow_premium_site_settings=allow_premium_site_settings,
draft_hash=pending_data.draft_hash,
preserve_agent_bindings=pending_data.preserve_agent_bindings,
)
# Delete import info from Redis
redis_client.delete(redis_key)
return Import(
id=import_id,
status=self._status_with_warnings(ImportStatus.COMPLETED),
app_id=app.id,
app_created=app_created,
app_mode=app.mode,
current_dsl_version=CURRENT_DSL_VERSION,
imported_dsl_version=data.get("version", "0.1.0"),
warnings=self._warnings,
)
except (NoPermissionError, DraftChangedError, AppQuotaCheckError):
raise
except Exception as e:
logger.exception("Error confirming import")
return Import(
id=import_id,
status=ImportStatus.FAILED,
error=str(e),
)
def check_dependencies(
self,
*,
app_model: App,
) -> CheckDependenciesResult:
"""Check dependencies"""
return self.check_app_dependencies(tenant_id=app_model.tenant_id, app_id=app_model.id)
@staticmethod
def check_app_dependencies(*, tenant_id: str, app_id: str) -> CheckDependenciesResult:
# Get dependencies from Redis
redis_key = f"{CHECK_DEPENDENCIES_REDIS_KEY_PREFIX}{app_id}"
dependencies = redis_client.get(redis_key)
if not dependencies:
return CheckDependenciesResult()
# Extract dependencies
dependencies = CheckDependenciesPendingData.model_validate_json(dependencies)
# Get leaked dependencies
leaked_dependencies = DependenciesAnalysisService.get_leaked_dependencies(
tenant_id=tenant_id, dependencies=dependencies.dependencies
)
return CheckDependenciesResult(
leaked_dependencies=leaked_dependencies,
)
@staticmethod
def cache_import_dependencies(*, app_id: str, dependencies: list[PluginDependency]) -> None:
redis_client.setex(
f"{CHECK_DEPENDENCIES_REDIS_KEY_PREFIX}{app_id}",
IMPORT_INFO_REDIS_EXPIRY,
CheckDependenciesPendingData(app_id=app_id, dependencies=dependencies).model_dump_json(),
)
def _load_app_for_overwrite(self, account: Account, app_id: str) -> App | None:
if account.current_tenant_id is None:
raise ValueError("Current tenant is not set")
if dify_config.RBAC_ENABLED and self._session.in_transaction():
raise RuntimeError("App overwrite authorization requires a session without an active transaction")
rbac_allowed = not dify_config.RBAC_ENABLED or RBACService.CheckAccess.check(
account.current_tenant_id,
account.id,
scene=RBACPermission.APP_IMPORT_EXPORT_DSL,
resource_type=RBACResourceScope.APP,
resource_id=app_id,
)
app = self._session.scalar(
select(App)
.where(
App.id == app_id,
App.tenant_id == account.current_tenant_id,
App.status == "normal",
)
.execution_options(populate_existing=True)
)
if app is not None and not rbac_allowed and app.maintainer != account.id:
raise NoPermissionError("You do not have permission to overwrite this app")
return app
def _ensure_agent_import_permission(self, account: Account, *, app: App | None) -> None:
if not dify_config.RBAC_ENABLED:
return
if account.current_tenant_id is None:
raise ValueError("Current tenant is not set")
binding = (
app.agent_app_binding_with_session(session=self._session, include_archived=True)
if app is not None
else None
)
if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY:
raise NoPermissionError("Agent DSL import permission is required to import an Agent App")
allowed = RBACService.CheckAccess.check(
account.current_tenant_id,
account.id,
scene=RBACPermission.AGENT_IMPORT_EXPORT_DSL,
resource_type=RBACResourceScope.AGENT if binding is not None else None,
resource_id=binding.id if binding is not None else None,
)
if not allowed:
raise NoPermissionError("Agent DSL import permission is required to import an Agent App")
@staticmethod
def _validate_workflow_overwrite(app: App, data: dict[str, Any]) -> None:
"""Apply editor compatibility checks to both YAML and package imports."""
app_mode = data.get("app", {}).get("mode")
if app.mode not in {AppMode.WORKFLOW, AppMode.ADVANCED_CHAT} or app_mode not in {
AppMode.WORKFLOW,
AppMode.ADVANCED_CHAT,
}:
raise ValueError("Only workflow or advanced chat DSLs can overwrite workflow Apps")
# Package uploads cannot run the editor's YAML node checks before import.
invalid_types = (
{BuiltinNodeTypes.END, "trigger-webhook", "trigger-schedule", "trigger-plugin"}
if app.mode == AppMode.ADVANCED_CHAT
else {BuiltinNodeTypes.ANSWER}
)
nodes = data.get("workflow", {}).get("graph", {}).get("nodes", [])
if any(node.get("data", {}).get("type") in invalid_types for node in nodes):
raise ValueError("Workflow contains node types incompatible with the target App")
def _lock_draft_matching(self, *, app: App | None, draft_hash: str) -> Workflow:
"""Lock the draft the caller exported, or raise DraftChangedError when it has moved on.
The row lock holds until commit, so the draft stays the one the caller exported.
"""
if app is None:
raise DraftChangedError()
draft = WorkflowService().get_draft_workflow_for_update(app_model=app, session=self._session)
if draft is None or draft_token(draft) != draft_hash:
raise DraftChangedError()
return draft
def _create_or_update_app(
self,
*,
app: App | None,
data: dict[str, Any],
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
dependencies: list[PluginDependency] | None = None,
import_app_id: str | None = None,
allow_premium_site_settings: bool = True,
preserve_agent_bindings: bool = False,
draft_hash: str | None = None,
) -> App:
"""Create a new app or update an existing one."""
app_data = data.get("app", {})
app_mode = app_data.get("mode")
if not app_mode:
raise ValueError("loss app mode")
app_mode = AppMode(app_mode)
# Check the draft before any write so a refused import leaves the app and its cached dependencies alone.
locked_draft = self._lock_draft_matching(app=app, draft_hash=draft_hash) if draft_hash is not None else None
site_data = SiteDsl.model_validate(data["site"]) if data.get("site") is not None else None
if app_mode == AppMode.AGENT:
self._ensure_agent_import_permission(account, app=app)
target_tenant_id = app.tenant_id if app is not None else account.current_tenant_id
if target_tenant_id is None:
raise ValueError("Current tenant is not set")
# Set icon type
icon_type_value = icon_type or app_data.get("icon_type")
resolved_icon_type: IconType
if icon_type_value in [IconType.EMOJI, IconType.IMAGE, IconType.LINK]:
resolved_icon_type = IconType(icon_type_value)
else:
resolved_icon_type = IconType.EMOJI
icon = icon or str(app_data.get("icon", ""))
if not is_valid_image_icon(
session=self._session,
tenant_id=target_tenant_id,
icon_type=resolved_icon_type,
icon=icon,
):
resolved_icon_type = DEFAULT_ICON_TYPE
icon = DEFAULT_ICON
if app:
# Update existing app
app.name = name or app_data.get("name", app.name)
app.description = description or app_data.get("description", app.description)
app.icon_type = resolved_icon_type
app.icon = icon
app.icon_background = icon_background or app_data.get("icon_background", app.icon_background)
app.updated_by = account.id
app.updated_at = naive_utc_now()
else:
# Create new app
app = App()
app.id = import_app_id or str(uuid4())
app.tenant_id = target_tenant_id
app.mode = app_mode
app.name = name or app_data.get("name", "")
app.description = description or app_data.get("description", "")
app.icon_type = resolved_icon_type
app.icon = icon
app.icon_background = icon_background or app_data.get("icon_background", "#FFFFFF")
app.enable_site = app_mode != AppMode.AGENT
app.enable_api = app_mode != AppMode.AGENT
app.use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False)
app.created_by = account.id
app.maintainer = account.id
app.updated_by = account.id
self._session.add(app)
self._session.flush()
app_was_created.send(app, account=account, session=self._session)
if site_data is not None:
site = app.site_with_session(session=self._session)
if site is None:
raise ValueError("App Site is unavailable")
apply_site_dsl(
site=site,
app=app,
data=site_data,
session=self._session,
allow_premium_settings=allow_premium_site_settings,
)
# save dependencies
if dependencies:
self.cache_import_dependencies(app_id=app.id, dependencies=dependencies)
# Initialize app based on mode
match app_mode:
case AppMode.ADVANCED_CHAT | AppMode.WORKFLOW:
workflow_data = data.get("workflow")
if not workflow_data or not isinstance(workflow_data, dict):
raise ValueError("Missing workflow data for workflow/advanced chat app")
environment_variables_list = workflow_data.get("environment_variables", [])
environment_variables = [
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
]
conversation_variables_list = workflow_data.get("conversation_variables", [])
conversation_variables = [
variable_factory.build_conversation_variable_from_mapping(obj)
for obj in conversation_variables_list
]
workflow_service = WorkflowService()
current_draft_workflow = locked_draft or workflow_service.get_draft_workflow(
app_model=app, session=self._session
)
unique_hash = current_draft_workflow.unique_hash if current_draft_workflow else None
graph = workflow_data.get("graph", {})
if not isinstance(graph, dict):
raise ValueError("Workflow graph must be a mapping")
# The source canvas position should not determine the imported app's initial view.
graph = graph.copy()
graph.pop("viewport", None)
for node in graph.get("nodes", []):
if node.get("data", {}).get("type", "") == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
dataset_ids = node["data"].get("dataset_ids", [])
node["data"]["dataset_ids"] = [
decrypted_id
for dataset_id in dataset_ids
if (
decrypted_id := self.decrypt_dataset_id(
encrypted_data=dataset_id, tenant_id=app.tenant_id
)
)
]
missing_knowledge_fs_spaces = missing_control_space_ids(
session=self._session,
tenant_id=app.tenant_id,
graph=graph,
)
for control_space_id in missing_knowledge_fs_spaces:
self._warnings.append(
DslImportWarning(
code="knowledge_fs_space_unresolved",
path="workflow.graph.nodes",
message=(
"A KnowledgeFS Space reference is unavailable in the target workspace "
"and must be reselected before publishing."
),
details={"control_space_id": control_space_id},
)
)
raw_agent_packages = data.get("agent_packages") or {}
if not isinstance(raw_agent_packages, Mapping):
raise ValueError("agent_packages must be a mapping")
import_agent_bindings = bool(raw_agent_packages) or (
preserve_agent_bindings and is_agent_v2_graph(graph)
)
graph_for_sync = AgentDslService.graph_without_package_bindings(graph) if raw_agent_packages else graph
draft_workflow = workflow_service.sync_draft_workflow(
app_model=app,
graph=graph_for_sync,
features=workflow_data.get("features", {}),
unique_hash=unique_hash,
account=account,
environment_variables=environment_variables,
conversation_variables=conversation_variables,
session=self._session,
commit=not import_agent_bindings,
sync_agent_bindings=not import_agent_bindings,
)
if import_agent_bindings:
retirement_candidates: set[str] = set()
if raw_agent_packages:
_, warnings, retirement_candidates = AgentDslService(self._session).import_workflow_packages(
workflow=draft_workflow,
portable_graph=graph,
raw_packages=raw_agent_packages,
account=account,
)
self._warnings.extend(warnings)
if preserve_agent_bindings:
retirement_candidates.update(
self._sync_copied_workflow_bindings(workflow=draft_workflow, account_id=account.id)
)
WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync(
session=self._session,
draft_workflow=draft_workflow,
)
self._session.commit()
WorkflowAgentRetirementService.retire_unowned(
tenant_id=app.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
case AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION:
# Initialize model config
model_config = data.get("model_config")
if not model_config or not isinstance(model_config, dict):
raise ValueError("Missing model_config for chat/agent-chat/completion app")
SuggestedQuestionsAfterAnswerConfigManager.validate_optional_fields(model_config)
TextToSpeechConfigManager.validate_optional_fields(model_config)
# Initialize or update model config
app_model_config = (
self._session.get(AppModelConfig, app.app_model_config_id) if app.app_model_config_id else None
)
if not app_model_config:
app_model_config = AppModelConfig(
app_id=app.id, created_by=account.id, updated_by=account.id
).from_model_config_dict(cast(AppModelConfigDict, model_config))
app_model_config.id = str(uuid4())
app.app_model_config_id = app_model_config.id
self._session.add(app_model_config)
# Persist the config and app FK before receivers query them in this transaction.
self._session.flush()
app_model_config_was_updated.send(
app,
app_model_config=app_model_config,
session=self._session,
)
case AppMode.AGENT:
if app.app_model_config_with_session(session=self._session) is not None:
raise ValueError("Agent DSL import only supports creating a new Agent App")
agent_data = data.get("agent")
raw_agent_packages = data.get("agent_packages")
if not isinstance(agent_data, Mapping) or not isinstance(raw_agent_packages, Mapping):
raise ValueError("Missing Agent package data")
package_ref = agent_data.get("package_ref")
if not isinstance(package_ref, str) or package_ref not in raw_agent_packages:
raise ValueError("Agent package_ref is missing or invalid")
package = AgentPackage.model_validate(raw_agent_packages[package_ref])
imported = AgentDslService(self._session).import_agent_app_package(
app=app,
account=account,
package=package,
)
self._warnings.extend(imported.warnings)
case _:
raise ValueError("Invalid app mode")
return app
@staticmethod
def _workflow_agent_bindings(*, workflow: Workflow, session: Session) -> list[WorkflowAgentNodeBinding]:
return list(
session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == workflow.tenant_id,
WorkflowAgentNodeBinding.app_id == workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == workflow.id,
WorkflowAgentNodeBinding.workflow_version == workflow.version,
)
)
)
def _sync_copied_workflow_bindings(self, *, workflow: Workflow, account_id: str) -> set[str]:
"""Reuse roster Agents while retaining node-owned inline materialization and complete node jobs."""
retirement_candidates = WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=self._session, draft_workflow=workflow, account_id=account_id
)
graph = workflow.graph_dict
agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph))
for binding in self._workflow_agent_bindings(workflow=workflow, session=self._session):
node_data = agent_nodes.get(binding.node_id)
if not isinstance(node_data, dict):
continue
node_job = node_data.pop(AGENT_NODE_JOB_DSL_KEY, None)
if node_job is not None:
binding.node_job_config = WorkflowNodeJobConfig.model_validate(node_job)
workflow.graph = json.dumps(graph)
return retirement_candidates
@classmethod
def export_dsl(
cls,
app_model: App,
*,
session: Session,
include_secret: bool = False,
workflow_id: str | None = None,
version_id: uuid.UUID | None = None,
) -> str:
"""
Export app
:param app_model: App instance
:param session: Database session used to load export data
:param include_secret: Whether include secret variable
:param workflow_id: Optional published workflow version to export
:param version_id: Optional published Agent version to export
:raises AgentVersionNotFoundError: If the selected Agent version is unavailable or not visible in history
:raises WorkflowNotFoundError: If the selected workflow version does not exist
:raises IsDraftWorkflowError: If the selected workflow is a draft
:return:
"""
return cls.serialize_export_data(
cls.load_export_data(
app_model,
session=session,
include_secret=include_secret,
workflow_id=workflow_id,
version_id=version_id,
)
)
@classmethod
def load_export_data(
cls,
app_model: App,
*,
session: Session,
include_secret: bool = False,
workflow_id: str | None = None,
version_id: uuid.UUID | None = None,
resource_exporter: AgentPackageResourceExporter | None = None,
preserve_agent_bindings: bool = False,
) -> AppDslExportData:
"""Load App and Site data, optionally retaining roster references for duplication."""
app_mode = AppMode.value_of(app_model.mode)
if app_mode == AppMode.AGENT:
package_ref, packages = AgentDslService(session).export_agent_app(app=app_model, version_id=version_id)
dependencies = AgentDslService(session).extract_package_dependencies(packages)
export_data = make_agent_app_dsl(
app_model,
package_ref=package_ref,
packages=packages,
dependencies=[],
).model_dump(mode="json")
else:
export_data = make_app_dsl(app_model)
if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
dependencies = cls._append_workflow_export_data(
export_data=export_data,
app_model=app_model,
include_secret=include_secret,
workflow_id=workflow_id,
session=session,
resource_exporter=resource_exporter,
preserve_agent_bindings=preserve_agent_bindings,
)
else:
dependencies = cls._append_model_config_export_data(export_data, app_model, session=session)
if (site := app_model.site_with_session(session=session)) is not None:
export_data["site"] = SiteDsl.from_site(site).model_dump(mode="json")
else:
export_data.pop("site", None)
return AppDslExportData(app_model.tenant_id, export_data, dependencies)
@staticmethod
def serialize_export_data(prepared: AppDslExportData) -> str:
"""Resolve plugin metadata and serialize materialized data; no Session is required."""
dependencies = DependenciesAnalysisService.generate_dependencies(
tenant_id=prepared.tenant_id, dependencies=prepared.dependency_identifiers
)
export_data = dict(prepared.data)
export_data["dependencies"] = [jsonable_encoder(item.model_dump()) for item in dependencies]
return yaml.dump(export_data, allow_unicode=True)
@staticmethod
def _load_export_workflow(app_model: App, *, workflow_id: str | None, session: Session) -> Workflow:
"""Require the selected published version, or the app draft when no ID is supplied."""
if workflow_id:
workflow = WorkflowService.get_published_workflow_by_id(app_model, workflow_id, session=session)
if workflow is None:
raise WorkflowNotFoundError(f"Workflow version not found. Workflow ID: {workflow_id}.")
else:
workflow = WorkflowService.get_draft_workflow(app_model, session=session)
if workflow is None:
raise WorkflowNotFoundError("Missing draft workflow configuration, please check.")
return workflow
@classmethod
def _append_workflow_export_data(
cls,
*,
export_data: dict[str, Any],
app_model: App,
include_secret: bool,
session: Session,
workflow_id: str | None = None,
resource_exporter: AgentPackageResourceExporter | None = None,
preserve_agent_bindings: bool = False,
) -> list[str]:
"""
Append workflow export data
:param export_data: export data
:param app_model: App instance
:param workflow_id: Optional published workflow version to export
"""
workflow = cls._load_export_workflow(app_model, workflow_id=workflow_id, session=session)
workflow_dict = workflow.to_dict(include_secret=include_secret)
graph = workflow_dict.get("graph", {})
reference_nodes: dict[str, dict[str, Any]] = {}
if preserve_agent_bindings:
graph = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=session,
draft_workflow=workflow,
binding_version=workflow.version,
)
agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph))
for binding in cls._workflow_agent_bindings(workflow=workflow, session=session):
node_data = agent_nodes.get(binding.node_id)
if binding.binding_type != WorkflowAgentBindingType.ROSTER_AGENT or not isinstance(node_data, dict):
continue
if not binding.agent_id or not binding.current_snapshot_id:
raise ValueError(f"Workflow Agent node {binding.node_id} has no complete persisted binding.")
node_data[AGENT_NODE_JOB_DSL_KEY] = WorkflowNodeJobConfig.model_validate(
binding.node_job_config_dict
).model_dump(mode="json")
reference_nodes[binding.node_id] = node_data
# Roster Agents are external references of the copied App, so only its inline Agents need packages.
graph_for_packages = (
{**graph, "nodes": [node for node in graph.get("nodes", []) if node["id"] not in reference_nodes]}
if reference_nodes
else graph
)
graph, agent_packages = AgentDslService(session).export_workflow_packages(
workflow=workflow,
graph=graph_for_packages,
resource_exporter=resource_exporter,
)
if reference_nodes:
packaged_nodes = {node["id"]: node for node in graph["nodes"]}
graph["nodes"] = [
{**node, "data": reference_nodes[node["id"]]}
if node["id"] in reference_nodes
else packaged_nodes[node["id"]]
for node in workflow.graph_dict.get("nodes", [])
]
workflow_dict["graph"] = graph
# TODO: refactor: we need a better way to filter workspace related data from nodes
for node in workflow_dict.get("graph", {}).get("nodes", []):
node_data = node.get("data", {})
if not node_data:
continue
data_type = node_data.get("type", "")
if data_type == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
dataset_ids = node_data.get("dataset_ids", [])
node_data["dataset_ids"] = [
cls.encrypt_dataset_id(dataset_id=dataset_id, tenant_id=app_model.tenant_id)
for dataset_id in dataset_ids
]
# filter credential id from tool node
if not include_secret and data_type == BuiltinNodeTypes.TOOL:
node_data.pop("credential_id", None)
# filter credential id from agent node
if not include_secret and data_type == BuiltinNodeTypes.AGENT:
for tool in node_data.get("agent_parameters", {}).get("tools", {}).get("value", []):
tool.pop("credential_id", None)
if data_type == TRIGGER_SCHEDULE_NODE_TYPE:
# override the config with the default config
node_data["config"] = TriggerScheduleNode.get_default_config()["config"]
if data_type == TRIGGER_WEBHOOK_NODE_TYPE:
# clear the webhook_url
node_data["webhook_url"] = ""
node_data["webhook_debug_url"] = ""
if data_type == TRIGGER_PLUGIN_NODE_TYPE:
# clear the subscription_id
node_data["subscription_id"] = ""
export_data["workflow"] = workflow_dict
dependencies = cls._extract_dependencies_from_workflow(workflow)
dependencies.extend(AgentDslService(session).extract_package_dependencies(agent_packages))
if agent_packages:
export_data["agent_packages"] = {
key: package.model_dump(mode="json") for key, package in agent_packages.items()
}
return dependencies
def _status_with_warnings(self, status: ImportStatus) -> ImportStatus:
if status == ImportStatus.COMPLETED and self._warnings:
return ImportStatus.COMPLETED_WITH_WARNINGS
return status
@classmethod
def _append_model_config_export_data(
cls, export_data: dict[str, Any], app_model: App, *, session: Session
) -> list[str]:
"""
Append model config export data
:param export_data: export data
:param app_model: App instance
:param session: Database session used to load the model config and annotation reply
"""
app_model_config = (
session.get(AppModelConfig, app_model.app_model_config_id) if app_model.app_model_config_id else None
)
if not app_model_config:
raise ValueError("Missing app configuration, please check.")
annotation_reply = load_annotation_reply_config(session, app_model_config.app_id)
model_config = app_model_config.to_dict(annotation_reply=annotation_reply)
# TODO: refactor: we need a better way to filter workspace related data from model config
# filter credential id from model config
for tool in model_config.get("agent_mode", {}).get("tools", []):
tool.pop("credential_id", None)
export_data["model_config"] = model_config
dependencies = cls._extract_dependencies_from_model_config(model_config)
return dependencies
@classmethod
def _extract_dependencies_from_workflow(cls, workflow: Workflow) -> list[str]:
"""
Extract dependencies from workflow
:param workflow: Workflow instance
:return: dependencies list format like ["langgenius/google"]
"""
graph = workflow.graph_dict
referenced_llm_nodes = [
node.get("data", {})
for node in graph.get("nodes", [])
if node.get("data", {}).get("type") == BuiltinNodeTypes.LLM
and should_resolve_llm_model_selector(node.get("data", {}).get("model_selector"))
]
environment_variables = (
{variable.name: variable for variable in workflow.environment_variables} if referenced_llm_nodes else {}
)
for node_data in referenced_llm_nodes:
try:
selector = parse_llm_model_selector(node_data["model_selector"])
variable = environment_variables.get(selector[1])
if not isinstance(variable, LLMEnvironmentVariable):
raise ValueError(
f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable"
)
node_data["model"] = resolve_llm_model_config(
node_model=ModelConfig.model_validate(node_data.get("model", {})),
variable_name=selector[1],
variable_value=variable.value,
).model_dump(mode="json")
except ValueError as exc:
logger.warning(
"Skipping unresolved LLM environment model while extracting dependencies for selector %r: %s",
node_data.get("model_selector"),
exc,
)
dependencies = cls._extract_dependencies_from_workflow_graph(graph)
return dependencies
@classmethod
def _extract_dependencies_from_workflow_graph(cls, graph: Mapping) -> list[str]:
"""
Extract dependencies from workflow graph
:param graph: Workflow graph
:return: dependencies list format like ["langgenius/google"]
"""
dependencies = []
for node in graph.get("nodes", []):
try:
node_data = node.get("data", {})
dependencies.extend(DependenciesAnalysisService.extract_external_node_dependencies(node_data))
typ = node_data.get("type")
match typ:
case BuiltinNodeTypes.TOOL:
tool_entity = ToolNodeData.model_validate(node["data"])
if tool_entity.provider_type not in PLUGIN_TOOL_PROVIDER_TYPES:
continue
dependencies.append(
node_data.get("plugin_id")
or DependenciesAnalysisService.analyze_tool_provider_reference(tool_entity.provider_id),
)
case BuiltinNodeTypes.LLM:
llm_entity = LLMNodeData.model_validate(node["data"])
dependencies.append(
DependenciesAnalysisService.analyze_model_provider_dependency(llm_entity.model.provider),
)
case BuiltinNodeTypes.QUESTION_CLASSIFIER:
question_classifier_entity = QuestionClassifierNodeData.model_validate(node["data"])
dependencies.append(
DependenciesAnalysisService.analyze_model_provider_dependency(
question_classifier_entity.model.provider
),
)
case BuiltinNodeTypes.PARAMETER_EXTRACTOR:
parameter_extractor_entity = ParameterExtractorNodeData.model_validate(node["data"])
dependencies.append(
DependenciesAnalysisService.analyze_model_provider_dependency(
parameter_extractor_entity.model.provider
),
)
case BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
knowledge_retrieval_entity = KnowledgeRetrievalNodeData.model_validate(node["data"])
if knowledge_retrieval_entity.retrieval_mode == "multiple":
if knowledge_retrieval_entity.multiple_retrieval_config:
if (
knowledge_retrieval_entity.multiple_retrieval_config.reranking_mode
== "reranking_model"
):
if knowledge_retrieval_entity.multiple_retrieval_config.reranking_model:
dependencies.append(
DependenciesAnalysisService.analyze_model_provider_dependency(
knowledge_retrieval_entity.multiple_retrieval_config.reranking_model.provider
),
)
elif (
knowledge_retrieval_entity.multiple_retrieval_config.reranking_mode
== "weighted_score"
):
if knowledge_retrieval_entity.multiple_retrieval_config.weights:
vector_setting = (
knowledge_retrieval_entity.multiple_retrieval_config.weights.vector_setting
)
dependencies.append(
DependenciesAnalysisService.analyze_model_provider_dependency(
vector_setting.embedding_provider_name
),
)
elif knowledge_retrieval_entity.retrieval_mode == "single":
model_config = knowledge_retrieval_entity.single_retrieval_config
if model_config:
dependencies.append(
DependenciesAnalysisService.analyze_model_provider_dependency(
model_config.model.provider
),
)
case _:
# TODO: Handle default case or unknown node types
pass
except Exception as e:
logger.exception("Error extracting node dependency", exc_info=e)
return dependencies
@classmethod
def _extract_dependencies_from_model_config(cls, model_config: Mapping) -> list[str]:
"""
Extract dependencies from model config
:param model_config: model config dict
:return: dependencies list format like ["langgenius/google"]
"""
dependencies = []
try:
# completion model
model_dict = model_config.get("model", {})
if model_dict:
dependencies.append(
DependenciesAnalysisService.analyze_model_provider_dependency(model_dict.get("provider", ""))
)
# reranking model
dataset_configs = model_config.get("dataset_configs", {})
if dataset_configs:
for dataset_config in dataset_configs.get("datasets", {}).get("datasets", []):
if dataset_config.get("reranking_model"):
dependencies.append(
DependenciesAnalysisService.analyze_model_provider_dependency(
dataset_config.get("reranking_model", {})
.get("reranking_provider_name", {})
.get("provider")
)
)
# tools
agent_configs = model_config.get("agent_mode", {})
if agent_configs:
for agent_config in agent_configs.get("tools", []):
if agent_config.get("provider_type") not in PLUGIN_TOOL_PROVIDER_TYPES:
continue
dependencies.append(
DependenciesAnalysisService.analyze_tool_dependency(agent_config.get("provider_id"))
)
except Exception as e:
logger.exception("Error extracting model config dependency", exc_info=e)
return dependencies
@classmethod
def get_leaked_dependencies(
cls, tenant_id: str, dsl_dependencies: list[PluginDependency]
) -> list[PluginDependency]:
"""
Returns the leaked dependencies in current workspace
"""
if not dsl_dependencies:
return []
return DependenciesAnalysisService.get_leaked_dependencies(tenant_id=tenant_id, dependencies=dsl_dependencies)
@staticmethod
def _generate_aes_key(tenant_id: str) -> bytes:
"""Generate AES key based on tenant_id"""
return hashlib.sha256(tenant_id.encode()).digest()
@classmethod
def encrypt_dataset_id(cls, dataset_id: str, tenant_id: str) -> str:
"""Encrypt dataset_id using AES-CBC mode or return plain text based on configuration"""
if not dify_config.DSL_EXPORT_ENCRYPT_DATASET_ID:
return dataset_id
key = cls._generate_aes_key(tenant_id)
iv = key[:16]
cipher = AES.new(key, AES.MODE_CBC, iv)
ct_bytes = cipher.encrypt(pad(dataset_id.encode(), AES.block_size))
return base64.b64encode(ct_bytes).decode()
@classmethod
def decrypt_dataset_id(cls, encrypted_data: str, tenant_id: str) -> str | None:
"""AES decryption with fallback to plain text UUID"""
# First, check if it's already a plain UUID (not encrypted)
if cls._is_valid_uuid(encrypted_data):
return encrypted_data
# If it's not a UUID, try to decrypt it
try:
key = cls._generate_aes_key(tenant_id)
iv = key[:16]
cipher = AES.new(key, AES.MODE_CBC, iv)
pt = unpad(cipher.decrypt(base64.b64decode(encrypted_data)), AES.block_size)
decrypted_text = pt.decode()
# Validate that the decrypted result is a valid UUID
if cls._is_valid_uuid(decrypted_text):
return decrypted_text
else:
# If decrypted result is not a valid UUID, it's probably not our encrypted data
return None
except Exception:
# If decryption fails completely, return None
return None
@staticmethod
def _is_valid_uuid(value: str) -> bool:
"""Check if string is a valid UUID format"""
try:
uuid.UUID(value)
return True
except (ValueError, TypeError):
return False