You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

workflow_service.py 42KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058
  1. import json
  2. import time
  3. import uuid
  4. from collections.abc import Callable, Generator, Mapping, Sequence
  5. from typing import Any, cast
  6. from sqlalchemy import exists, select
  7. from sqlalchemy.orm import Session, sessionmaker
  8. from core.app.app_config.entities import VariableEntityType
  9. from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
  10. from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
  11. from core.file import File
  12. from core.repositories import DifyCoreRepositoryFactory
  13. from core.variables import Variable
  14. from core.variables.variables import VariableUnion
  15. from core.workflow.entities import VariablePool, WorkflowNodeExecution
  16. from core.workflow.enums import ErrorStrategy, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
  17. from core.workflow.errors import WorkflowNodeRunFailedError
  18. from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent, NodeRunSucceededEvent
  19. from core.workflow.node_events import NodeRunResult
  20. from core.workflow.nodes import NodeType
  21. from core.workflow.nodes.base.node import Node
  22. from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
  23. from core.workflow.nodes.start.entities import StartNodeData
  24. from core.workflow.system_variable import SystemVariable
  25. from core.workflow.workflow_entry import WorkflowEntry
  26. from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated
  27. from extensions.ext_database import db
  28. from extensions.ext_storage import storage
  29. from factories.file_factory import build_from_mapping, build_from_mappings
  30. from libs.datetime_utils import naive_utc_now
  31. from models.account import Account
  32. from models.model import App, AppMode
  33. from models.tools import WorkflowToolProvider
  34. from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowType
  35. from repositories.factory import DifyAPIRepositoryFactory
  36. from services.enterprise.plugin_manager_service import PluginCredentialType
  37. from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError
  38. from services.workflow.workflow_converter import WorkflowConverter
  39. from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
  40. from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService
  41. class WorkflowService:
  42. """
  43. Workflow Service
  44. """
  45. def __init__(self, session_maker: sessionmaker | None = None):
  46. """Initialize WorkflowService with repository dependencies."""
  47. if session_maker is None:
  48. session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
  49. self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
  50. session_maker
  51. )
  52. def get_node_last_run(self, app_model: App, workflow: Workflow, node_id: str) -> WorkflowNodeExecutionModel | None:
  53. """
  54. Get the most recent execution for a specific node.
  55. Args:
  56. app_model: The application model
  57. workflow: The workflow model
  58. node_id: The node identifier
  59. Returns:
  60. The most recent WorkflowNodeExecutionModel for the node, or None if not found
  61. """
  62. return self._node_execution_service_repo.get_node_last_execution(
  63. tenant_id=app_model.tenant_id,
  64. app_id=app_model.id,
  65. workflow_id=workflow.id,
  66. node_id=node_id,
  67. )
  68. def is_workflow_exist(self, app_model: App) -> bool:
  69. stmt = select(
  70. exists().where(
  71. Workflow.tenant_id == app_model.tenant_id,
  72. Workflow.app_id == app_model.id,
  73. Workflow.version == Workflow.VERSION_DRAFT,
  74. )
  75. )
  76. return db.session.execute(stmt).scalar_one()
  77. def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
  78. """
  79. Get draft workflow
  80. """
  81. if workflow_id:
  82. return self.get_published_workflow_by_id(app_model, workflow_id)
  83. # fetch draft workflow by app_model
  84. workflow = (
  85. db.session.query(Workflow)
  86. .where(
  87. Workflow.tenant_id == app_model.tenant_id,
  88. Workflow.app_id == app_model.id,
  89. Workflow.version == Workflow.VERSION_DRAFT,
  90. )
  91. .first()
  92. )
  93. # return draft workflow
  94. return workflow
  95. def get_published_workflow_by_id(self, app_model: App, workflow_id: str) -> Workflow | None:
  96. """
  97. fetch published workflow by workflow_id
  98. """
  99. workflow = (
  100. db.session.query(Workflow)
  101. .where(
  102. Workflow.tenant_id == app_model.tenant_id,
  103. Workflow.app_id == app_model.id,
  104. Workflow.id == workflow_id,
  105. )
  106. .first()
  107. )
  108. if not workflow:
  109. return None
  110. if workflow.version == Workflow.VERSION_DRAFT:
  111. raise IsDraftWorkflowError(
  112. f"Cannot use draft workflow version. Workflow ID: {workflow_id}. "
  113. f"Please use a published workflow version or leave workflow_id empty."
  114. )
  115. return workflow
  116. def get_published_workflow(self, app_model: App) -> Workflow | None:
  117. """
  118. Get published workflow
  119. """
  120. if not app_model.workflow_id:
  121. return None
  122. # fetch published workflow by workflow_id
  123. workflow = (
  124. db.session.query(Workflow)
  125. .where(
  126. Workflow.tenant_id == app_model.tenant_id,
  127. Workflow.app_id == app_model.id,
  128. Workflow.id == app_model.workflow_id,
  129. )
  130. .first()
  131. )
  132. return workflow
  133. def get_all_published_workflow(
  134. self,
  135. *,
  136. session: Session,
  137. app_model: App,
  138. page: int,
  139. limit: int,
  140. user_id: str | None,
  141. named_only: bool = False,
  142. ) -> tuple[Sequence[Workflow], bool]:
  143. """
  144. Get published workflow with pagination
  145. """
  146. if not app_model.workflow_id:
  147. return [], False
  148. stmt = (
  149. select(Workflow)
  150. .where(Workflow.app_id == app_model.id)
  151. .order_by(Workflow.version.desc())
  152. .limit(limit + 1)
  153. .offset((page - 1) * limit)
  154. )
  155. if user_id:
  156. stmt = stmt.where(Workflow.created_by == user_id)
  157. if named_only:
  158. stmt = stmt.where(Workflow.marked_name != "")
  159. workflows = session.scalars(stmt).all()
  160. has_more = len(workflows) > limit
  161. if has_more:
  162. workflows = workflows[:-1]
  163. return workflows, has_more
  164. def sync_draft_workflow(
  165. self,
  166. *,
  167. app_model: App,
  168. graph: dict,
  169. features: dict,
  170. unique_hash: str | None,
  171. account: Account,
  172. environment_variables: Sequence[Variable],
  173. conversation_variables: Sequence[Variable],
  174. ) -> Workflow:
  175. """
  176. Sync draft workflow
  177. :raises WorkflowHashNotEqualError
  178. """
  179. # fetch draft workflow by app_model
  180. workflow = self.get_draft_workflow(app_model=app_model)
  181. if workflow and workflow.unique_hash != unique_hash:
  182. raise WorkflowHashNotEqualError()
  183. # validate features structure
  184. self.validate_features_structure(app_model=app_model, features=features)
  185. # create draft workflow if not found
  186. if not workflow:
  187. workflow = Workflow(
  188. tenant_id=app_model.tenant_id,
  189. app_id=app_model.id,
  190. type=WorkflowType.from_app_mode(app_model.mode).value,
  191. version=Workflow.VERSION_DRAFT,
  192. graph=json.dumps(graph),
  193. features=json.dumps(features),
  194. created_by=account.id,
  195. environment_variables=environment_variables,
  196. conversation_variables=conversation_variables,
  197. )
  198. db.session.add(workflow)
  199. # update draft workflow if found
  200. else:
  201. workflow.graph = json.dumps(graph)
  202. workflow.features = json.dumps(features)
  203. workflow.updated_by = account.id
  204. workflow.updated_at = naive_utc_now()
  205. workflow.environment_variables = environment_variables
  206. workflow.conversation_variables = conversation_variables
  207. # commit db session changes
  208. db.session.commit()
  209. # trigger app workflow events
  210. app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=workflow)
  211. # return draft workflow
  212. return workflow
  213. def publish_workflow(
  214. self,
  215. *,
  216. session: Session,
  217. app_model: App,
  218. account: Account,
  219. marked_name: str = "",
  220. marked_comment: str = "",
  221. ) -> Workflow:
  222. draft_workflow_stmt = select(Workflow).where(
  223. Workflow.tenant_id == app_model.tenant_id,
  224. Workflow.app_id == app_model.id,
  225. Workflow.version == Workflow.VERSION_DRAFT,
  226. )
  227. draft_workflow = session.scalar(draft_workflow_stmt)
  228. if not draft_workflow:
  229. raise ValueError("No valid workflow found.")
  230. # Validate credentials before publishing, for credential policy check
  231. from services.feature_service import FeatureService
  232. if FeatureService.get_system_features().plugin_manager.enabled:
  233. self._validate_workflow_credentials(draft_workflow)
  234. # create new workflow
  235. workflow = Workflow.new(
  236. tenant_id=app_model.tenant_id,
  237. app_id=app_model.id,
  238. type=draft_workflow.type,
  239. version=Workflow.version_from_datetime(naive_utc_now()),
  240. graph=draft_workflow.graph,
  241. created_by=account.id,
  242. environment_variables=draft_workflow.environment_variables,
  243. conversation_variables=draft_workflow.conversation_variables,
  244. marked_name=marked_name,
  245. marked_comment=marked_comment,
  246. rag_pipeline_variables=draft_workflow.rag_pipeline_variables,
  247. features=draft_workflow.features,
  248. )
  249. # commit db session changes
  250. session.add(workflow)
  251. # trigger app workflow events
  252. app_published_workflow_was_updated.send(app_model, published_workflow=workflow)
  253. # return new workflow
  254. return workflow
  255. def _validate_workflow_credentials(self, workflow: Workflow) -> None:
  256. """
  257. Validate all credentials in workflow nodes before publishing.
  258. :param workflow: The workflow to validate
  259. :raises ValueError: If any credentials violate policy compliance
  260. """
  261. graph_dict = workflow.graph_dict
  262. nodes = graph_dict.get("nodes", [])
  263. for node in nodes:
  264. node_data = node.get("data", {})
  265. node_type = node_data.get("type")
  266. node_id = node.get("id", "unknown")
  267. try:
  268. # Extract and validate credentials based on node type
  269. if node_type == "tool":
  270. credential_id = node_data.get("credential_id")
  271. provider = node_data.get("provider_id")
  272. if provider:
  273. if credential_id:
  274. # Check specific credential
  275. from core.helper.credential_utils import check_credential_policy_compliance
  276. check_credential_policy_compliance(
  277. credential_id=credential_id,
  278. provider=provider,
  279. credential_type=PluginCredentialType.TOOL,
  280. )
  281. else:
  282. # Check default workspace credential for this provider
  283. self._check_default_tool_credential(workflow.tenant_id, provider)
  284. elif node_type == "agent":
  285. agent_params = node_data.get("agent_parameters", {})
  286. model_config = agent_params.get("model", {}).get("value", {})
  287. if model_config.get("provider") and model_config.get("model"):
  288. self._validate_llm_model_config(
  289. workflow.tenant_id, model_config["provider"], model_config["model"]
  290. )
  291. # Validate load balancing credentials for agent model if load balancing is enabled
  292. agent_model_node_data = {"model": model_config}
  293. self._validate_load_balancing_credentials(workflow, agent_model_node_data, node_id)
  294. # Validate agent tools
  295. tools = agent_params.get("tools", {}).get("value", [])
  296. for tool in tools:
  297. # Agent tools store provider in provider_name field
  298. provider = tool.get("provider_name")
  299. credential_id = tool.get("credential_id")
  300. if provider:
  301. if credential_id:
  302. from core.helper.credential_utils import check_credential_policy_compliance
  303. check_credential_policy_compliance(credential_id, provider, PluginCredentialType.TOOL)
  304. else:
  305. self._check_default_tool_credential(workflow.tenant_id, provider)
  306. elif node_type in ["llm", "knowledge_retrieval", "parameter_extractor", "question_classifier"]:
  307. model_config = node_data.get("model", {})
  308. provider = model_config.get("provider")
  309. model_name = model_config.get("name")
  310. if provider and model_name:
  311. # Validate that the provider+model combination can fetch valid credentials
  312. self._validate_llm_model_config(workflow.tenant_id, provider, model_name)
  313. # Validate load balancing credentials if load balancing is enabled
  314. self._validate_load_balancing_credentials(workflow, node_data, node_id)
  315. else:
  316. raise ValueError(f"Node {node_id} ({node_type}): Missing provider or model configuration")
  317. except Exception as e:
  318. if isinstance(e, ValueError):
  319. raise e
  320. else:
  321. raise ValueError(f"Node {node_id} ({node_type}): {str(e)}")
  322. def _validate_llm_model_config(self, tenant_id: str, provider: str, model_name: str) -> None:
  323. """
  324. Validate that an LLM model configuration can fetch valid credentials and has active status.
  325. This method attempts to get the model instance and validates that:
  326. 1. The provider exists and is configured
  327. 2. The model exists in the provider
  328. 3. Credentials can be fetched for the model
  329. 4. The credentials pass policy compliance checks
  330. 5. The model status is ACTIVE (not NO_CONFIGURE, DISABLED, etc.)
  331. :param tenant_id: The tenant ID
  332. :param provider: The provider name
  333. :param model_name: The model name
  334. :raises ValueError: If the model configuration is invalid or credentials fail policy checks
  335. """
  336. try:
  337. from core.model_manager import ModelManager
  338. from core.model_runtime.entities.model_entities import ModelType
  339. from core.provider_manager import ProviderManager
  340. # Get model instance to validate provider+model combination
  341. model_manager = ModelManager()
  342. model_manager.get_model_instance(
  343. tenant_id=tenant_id, provider=provider, model_type=ModelType.LLM, model=model_name
  344. )
  345. # The ModelInstance constructor will automatically check credential policy compliance
  346. # via ProviderConfiguration.get_current_credentials() -> _check_credential_policy_compliance()
  347. # If it fails, an exception will be raised
  348. # Additionally, check the model status to ensure it's ACTIVE
  349. provider_manager = ProviderManager()
  350. provider_configurations = provider_manager.get_configurations(tenant_id)
  351. models = provider_configurations.get_models(provider=provider, model_type=ModelType.LLM)
  352. target_model = None
  353. for model in models:
  354. if model.model == model_name and model.provider.provider == provider:
  355. target_model = model
  356. break
  357. if target_model:
  358. target_model.raise_for_status()
  359. else:
  360. raise ValueError(f"Model {model_name} not found for provider {provider}")
  361. except Exception as e:
  362. raise ValueError(
  363. f"Failed to validate LLM model configuration (provider: {provider}, model: {model_name}): {str(e)}"
  364. )
  365. def _check_default_tool_credential(self, tenant_id: str, provider: str) -> None:
  366. """
  367. Check credential policy compliance for the default workspace credential of a tool provider.
  368. This method finds the default credential for the given provider and validates it.
  369. Uses the same fallback logic as runtime to handle deauthorized credentials.
  370. :param tenant_id: The tenant ID
  371. :param provider: The tool provider name
  372. :raises ValueError: If no default credential exists or if it fails policy compliance
  373. """
  374. try:
  375. from models.tools import BuiltinToolProvider
  376. # Use the same fallback logic as runtime: get the first available credential
  377. # ordered by is_default DESC, created_at ASC (same as tool_manager.py)
  378. default_provider = (
  379. db.session.query(BuiltinToolProvider)
  380. .where(
  381. BuiltinToolProvider.tenant_id == tenant_id,
  382. BuiltinToolProvider.provider == provider,
  383. )
  384. .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc())
  385. .first()
  386. )
  387. if not default_provider:
  388. raise ValueError("No default credential found")
  389. # Check credential policy compliance using the default credential ID
  390. from core.helper.credential_utils import check_credential_policy_compliance
  391. check_credential_policy_compliance(
  392. credential_id=default_provider.id,
  393. provider=provider,
  394. credential_type=PluginCredentialType.TOOL,
  395. check_existence=False,
  396. )
  397. except Exception as e:
  398. raise ValueError(f"Failed to validate default credential for tool provider {provider}: {str(e)}")
  399. def _validate_load_balancing_credentials(self, workflow: Workflow, node_data: dict, node_id: str) -> None:
  400. """
  401. Validate load balancing credentials for a workflow node.
  402. :param workflow: The workflow being validated
  403. :param node_data: The node data containing model configuration
  404. :param node_id: The node ID for error reporting
  405. :raises ValueError: If load balancing credentials violate policy compliance
  406. """
  407. # Extract model configuration
  408. model_config = node_data.get("model", {})
  409. provider = model_config.get("provider")
  410. model_name = model_config.get("name")
  411. if not provider or not model_name:
  412. return # No model config to validate
  413. # Check if this model has load balancing enabled
  414. if self._is_load_balancing_enabled(workflow.tenant_id, provider, model_name):
  415. # Get all load balancing configurations for this model
  416. load_balancing_configs = self._get_load_balancing_configs(workflow.tenant_id, provider, model_name)
  417. # Validate each load balancing configuration
  418. try:
  419. for config in load_balancing_configs:
  420. if config.get("credential_id"):
  421. from core.helper.credential_utils import check_credential_policy_compliance
  422. check_credential_policy_compliance(
  423. config["credential_id"], provider, PluginCredentialType.MODEL
  424. )
  425. except Exception as e:
  426. raise ValueError(f"Invalid load balancing credentials for {provider}/{model_name}: {str(e)}")
  427. def _is_load_balancing_enabled(self, tenant_id: str, provider: str, model_name: str) -> bool:
  428. """
  429. Check if load balancing is enabled for a specific model.
  430. :param tenant_id: The tenant ID
  431. :param provider: The provider name
  432. :param model_name: The model name
  433. :return: True if load balancing is enabled, False otherwise
  434. """
  435. try:
  436. from core.model_runtime.entities.model_entities import ModelType
  437. from core.provider_manager import ProviderManager
  438. # Get provider configurations
  439. provider_manager = ProviderManager()
  440. provider_configurations = provider_manager.get_configurations(tenant_id)
  441. provider_configuration = provider_configurations.get(provider)
  442. if not provider_configuration:
  443. return False
  444. # Get provider model setting
  445. provider_model_setting = provider_configuration.get_provider_model_setting(
  446. model_type=ModelType.LLM,
  447. model=model_name,
  448. )
  449. return provider_model_setting is not None and provider_model_setting.load_balancing_enabled
  450. except Exception:
  451. # If we can't determine the status, assume load balancing is not enabled
  452. return False
  453. def _get_load_balancing_configs(self, tenant_id: str, provider: str, model_name: str) -> list[dict]:
  454. """
  455. Get all load balancing configurations for a model.
  456. :param tenant_id: The tenant ID
  457. :param provider: The provider name
  458. :param model_name: The model name
  459. :return: List of load balancing configuration dictionaries
  460. """
  461. try:
  462. from services.model_load_balancing_service import ModelLoadBalancingService
  463. model_load_balancing_service = ModelLoadBalancingService()
  464. _, configs = model_load_balancing_service.get_load_balancing_configs(
  465. tenant_id=tenant_id,
  466. provider=provider,
  467. model=model_name,
  468. model_type="llm", # Load balancing is primarily used for LLM models
  469. config_from="predefined-model", # Check both predefined and custom models
  470. )
  471. _, custom_configs = model_load_balancing_service.get_load_balancing_configs(
  472. tenant_id=tenant_id, provider=provider, model=model_name, model_type="llm", config_from="custom-model"
  473. )
  474. all_configs = configs + custom_configs
  475. return [config for config in all_configs if config.get("credential_id")]
  476. except Exception:
  477. # If we can't get the configurations, return empty list
  478. # This will prevent validation errors from breaking the workflow
  479. return []
  480. def get_default_block_configs(self) -> Sequence[Mapping[str, object]]:
  481. """
  482. Get default block configs
  483. """
  484. # return default block config
  485. default_block_configs: list[Mapping[str, object]] = []
  486. for node_class_mapping in NODE_TYPE_CLASSES_MAPPING.values():
  487. node_class = node_class_mapping[LATEST_VERSION]
  488. default_config = node_class.get_default_config()
  489. if default_config:
  490. default_block_configs.append(default_config)
  491. return default_block_configs
  492. def get_default_block_config(
  493. self, node_type: str, filters: Mapping[str, object] | None = None
  494. ) -> Mapping[str, object]:
  495. """
  496. Get default config of node.
  497. :param node_type: node type
  498. :param filters: filter by node config parameters.
  499. :return:
  500. """
  501. node_type_enum = NodeType(node_type)
  502. # return default block config
  503. if node_type_enum not in NODE_TYPE_CLASSES_MAPPING:
  504. return {}
  505. node_class = NODE_TYPE_CLASSES_MAPPING[node_type_enum][LATEST_VERSION]
  506. default_config = node_class.get_default_config(filters=filters)
  507. if not default_config:
  508. return {}
  509. return default_config
  510. def run_draft_workflow_node(
  511. self,
  512. app_model: App,
  513. draft_workflow: Workflow,
  514. node_id: str,
  515. user_inputs: Mapping[str, Any],
  516. account: Account,
  517. query: str = "",
  518. files: Sequence[File] | None = None,
  519. ) -> WorkflowNodeExecutionModel:
  520. """
  521. Run draft workflow node
  522. """
  523. files = files or []
  524. with Session(bind=db.engine, expire_on_commit=False) as session, session.begin():
  525. draft_var_srv = WorkflowDraftVariableService(session)
  526. draft_var_srv.prefill_conversation_variable_default_values(draft_workflow)
  527. node_config = draft_workflow.get_node_config_by_id(node_id)
  528. node_type = Workflow.get_node_type_from_node_config(node_config)
  529. node_data = node_config.get("data", {})
  530. if node_type == NodeType.START:
  531. with Session(bind=db.engine) as session, session.begin():
  532. draft_var_srv = WorkflowDraftVariableService(session)
  533. conversation_id = draft_var_srv.get_or_create_conversation(
  534. account_id=account.id,
  535. app=app_model,
  536. workflow=draft_workflow,
  537. )
  538. start_data = StartNodeData.model_validate(node_data)
  539. user_inputs = _rebuild_file_for_user_inputs_in_start_node(
  540. tenant_id=draft_workflow.tenant_id, start_node_data=start_data, user_inputs=user_inputs
  541. )
  542. # init variable pool
  543. variable_pool = _setup_variable_pool(
  544. query=query,
  545. files=files or [],
  546. user_id=account.id,
  547. user_inputs=user_inputs,
  548. workflow=draft_workflow,
  549. # NOTE(QuantumGhost): We rely on `DraftVarLoader` to load conversation variables.
  550. conversation_variables=[],
  551. node_type=node_type,
  552. conversation_id=conversation_id,
  553. )
  554. else:
  555. variable_pool = VariablePool(
  556. system_variables=SystemVariable.empty(),
  557. user_inputs=user_inputs,
  558. environment_variables=draft_workflow.environment_variables,
  559. conversation_variables=[],
  560. )
  561. variable_loader = DraftVarLoader(
  562. engine=db.engine,
  563. app_id=app_model.id,
  564. tenant_id=app_model.tenant_id,
  565. )
  566. enclosing_node_type_and_id = draft_workflow.get_enclosing_node_type_and_id(node_config)
  567. if enclosing_node_type_and_id:
  568. _, enclosing_node_id = enclosing_node_type_and_id
  569. else:
  570. enclosing_node_id = None
  571. run = WorkflowEntry.single_step_run(
  572. workflow=draft_workflow,
  573. node_id=node_id,
  574. user_inputs=user_inputs,
  575. user_id=account.id,
  576. variable_pool=variable_pool,
  577. variable_loader=variable_loader,
  578. )
  579. # run draft workflow node
  580. start_at = time.perf_counter()
  581. node_execution = self._handle_single_step_result(
  582. invoke_node_fn=lambda: run,
  583. start_at=start_at,
  584. node_id=node_id,
  585. )
  586. # Set workflow_id on the NodeExecution
  587. node_execution.workflow_id = draft_workflow.id
  588. # Create repository and save the node execution
  589. repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
  590. session_factory=db.engine,
  591. user=account,
  592. app_id=app_model.id,
  593. triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP,
  594. )
  595. repository.save(node_execution)
  596. workflow_node_execution = self._node_execution_service_repo.get_execution_by_id(node_execution.id)
  597. if workflow_node_execution is None:
  598. raise ValueError(f"WorkflowNodeExecution with id {node_execution.id} not found after saving")
  599. with Session(db.engine) as session:
  600. outputs = workflow_node_execution.load_full_outputs(session, storage)
  601. with Session(bind=db.engine) as session, session.begin():
  602. draft_var_saver = DraftVariableSaver(
  603. session=session,
  604. app_id=app_model.id,
  605. node_id=workflow_node_execution.node_id,
  606. node_type=NodeType(workflow_node_execution.node_type),
  607. enclosing_node_id=enclosing_node_id,
  608. node_execution_id=node_execution.id,
  609. user=account,
  610. )
  611. draft_var_saver.save(process_data=node_execution.process_data, outputs=outputs)
  612. session.commit()
  613. return workflow_node_execution
  614. def run_free_workflow_node(
  615. self, node_data: dict, tenant_id: str, user_id: str, node_id: str, user_inputs: dict[str, Any]
  616. ) -> WorkflowNodeExecution:
  617. """
  618. Run free workflow node
  619. """
  620. # run free workflow node
  621. start_at = time.perf_counter()
  622. node_execution = self._handle_single_step_result(
  623. invoke_node_fn=lambda: WorkflowEntry.run_free_node(
  624. node_id=node_id,
  625. node_data=node_data,
  626. tenant_id=tenant_id,
  627. user_id=user_id,
  628. user_inputs=user_inputs,
  629. ),
  630. start_at=start_at,
  631. node_id=node_id,
  632. )
  633. return node_execution
  634. def _handle_single_step_result(
  635. self,
  636. invoke_node_fn: Callable[[], tuple[Node, Generator[GraphNodeEventBase, None, None]]],
  637. start_at: float,
  638. node_id: str,
  639. ) -> WorkflowNodeExecution:
  640. """
  641. Handle single step execution and return WorkflowNodeExecution.
  642. Args:
  643. invoke_node_fn: Function to invoke node execution
  644. start_at: Execution start time
  645. node_id: ID of the node being executed
  646. Returns:
  647. WorkflowNodeExecution: The execution result
  648. """
  649. node, node_run_result, run_succeeded, error = self._execute_node_safely(invoke_node_fn)
  650. # Create base node execution
  651. node_execution = WorkflowNodeExecution(
  652. id=str(uuid.uuid4()),
  653. workflow_id="", # Single-step execution has no workflow ID
  654. index=1,
  655. node_id=node_id,
  656. node_type=node.node_type,
  657. title=node.title,
  658. elapsed_time=time.perf_counter() - start_at,
  659. created_at=naive_utc_now(),
  660. finished_at=naive_utc_now(),
  661. )
  662. # Populate execution result data
  663. self._populate_execution_result(node_execution, node_run_result, run_succeeded, error)
  664. return node_execution
  665. def _execute_node_safely(
  666. self, invoke_node_fn: Callable[[], tuple[Node, Generator[GraphNodeEventBase, None, None]]]
  667. ) -> tuple[Node, NodeRunResult | None, bool, str | None]:
  668. """
  669. Execute node safely and handle errors according to error strategy.
  670. Returns:
  671. Tuple of (node, node_run_result, run_succeeded, error)
  672. """
  673. try:
  674. node, node_events = invoke_node_fn()
  675. node_run_result = next(
  676. (
  677. event.node_run_result
  678. for event in node_events
  679. if isinstance(event, (NodeRunSucceededEvent, NodeRunFailedEvent))
  680. ),
  681. None,
  682. )
  683. if not node_run_result:
  684. raise ValueError("Node execution failed - no result returned")
  685. # Apply error strategy if node failed
  686. if node_run_result.status == WorkflowNodeExecutionStatus.FAILED and node.error_strategy:
  687. node_run_result = self._apply_error_strategy(node, node_run_result)
  688. run_succeeded = node_run_result.status in (
  689. WorkflowNodeExecutionStatus.SUCCEEDED,
  690. WorkflowNodeExecutionStatus.EXCEPTION,
  691. )
  692. error = node_run_result.error if not run_succeeded else None
  693. return node, node_run_result, run_succeeded, error
  694. except WorkflowNodeRunFailedError as e:
  695. node = e.node
  696. run_succeeded = False
  697. node_run_result = None
  698. error = e.error
  699. return node, node_run_result, run_succeeded, error
  700. def _apply_error_strategy(self, node: Node, node_run_result: NodeRunResult) -> NodeRunResult:
  701. """Apply error strategy when node execution fails."""
  702. # TODO(Novice): Maybe we should apply error strategy to node level?
  703. error_outputs = {
  704. "error_message": node_run_result.error,
  705. "error_type": node_run_result.error_type,
  706. }
  707. # Add default values if strategy is DEFAULT_VALUE
  708. if node.error_strategy is ErrorStrategy.DEFAULT_VALUE:
  709. error_outputs.update(node.default_value_dict)
  710. return NodeRunResult(
  711. status=WorkflowNodeExecutionStatus.EXCEPTION,
  712. error=node_run_result.error,
  713. inputs=node_run_result.inputs,
  714. metadata={WorkflowNodeExecutionMetadataKey.ERROR_STRATEGY: node.error_strategy},
  715. outputs=error_outputs,
  716. )
  717. def _populate_execution_result(
  718. self,
  719. node_execution: WorkflowNodeExecution,
  720. node_run_result: NodeRunResult | None,
  721. run_succeeded: bool,
  722. error: str | None,
  723. ) -> None:
  724. """Populate node execution with result data."""
  725. if run_succeeded and node_run_result:
  726. node_execution.inputs = (
  727. WorkflowEntry.handle_special_values(node_run_result.inputs) if node_run_result.inputs else None
  728. )
  729. node_execution.process_data = (
  730. WorkflowEntry.handle_special_values(node_run_result.process_data)
  731. if node_run_result.process_data
  732. else None
  733. )
  734. node_execution.outputs = node_run_result.outputs
  735. node_execution.metadata = node_run_result.metadata
  736. # Set status and error based on result
  737. node_execution.status = node_run_result.status
  738. if node_run_result.status == WorkflowNodeExecutionStatus.EXCEPTION:
  739. node_execution.error = node_run_result.error
  740. else:
  741. node_execution.status = WorkflowNodeExecutionStatus.FAILED
  742. node_execution.error = error
  743. def convert_to_workflow(self, app_model: App, account: Account, args: dict) -> App:
  744. """
  745. Basic mode of chatbot app(expert mode) to workflow
  746. Completion App to Workflow App
  747. :param app_model: App instance
  748. :param account: Account instance
  749. :param args: dict
  750. :return:
  751. """
  752. # chatbot convert to workflow mode
  753. workflow_converter = WorkflowConverter()
  754. if app_model.mode not in {AppMode.CHAT, AppMode.COMPLETION}:
  755. raise ValueError(f"Current App mode: {app_model.mode} is not supported convert to workflow.")
  756. # convert to workflow
  757. new_app: App = workflow_converter.convert_to_workflow(
  758. app_model=app_model,
  759. account=account,
  760. name=args.get("name", "Default Name"),
  761. icon_type=args.get("icon_type", "emoji"),
  762. icon=args.get("icon", "🤖"),
  763. icon_background=args.get("icon_background", "#FFEAD5"),
  764. )
  765. return new_app
  766. def validate_features_structure(self, app_model: App, features: dict):
  767. if app_model.mode == AppMode.ADVANCED_CHAT:
  768. return AdvancedChatAppConfigManager.config_validate(
  769. tenant_id=app_model.tenant_id, config=features, only_structure_validate=True
  770. )
  771. elif app_model.mode == AppMode.WORKFLOW:
  772. return WorkflowAppConfigManager.config_validate(
  773. tenant_id=app_model.tenant_id, config=features, only_structure_validate=True
  774. )
  775. else:
  776. raise ValueError(f"Invalid app mode: {app_model.mode}")
  777. def update_workflow(
  778. self, *, session: Session, workflow_id: str, tenant_id: str, account_id: str, data: dict
  779. ) -> Workflow | None:
  780. """
  781. Update workflow attributes
  782. :param session: SQLAlchemy database session
  783. :param workflow_id: Workflow ID
  784. :param tenant_id: Tenant ID
  785. :param account_id: Account ID (for permission check)
  786. :param data: Dictionary containing fields to update
  787. :return: Updated workflow or None if not found
  788. """
  789. stmt = select(Workflow).where(Workflow.id == workflow_id, Workflow.tenant_id == tenant_id)
  790. workflow = session.scalar(stmt)
  791. if not workflow:
  792. return None
  793. allowed_fields = ["marked_name", "marked_comment"]
  794. for field, value in data.items():
  795. if field in allowed_fields:
  796. setattr(workflow, field, value)
  797. workflow.updated_by = account_id
  798. workflow.updated_at = naive_utc_now()
  799. return workflow
  800. def delete_workflow(self, *, session: Session, workflow_id: str, tenant_id: str) -> bool:
  801. """
  802. Delete a workflow
  803. :param session: SQLAlchemy database session
  804. :param workflow_id: Workflow ID
  805. :param tenant_id: Tenant ID
  806. :return: True if successful
  807. :raises: ValueError if workflow not found
  808. :raises: WorkflowInUseError if workflow is in use
  809. :raises: DraftWorkflowDeletionError if workflow is a draft version
  810. """
  811. stmt = select(Workflow).where(Workflow.id == workflow_id, Workflow.tenant_id == tenant_id)
  812. workflow = session.scalar(stmt)
  813. if not workflow:
  814. raise ValueError(f"Workflow with ID {workflow_id} not found")
  815. # Check if workflow is a draft version
  816. if workflow.version == Workflow.VERSION_DRAFT:
  817. raise DraftWorkflowDeletionError("Cannot delete draft workflow versions")
  818. # Check if this workflow is currently referenced by an app
  819. app_stmt = select(App).where(App.workflow_id == workflow_id)
  820. app = session.scalar(app_stmt)
  821. if app:
  822. # Cannot delete a workflow that's currently in use by an app
  823. raise WorkflowInUseError(f"Cannot delete workflow that is currently in use by app '{app.id}'")
  824. # Don't use workflow.tool_published as it's not accurate for specific workflow versions
  825. # Check if there's a tool provider using this specific workflow version
  826. tool_provider = (
  827. session.query(WorkflowToolProvider)
  828. .where(
  829. WorkflowToolProvider.tenant_id == workflow.tenant_id,
  830. WorkflowToolProvider.app_id == workflow.app_id,
  831. WorkflowToolProvider.version == workflow.version,
  832. )
  833. .first()
  834. )
  835. if tool_provider:
  836. # Cannot delete a workflow that's published as a tool
  837. raise WorkflowInUseError("Cannot delete workflow that is published as a tool")
  838. session.delete(workflow)
  839. return True
  840. def _setup_variable_pool(
  841. query: str,
  842. files: Sequence[File],
  843. user_id: str,
  844. user_inputs: Mapping[str, Any],
  845. workflow: Workflow,
  846. node_type: NodeType,
  847. conversation_id: str,
  848. conversation_variables: list[Variable],
  849. ):
  850. # Only inject system variables for START node type.
  851. if node_type == NodeType.START:
  852. system_variable = SystemVariable(
  853. user_id=user_id,
  854. app_id=workflow.app_id,
  855. workflow_id=workflow.id,
  856. files=files or [],
  857. workflow_execution_id=str(uuid.uuid4()),
  858. )
  859. # Only add chatflow-specific variables for non-workflow types
  860. if workflow.type != WorkflowType.WORKFLOW.value:
  861. system_variable.query = query
  862. system_variable.conversation_id = conversation_id
  863. system_variable.dialogue_count = 1
  864. else:
  865. system_variable = SystemVariable.empty()
  866. # init variable pool
  867. variable_pool = VariablePool(
  868. system_variables=system_variable,
  869. user_inputs=user_inputs,
  870. environment_variables=workflow.environment_variables,
  871. # Based on the definition of `VariableUnion`,
  872. # `list[Variable]` can be safely used as `list[VariableUnion]` since they are compatible.
  873. conversation_variables=cast(list[VariableUnion], conversation_variables), #
  874. )
  875. return variable_pool
  876. def _rebuild_file_for_user_inputs_in_start_node(
  877. tenant_id: str, start_node_data: StartNodeData, user_inputs: Mapping[str, Any]
  878. ) -> Mapping[str, Any]:
  879. inputs_copy = dict(user_inputs)
  880. for variable in start_node_data.variables:
  881. if variable.type not in (VariableEntityType.FILE, VariableEntityType.FILE_LIST):
  882. continue
  883. if variable.variable not in user_inputs:
  884. continue
  885. value = user_inputs[variable.variable]
  886. file = _rebuild_single_file(tenant_id=tenant_id, value=value, variable_entity_type=variable.type)
  887. inputs_copy[variable.variable] = file
  888. return inputs_copy
  889. def _rebuild_single_file(tenant_id: str, value: Any, variable_entity_type: VariableEntityType) -> File | Sequence[File]:
  890. if variable_entity_type == VariableEntityType.FILE:
  891. if not isinstance(value, dict):
  892. raise ValueError(f"expected dict for file object, got {type(value)}")
  893. return build_from_mapping(mapping=value, tenant_id=tenant_id)
  894. elif variable_entity_type == VariableEntityType.FILE_LIST:
  895. if not isinstance(value, list):
  896. raise ValueError(f"expected list for file list object, got {type(value)}")
  897. if len(value) == 0:
  898. return []
  899. if not isinstance(value[0], dict):
  900. raise ValueError(f"expected dict for first element in the file list, got {type(value)}")
  901. return build_from_mappings(mappings=value, tenant_id=tenant_id)
  902. else:
  903. raise Exception("unreachable")