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_draft_variable_service.py 29KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. import dataclasses
  2. import datetime
  3. import logging
  4. from collections.abc import Mapping, Sequence
  5. from enum import StrEnum
  6. from typing import Any, ClassVar
  7. from sqlalchemy import Engine, orm, select
  8. from sqlalchemy.dialects.postgresql import insert
  9. from sqlalchemy.orm import Session
  10. from sqlalchemy.sql.expression import and_, or_
  11. from core.app.entities.app_invoke_entities import InvokeFrom
  12. from core.file.models import File
  13. from core.variables import Segment, StringSegment, Variable
  14. from core.variables.consts import MIN_SELECTORS_LENGTH
  15. from core.variables.segments import ArrayFileSegment, FileSegment
  16. from core.variables.types import SegmentType
  17. from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
  18. from core.workflow.enums import SystemVariableKey
  19. from core.workflow.nodes import NodeType
  20. from core.workflow.nodes.variable_assigner.common.helpers import get_updated_variables
  21. from core.workflow.variable_loader import VariableLoader
  22. from factories.file_factory import StorageKeyLoader
  23. from factories.variable_factory import build_segment, segment_to_variable
  24. from models import App, Conversation
  25. from models.enums import DraftVariableType
  26. from models.workflow import Workflow, WorkflowDraftVariable, WorkflowNodeExecutionModel, is_system_variable_editable
  27. _logger = logging.getLogger(__name__)
  28. @dataclasses.dataclass(frozen=True)
  29. class WorkflowDraftVariableList:
  30. variables: list[WorkflowDraftVariable]
  31. total: int | None = None
  32. class WorkflowDraftVariableError(Exception):
  33. pass
  34. class VariableResetError(WorkflowDraftVariableError):
  35. pass
  36. class UpdateNotSupportedError(WorkflowDraftVariableError):
  37. pass
  38. class DraftVarLoader(VariableLoader):
  39. # This implements the VariableLoader interface for loading draft variables.
  40. #
  41. # ref: core.workflow.variable_loader.VariableLoader
  42. # Database engine used for loading variables.
  43. _engine: Engine
  44. # Application ID for which variables are being loaded.
  45. _app_id: str
  46. _tenant_id: str
  47. _fallback_variables: Sequence[Variable]
  48. def __init__(
  49. self,
  50. engine: Engine,
  51. app_id: str,
  52. tenant_id: str,
  53. fallback_variables: Sequence[Variable] | None = None,
  54. ) -> None:
  55. self._engine = engine
  56. self._app_id = app_id
  57. self._tenant_id = tenant_id
  58. self._fallback_variables = fallback_variables or []
  59. def _selector_to_tuple(self, selector: Sequence[str]) -> tuple[str, str]:
  60. return (selector[0], selector[1])
  61. def load_variables(self, selectors: list[list[str]]) -> list[Variable]:
  62. if not selectors:
  63. return []
  64. # Map each selector (as a tuple via `_selector_to_tuple`) to its corresponding Variable instance.
  65. variable_by_selector: dict[tuple[str, str], Variable] = {}
  66. with Session(bind=self._engine, expire_on_commit=False) as session:
  67. srv = WorkflowDraftVariableService(session)
  68. draft_vars = srv.get_draft_variables_by_selectors(self._app_id, selectors)
  69. for draft_var in draft_vars:
  70. segment = draft_var.get_value()
  71. variable = segment_to_variable(
  72. segment=segment,
  73. selector=draft_var.get_selector(),
  74. id=draft_var.id,
  75. name=draft_var.name,
  76. description=draft_var.description,
  77. )
  78. selector_tuple = self._selector_to_tuple(variable.selector)
  79. variable_by_selector[selector_tuple] = variable
  80. # Important:
  81. files: list[File] = []
  82. for draft_var in draft_vars:
  83. value = draft_var.get_value()
  84. if isinstance(value, FileSegment):
  85. files.append(value.value)
  86. elif isinstance(value, ArrayFileSegment):
  87. files.extend(value.value)
  88. with Session(bind=self._engine) as session:
  89. storage_key_loader = StorageKeyLoader(session, tenant_id=self._tenant_id)
  90. storage_key_loader.load_storage_keys(files)
  91. return list(variable_by_selector.values())
  92. class WorkflowDraftVariableService:
  93. _session: Session
  94. def __init__(self, session: Session) -> None:
  95. self._session = session
  96. def get_variable(self, variable_id: str) -> WorkflowDraftVariable | None:
  97. return self._session.query(WorkflowDraftVariable).filter(WorkflowDraftVariable.id == variable_id).first()
  98. def get_draft_variables_by_selectors(
  99. self,
  100. app_id: str,
  101. selectors: Sequence[list[str]],
  102. ) -> list[WorkflowDraftVariable]:
  103. ors = []
  104. for selector in selectors:
  105. assert len(selector) >= MIN_SELECTORS_LENGTH, f"Invalid selector to get: {selector}"
  106. node_id, name = selector[:2]
  107. ors.append(and_(WorkflowDraftVariable.node_id == node_id, WorkflowDraftVariable.name == name))
  108. # NOTE(QuantumGhost): Although the number of `or` expressions may be large, as long as
  109. # each expression includes conditions on both `node_id` and `name` (which are covered by the unique index),
  110. # PostgreSQL can efficiently retrieve the results using a bitmap index scan.
  111. #
  112. # Alternatively, a `SELECT` statement could be constructed for each selector and
  113. # combined using `UNION` to fetch all rows.
  114. # Benchmarking indicates that both approaches yield comparable performance.
  115. variables = (
  116. self._session.query(WorkflowDraftVariable).where(WorkflowDraftVariable.app_id == app_id, or_(*ors)).all()
  117. )
  118. return variables
  119. def list_variables_without_values(self, app_id: str, page: int, limit: int) -> WorkflowDraftVariableList:
  120. criteria = WorkflowDraftVariable.app_id == app_id
  121. total = None
  122. query = self._session.query(WorkflowDraftVariable).filter(criteria)
  123. if page == 1:
  124. total = query.count()
  125. variables = (
  126. # Do not load the `value` field.
  127. query.options(orm.defer(WorkflowDraftVariable.value))
  128. .order_by(WorkflowDraftVariable.id.desc())
  129. .limit(limit)
  130. .offset((page - 1) * limit)
  131. .all()
  132. )
  133. return WorkflowDraftVariableList(variables=variables, total=total)
  134. def _list_node_variables(self, app_id: str, node_id: str) -> WorkflowDraftVariableList:
  135. criteria = (
  136. WorkflowDraftVariable.app_id == app_id,
  137. WorkflowDraftVariable.node_id == node_id,
  138. )
  139. query = self._session.query(WorkflowDraftVariable).filter(*criteria)
  140. variables = query.order_by(WorkflowDraftVariable.id.desc()).all()
  141. return WorkflowDraftVariableList(variables=variables)
  142. def list_node_variables(self, app_id: str, node_id: str) -> WorkflowDraftVariableList:
  143. return self._list_node_variables(app_id, node_id)
  144. def list_conversation_variables(self, app_id: str) -> WorkflowDraftVariableList:
  145. return self._list_node_variables(app_id, CONVERSATION_VARIABLE_NODE_ID)
  146. def list_system_variables(self, app_id: str) -> WorkflowDraftVariableList:
  147. return self._list_node_variables(app_id, SYSTEM_VARIABLE_NODE_ID)
  148. def get_conversation_variable(self, app_id: str, name: str) -> WorkflowDraftVariable | None:
  149. return self._get_variable(app_id=app_id, node_id=CONVERSATION_VARIABLE_NODE_ID, name=name)
  150. def get_system_variable(self, app_id: str, name: str) -> WorkflowDraftVariable | None:
  151. return self._get_variable(app_id=app_id, node_id=SYSTEM_VARIABLE_NODE_ID, name=name)
  152. def get_node_variable(self, app_id: str, node_id: str, name: str) -> WorkflowDraftVariable | None:
  153. return self._get_variable(app_id, node_id, name)
  154. def _get_variable(self, app_id: str, node_id: str, name: str) -> WorkflowDraftVariable | None:
  155. variable = (
  156. self._session.query(WorkflowDraftVariable)
  157. .where(
  158. WorkflowDraftVariable.app_id == app_id,
  159. WorkflowDraftVariable.node_id == node_id,
  160. WorkflowDraftVariable.name == name,
  161. )
  162. .first()
  163. )
  164. return variable
  165. def update_variable(
  166. self,
  167. variable: WorkflowDraftVariable,
  168. name: str | None = None,
  169. value: Segment | None = None,
  170. ) -> WorkflowDraftVariable:
  171. if not variable.editable:
  172. raise UpdateNotSupportedError(f"variable not support updating, id={variable.id}")
  173. if name is not None:
  174. variable.set_name(name)
  175. if value is not None:
  176. variable.set_value(value)
  177. variable.last_edited_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
  178. self._session.flush()
  179. return variable
  180. def _reset_conv_var(self, workflow: Workflow, variable: WorkflowDraftVariable) -> WorkflowDraftVariable | None:
  181. conv_var_by_name = {i.name: i for i in workflow.conversation_variables}
  182. conv_var = conv_var_by_name.get(variable.name)
  183. if conv_var is None:
  184. self._session.delete(instance=variable)
  185. self._session.flush()
  186. _logger.warning(
  187. "Conversation variable not found for draft variable, id=%s, name=%s", variable.id, variable.name
  188. )
  189. return None
  190. variable.set_value(conv_var)
  191. variable.last_edited_at = None
  192. self._session.add(variable)
  193. self._session.flush()
  194. return variable
  195. def _reset_node_var(self, workflow: Workflow, variable: WorkflowDraftVariable) -> WorkflowDraftVariable | None:
  196. # If a variable does not allow updating, it makes no sence to resetting it.
  197. if not variable.editable:
  198. return variable
  199. # No execution record for this variable, delete the variable instead.
  200. if variable.node_execution_id is None:
  201. self._session.delete(instance=variable)
  202. self._session.flush()
  203. _logger.warning("draft variable has no node_execution_id, id=%s, name=%s", variable.id, variable.name)
  204. return None
  205. query = select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.id == variable.node_execution_id)
  206. node_exec = self._session.scalars(query).first()
  207. if node_exec is None:
  208. _logger.warning(
  209. "Node exectution not found for draft variable, id=%s, name=%s, node_execution_id=%s",
  210. variable.id,
  211. variable.name,
  212. variable.node_execution_id,
  213. )
  214. self._session.delete(instance=variable)
  215. self._session.flush()
  216. return None
  217. # Get node type for proper value extraction
  218. node_config = workflow.get_node_config_by_id(variable.node_id)
  219. node_type = workflow.get_node_type_from_node_config(node_config)
  220. outputs_dict = node_exec.outputs_dict or {}
  221. # Note: Based on the implementation in `_build_from_variable_assigner_mapping`,
  222. # VariableAssignerNode (both v1 and v2) can only create conversation draft variables.
  223. # For consistency, we should simply return when processing VARIABLE_ASSIGNER nodes.
  224. #
  225. # This implementation must remain synchronized with the `_build_from_variable_assigner_mapping`
  226. # and `save` methods.
  227. if node_type == NodeType.VARIABLE_ASSIGNER:
  228. return variable
  229. if variable.name not in outputs_dict:
  230. # If variable not found in execution data, delete the variable
  231. self._session.delete(instance=variable)
  232. self._session.flush()
  233. return None
  234. value = outputs_dict[variable.name]
  235. value_seg = WorkflowDraftVariable.build_segment_with_type(variable.value_type, value)
  236. # Extract variable value using unified logic
  237. variable.set_value(value_seg)
  238. variable.last_edited_at = None # Reset to indicate this is a reset operation
  239. self._session.flush()
  240. return variable
  241. def reset_variable(self, workflow: Workflow, variable: WorkflowDraftVariable) -> WorkflowDraftVariable | None:
  242. variable_type = variable.get_variable_type()
  243. if variable_type == DraftVariableType.CONVERSATION:
  244. return self._reset_conv_var(workflow, variable)
  245. elif variable_type == DraftVariableType.NODE:
  246. return self._reset_node_var(workflow, variable)
  247. else:
  248. raise VariableResetError(f"cannot reset system variable, variable_id={variable.id}")
  249. def delete_variable(self, variable: WorkflowDraftVariable):
  250. self._session.delete(variable)
  251. def delete_workflow_variables(self, app_id: str):
  252. (
  253. self._session.query(WorkflowDraftVariable)
  254. .filter(WorkflowDraftVariable.app_id == app_id)
  255. .delete(synchronize_session=False)
  256. )
  257. def delete_node_variables(self, app_id: str, node_id: str):
  258. return self._delete_node_variables(app_id, node_id)
  259. def _delete_node_variables(self, app_id: str, node_id: str):
  260. self._session.query(WorkflowDraftVariable).where(
  261. WorkflowDraftVariable.app_id == app_id,
  262. WorkflowDraftVariable.node_id == node_id,
  263. ).delete()
  264. def _get_conversation_id_from_draft_variable(self, app_id: str) -> str | None:
  265. draft_var = self._get_variable(
  266. app_id=app_id,
  267. node_id=SYSTEM_VARIABLE_NODE_ID,
  268. name=str(SystemVariableKey.CONVERSATION_ID),
  269. )
  270. if draft_var is None:
  271. return None
  272. segment = draft_var.get_value()
  273. if not isinstance(segment, StringSegment):
  274. _logger.warning(
  275. "sys.conversation_id variable is not a string: app_id=%s, id=%s",
  276. app_id,
  277. draft_var.id,
  278. )
  279. return None
  280. return segment.value
  281. def get_or_create_conversation(
  282. self,
  283. account_id: str,
  284. app: App,
  285. workflow: Workflow,
  286. ) -> str:
  287. """
  288. get_or_create_conversation creates and returns the ID of a conversation for debugging.
  289. If a conversation already exists, as determined by the following criteria, its ID is returned:
  290. - The system variable `sys.conversation_id` exists in the draft variable table, and
  291. - A corresponding conversation record is found in the database.
  292. If no such conversation exists, a new conversation is created and its ID is returned.
  293. """
  294. conv_id = self._get_conversation_id_from_draft_variable(workflow.app_id)
  295. if conv_id is not None:
  296. conversation = (
  297. self._session.query(Conversation)
  298. .filter(
  299. Conversation.id == conv_id,
  300. Conversation.app_id == workflow.app_id,
  301. )
  302. .first()
  303. )
  304. # Only return the conversation ID if it exists and is valid (has a correspond conversation record in DB).
  305. if conversation is not None:
  306. return conv_id
  307. conversation = Conversation(
  308. app_id=workflow.app_id,
  309. app_model_config_id=app.app_model_config_id,
  310. model_provider=None,
  311. model_id="",
  312. override_model_configs=None,
  313. mode=app.mode,
  314. name="Draft Debugging Conversation",
  315. inputs={},
  316. introduction="",
  317. system_instruction="",
  318. system_instruction_tokens=0,
  319. status="normal",
  320. invoke_from=InvokeFrom.DEBUGGER.value,
  321. from_source="console",
  322. from_end_user_id=None,
  323. from_account_id=account_id,
  324. )
  325. self._session.add(conversation)
  326. self._session.flush()
  327. return conversation.id
  328. def prefill_conversation_variable_default_values(self, workflow: Workflow):
  329. """"""
  330. draft_conv_vars: list[WorkflowDraftVariable] = []
  331. for conv_var in workflow.conversation_variables:
  332. draft_var = WorkflowDraftVariable.new_conversation_variable(
  333. app_id=workflow.app_id,
  334. name=conv_var.name,
  335. value=conv_var,
  336. description=conv_var.description,
  337. )
  338. draft_conv_vars.append(draft_var)
  339. _batch_upsert_draft_varaible(
  340. self._session,
  341. draft_conv_vars,
  342. policy=_UpsertPolicy.IGNORE,
  343. )
  344. class _UpsertPolicy(StrEnum):
  345. IGNORE = "ignore"
  346. OVERWRITE = "overwrite"
  347. def _batch_upsert_draft_varaible(
  348. session: Session,
  349. draft_vars: Sequence[WorkflowDraftVariable],
  350. policy: _UpsertPolicy = _UpsertPolicy.OVERWRITE,
  351. ) -> None:
  352. if not draft_vars:
  353. return None
  354. # Although we could use SQLAlchemy ORM operations here, we choose not to for several reasons:
  355. #
  356. # 1. The variable saving process involves writing multiple rows to the
  357. # `workflow_draft_variables` table. Batch insertion significantly improves performance.
  358. # 2. Using the ORM would require either:
  359. #
  360. # a. Checking for the existence of each variable before insertion,
  361. # resulting in 2n SQL statements for n variables and potential concurrency issues.
  362. # b. Attempting insertion first, then updating if a unique index violation occurs,
  363. # which still results in n to 2n SQL statements.
  364. #
  365. # Both approaches are inefficient and suboptimal.
  366. # 3. We do not need to retrieve the results of the SQL execution or populate ORM
  367. # model instances with the returned values.
  368. # 4. Batch insertion with `ON CONFLICT DO UPDATE` allows us to insert or update all
  369. # variables in a single SQL statement, avoiding the issues above.
  370. #
  371. # For these reasons, we use the SQLAlchemy query builder and rely on dialect-specific
  372. # insert operations instead of the ORM layer.
  373. stmt = insert(WorkflowDraftVariable).values([_model_to_insertion_dict(v) for v in draft_vars])
  374. if policy == _UpsertPolicy.OVERWRITE:
  375. stmt = stmt.on_conflict_do_update(
  376. index_elements=WorkflowDraftVariable.unique_app_id_node_id_name(),
  377. set_={
  378. "updated_at": stmt.excluded.updated_at,
  379. "last_edited_at": stmt.excluded.last_edited_at,
  380. "description": stmt.excluded.description,
  381. "value_type": stmt.excluded.value_type,
  382. "value": stmt.excluded.value,
  383. "visible": stmt.excluded.visible,
  384. "editable": stmt.excluded.editable,
  385. "node_execution_id": stmt.excluded.node_execution_id,
  386. },
  387. )
  388. elif _UpsertPolicy.IGNORE:
  389. stmt = stmt.on_conflict_do_nothing(index_elements=WorkflowDraftVariable.unique_app_id_node_id_name())
  390. else:
  391. raise Exception("Invalid value for update policy.")
  392. session.execute(stmt)
  393. def _model_to_insertion_dict(model: WorkflowDraftVariable) -> dict[str, Any]:
  394. d: dict[str, Any] = {
  395. "app_id": model.app_id,
  396. "last_edited_at": None,
  397. "node_id": model.node_id,
  398. "name": model.name,
  399. "selector": model.selector,
  400. "value_type": model.value_type,
  401. "value": model.value,
  402. "node_execution_id": model.node_execution_id,
  403. }
  404. if model.visible is not None:
  405. d["visible"] = model.visible
  406. if model.editable is not None:
  407. d["editable"] = model.editable
  408. if model.created_at is not None:
  409. d["created_at"] = model.created_at
  410. if model.updated_at is not None:
  411. d["updated_at"] = model.updated_at
  412. if model.description is not None:
  413. d["description"] = model.description
  414. return d
  415. def _build_segment_for_serialized_values(v: Any) -> Segment:
  416. """
  417. Reconstructs Segment objects from serialized values, with special handling
  418. for FileSegment and ArrayFileSegment types.
  419. This function should only be used when:
  420. 1. No explicit type information is available
  421. 2. The input value is in serialized form (dict or list)
  422. It detects potential file objects in the serialized data and properly rebuilds the
  423. appropriate segment type.
  424. """
  425. return build_segment(WorkflowDraftVariable.rebuild_file_types(v))
  426. class DraftVariableSaver:
  427. # _DUMMY_OUTPUT_IDENTITY is a placeholder output for workflow nodes.
  428. # Its sole possible value is `None`.
  429. #
  430. # This is used to signal the execution of a workflow node when it has no other outputs.
  431. _DUMMY_OUTPUT_IDENTITY: ClassVar[str] = "__dummy__"
  432. _DUMMY_OUTPUT_VALUE: ClassVar[None] = None
  433. # _EXCLUDE_VARIABLE_NAMES_MAPPING maps node types and versions to variable names that
  434. # should be excluded when saving draft variables. This prevents certain internal or
  435. # technical variables from being exposed in the draft environment, particularly those
  436. # that aren't meant to be directly edited or viewed by users.
  437. _EXCLUDE_VARIABLE_NAMES_MAPPING: dict[NodeType, frozenset[str]] = {
  438. NodeType.LLM: frozenset(["finish_reason"]),
  439. NodeType.LOOP: frozenset(["loop_round"]),
  440. }
  441. # Database session used for persisting draft variables.
  442. _session: Session
  443. # The application ID associated with the draft variables.
  444. # This should match the `Workflow.app_id` of the workflow to which the current node belongs.
  445. _app_id: str
  446. # The ID of the node for which DraftVariableSaver is saving output variables.
  447. _node_id: str
  448. # The type of the current node (see NodeType).
  449. _node_type: NodeType
  450. # Indicates how the workflow execution was triggered (see InvokeFrom).
  451. _invoke_from: InvokeFrom
  452. #
  453. _node_execution_id: str
  454. # _enclosing_node_id identifies the container node that the current node belongs to.
  455. # For example, if the current node is an LLM node inside an Iteration node
  456. # or Loop node, then `_enclosing_node_id` refers to the ID of
  457. # the containing Iteration or Loop node.
  458. #
  459. # If the current node is not nested within another node, `_enclosing_node_id` is
  460. # `None`.
  461. _enclosing_node_id: str | None
  462. def __init__(
  463. self,
  464. session: Session,
  465. app_id: str,
  466. node_id: str,
  467. node_type: NodeType,
  468. invoke_from: InvokeFrom,
  469. node_execution_id: str,
  470. enclosing_node_id: str | None = None,
  471. ):
  472. self._session = session
  473. self._app_id = app_id
  474. self._node_id = node_id
  475. self._node_type = node_type
  476. self._invoke_from = invoke_from
  477. self._node_execution_id = node_execution_id
  478. self._enclosing_node_id = enclosing_node_id
  479. def _create_dummy_output_variable(self):
  480. return WorkflowDraftVariable.new_node_variable(
  481. app_id=self._app_id,
  482. node_id=self._node_id,
  483. name=self._DUMMY_OUTPUT_IDENTITY,
  484. node_execution_id=self._node_execution_id,
  485. value=build_segment(self._DUMMY_OUTPUT_VALUE),
  486. visible=False,
  487. editable=False,
  488. )
  489. def _should_save_output_variables_for_draft(self) -> bool:
  490. # Only save output variables for debugging execution of workflow.
  491. if self._invoke_from != InvokeFrom.DEBUGGER:
  492. return False
  493. if self._enclosing_node_id is not None and self._node_type != NodeType.VARIABLE_ASSIGNER:
  494. # Currently we do not save output variables for nodes inside loop or iteration.
  495. return False
  496. return True
  497. def _build_from_variable_assigner_mapping(self, process_data: Mapping[str, Any]) -> list[WorkflowDraftVariable]:
  498. draft_vars: list[WorkflowDraftVariable] = []
  499. updated_variables = get_updated_variables(process_data) or []
  500. for item in updated_variables:
  501. selector = item.selector
  502. if len(selector) < MIN_SELECTORS_LENGTH:
  503. raise Exception("selector too short")
  504. # NOTE(QuantumGhost): only the following two kinds of variable could be updated by
  505. # VariableAssigner: ConversationVariable and iteration variable.
  506. # We only save conversation variable here.
  507. if selector[0] != CONVERSATION_VARIABLE_NODE_ID:
  508. continue
  509. segment = WorkflowDraftVariable.build_segment_with_type(segment_type=item.value_type, value=item.new_value)
  510. draft_vars.append(
  511. WorkflowDraftVariable.new_conversation_variable(
  512. app_id=self._app_id,
  513. name=item.name,
  514. value=segment,
  515. )
  516. )
  517. # Add a dummy output variable to indicate that this node is executed.
  518. draft_vars.append(self._create_dummy_output_variable())
  519. return draft_vars
  520. def _build_variables_from_start_mapping(self, output: Mapping[str, Any]) -> list[WorkflowDraftVariable]:
  521. draft_vars = []
  522. has_non_sys_variables = False
  523. for name, value in output.items():
  524. value_seg = _build_segment_for_serialized_values(value)
  525. node_id, name = self._normalize_variable_for_start_node(name)
  526. # If node_id is not `sys`, it means that the variable is a user-defined input field
  527. # in `Start` node.
  528. if node_id != SYSTEM_VARIABLE_NODE_ID:
  529. draft_vars.append(
  530. WorkflowDraftVariable.new_node_variable(
  531. app_id=self._app_id,
  532. node_id=self._node_id,
  533. name=name,
  534. node_execution_id=self._node_execution_id,
  535. value=value_seg,
  536. visible=True,
  537. editable=True,
  538. )
  539. )
  540. has_non_sys_variables = True
  541. else:
  542. if name == SystemVariableKey.FILES:
  543. # Here we know the type of variable must be `array[file]`, we
  544. # just build files from the value.
  545. files = [File.model_validate(v) for v in value]
  546. if files:
  547. value_seg = WorkflowDraftVariable.build_segment_with_type(SegmentType.ARRAY_FILE, files)
  548. else:
  549. value_seg = ArrayFileSegment(value=[])
  550. draft_vars.append(
  551. WorkflowDraftVariable.new_sys_variable(
  552. app_id=self._app_id,
  553. name=name,
  554. node_execution_id=self._node_execution_id,
  555. value=value_seg,
  556. editable=self._should_variable_be_editable(node_id, name),
  557. )
  558. )
  559. if not has_non_sys_variables:
  560. draft_vars.append(self._create_dummy_output_variable())
  561. return draft_vars
  562. def _normalize_variable_for_start_node(self, name: str) -> tuple[str, str]:
  563. if not name.startswith(f"{SYSTEM_VARIABLE_NODE_ID}."):
  564. return self._node_id, name
  565. _, name_ = name.split(".", maxsplit=1)
  566. return SYSTEM_VARIABLE_NODE_ID, name_
  567. def _build_variables_from_mapping(self, output: Mapping[str, Any]) -> list[WorkflowDraftVariable]:
  568. draft_vars = []
  569. for name, value in output.items():
  570. if not self._should_variable_be_saved(name):
  571. _logger.debug(
  572. "Skip saving variable as it has been excluded by its node_type, name=%s, node_type=%s",
  573. name,
  574. self._node_type,
  575. )
  576. continue
  577. if isinstance(value, Segment):
  578. value_seg = value
  579. else:
  580. value_seg = _build_segment_for_serialized_values(value)
  581. draft_vars.append(
  582. WorkflowDraftVariable.new_node_variable(
  583. app_id=self._app_id,
  584. node_id=self._node_id,
  585. name=name,
  586. node_execution_id=self._node_execution_id,
  587. value=value_seg,
  588. visible=self._should_variable_be_visible(self._node_id, self._node_type, name),
  589. )
  590. )
  591. return draft_vars
  592. def save(
  593. self,
  594. process_data: Mapping[str, Any] | None = None,
  595. outputs: Mapping[str, Any] | None = None,
  596. ):
  597. draft_vars: list[WorkflowDraftVariable] = []
  598. if outputs is None:
  599. outputs = {}
  600. if process_data is None:
  601. process_data = {}
  602. if not self._should_save_output_variables_for_draft():
  603. return
  604. if self._node_type == NodeType.VARIABLE_ASSIGNER:
  605. draft_vars = self._build_from_variable_assigner_mapping(process_data=process_data)
  606. elif self._node_type == NodeType.START:
  607. draft_vars = self._build_variables_from_start_mapping(outputs)
  608. else:
  609. draft_vars = self._build_variables_from_mapping(outputs)
  610. _batch_upsert_draft_varaible(self._session, draft_vars)
  611. @staticmethod
  612. def _should_variable_be_editable(node_id: str, name: str) -> bool:
  613. if node_id in (CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID):
  614. return False
  615. if node_id == SYSTEM_VARIABLE_NODE_ID and not is_system_variable_editable(name):
  616. return False
  617. return True
  618. @staticmethod
  619. def _should_variable_be_visible(node_id: str, node_type: NodeType, name: str) -> bool:
  620. if node_type in NodeType.IF_ELSE:
  621. return False
  622. if node_id == SYSTEM_VARIABLE_NODE_ID and not is_system_variable_editable(name):
  623. return False
  624. return True
  625. def _should_variable_be_saved(self, name: str) -> bool:
  626. exclude_var_names = self._EXCLUDE_VARIABLE_NAMES_MAPPING.get(self._node_type)
  627. if exclude_var_names is None:
  628. return True
  629. return name not in exclude_var_names