From 301742c0823e87fcceb019944ca8bb24edc97e55 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Thu, 8 Oct 2026 18:32:44 +0800 Subject: [PATCH 1/8] refactor(api): route draft variable APIs through application services --- api/controllers/common/__init__.py | 1 + api/controllers/common/errors.py | 94 +++- api/controllers/console/__init__.py | 19 +- .../console/app/workflow_draft_variable.py | 466 +++-------------- .../app/workflow_variable_admission.py | 65 +++ api/controllers/console/datasets/__init__.py | 1 + .../console/datasets/rag_pipeline/__init__.py | 1 + .../rag_pipeline_draft_variable.py | 356 +++---------- api/controllers/console/snippets/__init__.py | 1 + .../snippet_workflow_draft_variable.py | 293 +++-------- api/controllers/service_api/__init__.py | 3 +- api/extensions/ext_application_services.py | 13 + api/fields/workflow_draft_variable_fields.py | 6 +- api/services/errors/dataset.py | 4 + api/services/errors/document.py | 12 + api/tests/unit_tests/conftest.py | 20 + .../controllers/common/test_errors.py | 67 +++ .../app/test_workflow_variable_conflicts.py | 214 ++++++++ .../app/test_workflow_variable_transport.py | 276 ++++++++++ .../app/workflow_draft_variables_test.py | 2 +- .../test_rag_pipeline_draft_variable.py | 470 ------------------ .../test_snippet_workflow_draft_variable.py | 327 ------------ 22 files changed, 1001 insertions(+), 1710 deletions(-) create mode 100644 api/controllers/common/__init__.py create mode 100644 api/controllers/console/app/workflow_variable_admission.py create mode 100644 api/controllers/console/datasets/__init__.py create mode 100644 api/controllers/console/datasets/rag_pipeline/__init__.py create mode 100644 api/controllers/console/snippets/__init__.py create mode 100644 api/tests/unit_tests/controllers/console/app/test_workflow_variable_conflicts.py create mode 100644 api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py delete mode 100644 api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py delete mode 100644 api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py diff --git a/api/controllers/common/__init__.py b/api/controllers/common/__init__.py new file mode 100644 index 00000000000000..1726f135c40853 --- /dev/null +++ b/api/controllers/common/__init__.py @@ -0,0 +1 @@ +"""Shared HTTP adapter helpers.""" diff --git a/api/controllers/common/errors.py b/api/controllers/common/errors.py index 78cee1b813cddf..f4ec4ba5a2a3e4 100644 --- a/api/controllers/common/errors.py +++ b/api/controllers/common/errors.py @@ -1,12 +1,17 @@ +import logging from collections import OrderedDict from http import HTTPStatus -from typing import cast +from typing import NoReturn, cast from flask import current_app, got_request_exception from flask_restx import Api +from werkzeug.exceptions import HTTPException from libs.exception import BaseHTTPException +from services.annotation.errors import AnnotationResourceNotFoundError from services.errors.base import NoPermissionError +from services.errors.dataset import DatasetNotFoundError +from services.errors.document import DocumentAccessDeniedError, DocumentNotFoundError, DocumentSourceNotFoundError class FilenameNotExistsError(BaseHTTPException): @@ -97,6 +102,93 @@ class InvalidArgumentError(BaseHTTPException): code = 400 +class InvalidRequestError(BaseHTTPException): + error_code = "bad_request" + code = 400 + description = "The browser (or proxy) sent a request that this server could not understand." + + +class AccessDeniedError(BaseHTTPException): + error_code = "forbidden" + code = 403 + description = ( + "You don't have the permission to access the requested resource. " + "It is either read-protected or not readable by the server." + ) + + +class AuthenticationRequiredError(BaseHTTPException): + error_code = "unauthorized" + code = 401 + description = "Authentication is required." + + +class InternalServerError(BaseHTTPException): + error_code = "internal_server_error" + code = 500 + description = ( + "The server encountered an internal error and was unable to complete your request. " + "Either the server is overloaded or there is an error in the application." + ) + + +class UnsupportedMediaTypeError(BaseHTTPException): + error_code = "unsupported_media_type" + code = 415 + description = "The server does not support the media type transmitted in the request." + + +class UnprocessableEntityError(BaseHTTPException): + error_code = "unprocessable_entity" + code = 422 + + +class TooManyRequestsError(BaseHTTPException): + error_code = "too_many_requests" + code = 429 + description = "This user has exceeded an allotted request count. Try again later." + + +class RequestBodyTooLargeError(BaseHTTPException): + error_code = "request_entity_too_large" + code = 413 + + +def raise_unexpected_error(error: Exception, *, message: str) -> NoReturn: + """Keep HTTP failures intact and sanitize unclassified failures at the transport boundary. + + Framework request parsing and project HTTP errors may both reach a route's + error translator. Neither should be converted to a generic server error. + """ + if isinstance(error, HTTPException): + raise error + logging.getLogger(__name__).exception(message) + raise InternalServerError() from error + + +def register_resource_error_handlers(api: Api) -> None: + """Translate annotation and document failures at the HTTP boundary.""" + + def missing_resource(error: Exception) -> tuple[dict[str, str | int], int]: + got_request_exception.send(current_app, exception=error) + return {"code": "not_found", "message": str(error), "status": 404}, 404 + + def document_access_denied(error: DocumentAccessDeniedError) -> tuple[dict[str, str | int], int]: + got_request_exception.send(current_app, exception=error) + return {"code": "forbidden", "message": str(error), "status": 403}, 403 + + for error_type in ( + AnnotationResourceNotFoundError, + DatasetNotFoundError, + DocumentNotFoundError, + DocumentSourceNotFoundError, + ): + api.errorhandler(error_type)(missing_resource) + cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(error_type, last=False) + api.errorhandler(DocumentAccessDeniedError)(document_access_denied) + cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(DocumentAccessDeniedError, last=False) + + def register_permission_error_handler(api: Api) -> None: """Preserve the default permission-error response for Console and Service API.""" diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index a40044be719bfb..9f12813efcdbff 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -6,9 +6,10 @@ from flask import Blueprint, current_app, got_request_exception from flask_restx import Namespace -from controllers.common.errors import register_permission_error_handler +from controllers.common.errors import register_permission_error_handler, register_resource_error_handlers from libs.external_api import ExternalApi from machinery.errors import ActiveWorkspaceRequiredError +from services.workflow.variable_contracts import DraftVariableChangedError bp = Blueprint("console", __name__, url_prefix="/console/api") @@ -19,6 +20,7 @@ description="Console management APIs for app configuration, monitoring, and administration", ) register_permission_error_handler(api) +register_resource_error_handlers(api) @api.errorhandler(ActiveWorkspaceRequiredError) @@ -38,6 +40,21 @@ def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError) cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(ActiveWorkspaceRequiredError, last=False) +@api.errorhandler(DraftVariableChangedError) +def _handle_draft_variable_changed_error(error: DraftVariableChangedError): + """Expose a concurrent variable edit consistently across Console use cases.""" + got_request_exception.send(current_app, exception=error) + status = HTTPStatus.CONFLICT + return { + "code": "draft_variable_changed", + "message": "Variable changed; refresh and retry with the current value.", + "status": status.value, + }, status.value + + +cast(OrderedDict[type[Exception], object], api.error_handlers).move_to_end(DraftVariableChangedError, last=False) + + console_ns = Namespace("console", description="Console management API operations", path="/") RESOURCE_MODULES = ( diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index 8b4841ed00da0a..867434ce7f9057 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -1,39 +1,19 @@ -import logging -from collections.abc import Callable -from functools import wraps -from typing import Any, Concatenate, Self +from typing import Any, Self from uuid import UUID from flask import Response from flask_restx import Resource from pydantic import BaseModel, ConfigDict, Field, model_validator -from sqlalchemy.orm import sessionmaker -from controllers.common.errors import InvalidArgumentError, NotFoundError from controllers.common.fields import SimpleResultResponse -from controllers.common.rbac import PlainApp, RBACCheck from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.app.error import ( - DraftWorkflowNotExist, -) -from controllers.console.app.wraps import get_app_model +from controllers.console.app.workflow_variable_admission import console_variable_admission from controllers.console.wraps import ( RBACPermission, - account_initialization_required, - edit_permission_required, model_validate, - rbac_permission_required, - setup_required, - with_current_user, ) -from core.app.file_access import DatabaseFileAccessController -from core.workflow.llm_environment_variable import environment_variable_value_type -from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID -from extensions.ext_database import db -from factories import variable_factory -from factories.file_factory import build_from_mapping, build_from_mappings -from factories.variable_factory import build_segment_with_type +from extensions.ext_application_services import application_services from fields.base import ResponseModel from fields.workflow_draft_variable_fields import ( WorkflowDraftVariableFullContentResponse, @@ -42,16 +22,9 @@ WorkflowDraftVariableResponse, WorkflowDraftVariableWithoutValueResponse, ) -from graphon.variables.types import SegmentType from libs.helper import dump_response -from libs.login import login_required -from models import Account, App, AppMode -from models.workflow import WorkflowDraftVariable -from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService -from services.workflow_service import WorkflowService - -logger = logging.getLogger(__name__) -_file_access_controller = DatabaseFileAccessController() +from machinery.context import RequestContext +from services.workflow.contracts import WorkflowOwner class WorkflowDraftVariableListQuery(BaseModel): @@ -162,46 +135,6 @@ class EnvironmentVariableListResponse(ResponseModel): ) -def ensure_variable_access( - variable: WorkflowDraftVariable | None, - app_id: str, - variable_id: str, - current_user_id: str, -) -> WorkflowDraftVariable: - if variable is None: - raise NotFoundError(description=f"variable not found, id={variable_id}") - if variable.app_id != app_id or variable.user_id != current_user_id: - raise NotFoundError(description=f"variable not found, id={variable_id}") - return variable - - -def _api_prerequisite[T, **P, R]( - f: Callable[Concatenate[T, Account, P], R], -) -> Callable[Concatenate[T, P], R | Response]: - """Common prerequisites for all draft workflow variable APIs. - - It ensures the following conditions are satisfied: - - - Dify has been property setup. - - The request user has logged in and initialized. - - The requested app is a workflow or a chat flow. - - The request user has the edit permission for the app. - """ - - @setup_required - @login_required - @account_initialization_required - @edit_permission_required - @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @with_current_user - @wraps(f) - def wrapper(self: T, current_user: Account, *args: P.args, **kwargs: P.kwargs) -> R | Response: - return f(self, current_user, *args, **kwargs) - - return wrapper - - @console_ns.route("/apps//workflows/draft/variables") class WorkflowVariableCollectionApi(Resource): @console_ns.doc(params=query_params_from_model(WorkflowDraftVariableListQuery)) @@ -214,64 +147,24 @@ class WorkflowVariableCollectionApi(Resource): "Workflow variables retrieved successfully", console_ns.models[WorkflowDraftVariableListWithoutValueResponse.__name__], ) - @_api_prerequisite - @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) + @console_variable_admission("app") @model_validate(WorkflowDraftVariableListQuery) - def get(self, req_data: WorkflowDraftVariableListQuery, current_user: Account, app_model: App): - """ - Get draft workflow - """ - - # fetch draft workflow by app_model - workflow_service = WorkflowService() - workflow_exist = workflow_service.is_workflow_exist(app_model=app_model, session=db.session()) - if not workflow_exist: - raise DraftWorkflowNotExist() - - # fetch draft workflow by app_model - with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: - draft_var_srv = WorkflowDraftVariableService( - session=session, - ) - workflow_vars = draft_var_srv.list_variables_without_values( - app_id=app_model.id, - page=req_data.page, - limit=req_data.limit, - user_id=current_user.id, - ) - - return dump_response(WorkflowDraftVariableListWithoutValueResponse, workflow_vars) + def get(self, req_data: WorkflowDraftVariableListQuery, request_context: RequestContext, app_id: UUID): + """List draft workflow variables without loading their values.""" + variables = application_services().console_workflow_variables.list_variables( + request_context, WorkflowOwner(str(app_id), "app"), page=req_data.page, limit=req_data.limit + ) + return dump_response(WorkflowDraftVariableListWithoutValueResponse, variables) @console_ns.doc("delete_workflow_variables") @console_ns.doc(description="Delete all draft workflow variables") @console_ns.response(204, "Workflow variables deleted successfully") - @_api_prerequisite - def delete(self, current_user: Account, app_model: App): - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), - ) - draft_var_srv.delete_user_workflow_variables(app_model.id, user_id=current_user.id) - db.session.commit() + @console_variable_admission("app") + def delete(self, request_context: RequestContext, app_id: UUID): + application_services().console_workflow_variables.delete_all(request_context, WorkflowOwner(str(app_id), "app")) return Response("", 204) -def validate_node_id(node_id: str) -> None: - if node_id in [ - CONVERSATION_VARIABLE_NODE_ID, - SYSTEM_VARIABLE_NODE_ID, - ]: - # NOTE(QuantumGhost): While we store the system and conversation variables as node variables - # with specific `node_id` in database, we still want to make the API separated. By disallowing - # accessing system and conversation variables in `WorkflowDraftNodeVariableListApi`, - # we mitigate the risk that user of the API depending on the implementation detail of the API. - # - # ref: [Hyrum's Law](https://www.hyrumslaw.com/) - - raise InvalidArgumentError( - f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}", - ) - - @console_ns.route("/apps//workflows/draft/nodes//variables") class NodeVariableCollectionApi(Resource): @console_ns.doc("get_node_variables") @@ -282,35 +175,26 @@ class NodeVariableCollectionApi(Resource): "Node variables retrieved successfully", console_ns.models[WorkflowDraftVariableListResponse.__name__], ) - @_api_prerequisite - @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) - def get(self, current_user: Account, app_model: App, node_id: str): - validate_node_id(node_id) - with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: - draft_var_srv = WorkflowDraftVariableService( - session=session, - ) - node_vars = draft_var_srv.list_node_variables(app_model.id, node_id, user_id=current_user.id) - - return dump_response(WorkflowDraftVariableListResponse, node_vars) + @console_variable_admission("app") + def get(self, request_context: RequestContext, app_id: UUID, node_id: str): + variables = application_services().console_workflow_variables.node( + request_context, WorkflowOwner(str(app_id), "app"), node_id + ) + return dump_response(WorkflowDraftVariableListResponse, variables) @console_ns.doc("delete_node_variables") @console_ns.doc(description="Delete all variables for a specific node") @console_ns.response(204, "Node variables deleted successfully") - @_api_prerequisite - def delete(self, current_user: Account, app_model: App, node_id: str): - validate_node_id(node_id) - srv = WorkflowDraftVariableService(db.session()) - srv.delete_node_variables(app_model.id, node_id, user_id=current_user.id) - db.session.commit() + @console_variable_admission("app") + def delete(self, request_context: RequestContext, app_id: UUID, node_id: str): + application_services().console_workflow_variables.delete_node( + request_context, WorkflowOwner(str(app_id), "app"), node_id + ) return Response("", 204) @console_ns.route("/apps//workflows/draft/variables/") class VariableApi(Resource): - _PATCH_NAME_FIELD = "name" - _PATCH_VALUE_FIELD = "value" - @console_ns.doc("get_variable") @console_ns.doc(description="Get a specific workflow variable") @console_ns.doc(params={"app_id": "Application ID", "variable_id": "Variable ID"}) @@ -320,18 +204,10 @@ class VariableApi(Resource): console_ns.models[WorkflowDraftVariableResponse.__name__], ) @console_ns.response(404, "Variable not found") - @_api_prerequisite - @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) - def get(self, current_user: Account, app_model: App, variable_id: UUID): - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), - ) - variable_id_str = str(variable_id) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id_str), - app_id=app_model.id, - variable_id=variable_id_str, - current_user_id=current_user.id, + @console_variable_admission("app") + def get(self, request_context: RequestContext, app_id: UUID, variable_id: UUID): + variable = application_services().console_workflow_variables.get( + request_context, WorkflowOwner(str(app_id), "app"), str(variable_id) ) return dump_response(WorkflowDraftVariableResponse, variable) @@ -344,99 +220,33 @@ def get(self, current_user: Account, app_model: App, variable_id: UUID): console_ns.models[WorkflowDraftVariableResponse.__name__], ) @console_ns.response(404, "Variable not found") - @_api_prerequisite + @console_variable_admission("app") @model_validate(WorkflowDraftVariableUpdatePayload) def patch( self, req_data: WorkflowDraftVariableUpdatePayload, - current_user: Account, - app_model: App, + request_context: RequestContext, + app_id: UUID, variable_id: UUID, ): - # Request payload for file types: - # - # Local File: - # - # { - # "type": "image", - # "transfer_method": "local_file", - # "url": "", - # "upload_file_id": "daded54f-72c7-4f8e-9d18-9b0abdd9f190" - # } - # - # Remote File: - # - # - # { - # "type": "image", - # "transfer_method": "remote_url", - # "url": "http://127.0.0.1:5001/files/1602650a-4fe4-423c-85a2-af76c083e3c4/file-preview?timestamp=1750041099&nonce=...&sign=...=", - # "upload_file_id": "1602650a-4fe4-423c-85a2-af76c083e3c4" - # } - - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), - ) - - variable_id_str = str(variable_id) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id_str), - app_id=app_model.id, - variable_id=variable_id_str, - current_user_id=current_user.id, + variable = application_services().console_workflow_variables.patch( + request_context, + WorkflowOwner(str(app_id), "app"), + str(variable_id), + name=req_data.name, + value=req_data.value, ) - - new_name = req_data.name - raw_value = req_data.value - if new_name is None and raw_value is None: - return dump_response(WorkflowDraftVariableResponse, variable) - - new_value = None - if raw_value is not None: - match variable.value_type: - case SegmentType.FILE: - if not isinstance(raw_value, dict): - raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( - mapping=raw_value, - tenant_id=app_model.tenant_id, - access_controller=_file_access_controller, - ) - case SegmentType.ARRAY_FILE: - if not isinstance(raw_value, list): - raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( - mappings=raw_value, - tenant_id=app_model.tenant_id, - access_controller=_file_access_controller, - ) - case _: - pass - new_value = build_segment_with_type(variable.value_type, raw_value) - draft_var_srv.update_variable(variable, name=new_name, value=new_value) - db.session.commit() return dump_response(WorkflowDraftVariableResponse, variable) @console_ns.doc("delete_variable") @console_ns.doc(description="Delete a workflow variable") @console_ns.response(204, "Variable deleted successfully") @console_ns.response(404, "Variable not found") - @_api_prerequisite - def delete(self, current_user: Account, app_model: App, variable_id: UUID): - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), - ) - variable_id_str = str(variable_id) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id_str), - app_id=app_model.id, - variable_id=variable_id_str, - current_user_id=current_user.id, + @console_variable_admission("app") + def delete(self, request_context: RequestContext, app_id: UUID, variable_id: UUID): + application_services().console_workflow_variables.delete( + request_context, WorkflowOwner(str(app_id), "app"), str(variable_id) ) - draft_var_srv.delete_variable(variable) - db.session.commit() return Response("", 204) @@ -452,49 +262,14 @@ class VariableResetApi(Resource): ) @console_ns.response(204, "Variable reset (no content)") @console_ns.response(404, "Variable not found") - @_api_prerequisite - def put(self, current_user: Account, app_model: App, variable_id: UUID): - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), + @console_variable_admission("app") + def put(self, request_context: RequestContext, app_id: UUID, variable_id: UUID): + variable = application_services().console_workflow_variables.reset( + request_context, WorkflowOwner(str(app_id), "app"), str(variable_id) ) - - workflow_srv = WorkflowService() - draft_workflow = workflow_srv.get_draft_workflow(app_model, session=db.session()) - if draft_workflow is None: - raise NotFoundError( - f"Draft workflow not found, app_id={app_model.id}", - ) - variable_id_str = str(variable_id) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id_str), - app_id=app_model.id, - variable_id=variable_id_str, - current_user_id=current_user.id, - ) - - resetted = draft_var_srv.reset_variable(draft_workflow, variable) - db.session.commit() - if resetted is None: + if variable is None: return Response("", 204) - return dump_response(WorkflowDraftVariableResponse, resetted) - - -def _get_variable_list(app_model: App, node_id: str, current_user_id: str) -> WorkflowDraftVariableList: - with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: - draft_var_srv = WorkflowDraftVariableService( - session=session, - ) - if node_id == CONVERSATION_VARIABLE_NODE_ID: - draft_vars = draft_var_srv.list_conversation_variables(app_model.id, user_id=current_user_id) - elif node_id == SYSTEM_VARIABLE_NODE_ID: - draft_vars = draft_var_srv.list_system_variables(app_model.id, user_id=current_user_id) - else: - draft_vars = draft_var_srv.list_node_variables( - app_id=app_model.id, - node_id=node_id, - user_id=current_user_id, - ) - return draft_vars + return dump_response(WorkflowDraftVariableResponse, variable) @console_ns.route("/apps//workflows/draft/conversation-variables") @@ -508,22 +283,12 @@ class ConversationVariableCollectionApi(Resource): console_ns.models[WorkflowDraftVariableListResponse.__name__], ) @console_ns.response(404, "Draft workflow not found") - @_api_prerequisite - @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) - def get(self, current_user: Account, app_model: App): - # NOTE(QuantumGhost): Prefill conversation variables into the draft variables table - # so their IDs can be returned to the caller. - workflow_srv = WorkflowService() - draft_workflow = workflow_srv.get_draft_workflow(app_model, session=db.session()) - if draft_workflow is None: - raise NotFoundError(description=f"draft workflow not found, id={app_model.id}") - draft_var_srv = WorkflowDraftVariableService(db.session()) - draft_var_srv.prefill_conversation_variable_default_values(draft_workflow, user_id=current_user.id) - db.session.commit() - return dump_response( - WorkflowDraftVariableListResponse, - _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID, current_user.id), + @console_variable_admission("app") + def get(self, request_context: RequestContext, app_id: UUID): + variables = application_services().console_workflow_variables.conversation( + request_context, WorkflowOwner(str(app_id), "app") ) + return dump_response(WorkflowDraftVariableListResponse, variables) @console_ns.expect(console_ns.models[ConversationVariableUpdatePayload.__name__]) @console_ns.doc("update_conversation_variables") @@ -534,32 +299,11 @@ def get(self, current_user: Account, app_model: App): "Conversation variables updated successfully", console_ns.models[SimpleResultResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @edit_permission_required - @rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp())) - @with_current_user - @get_app_model(mode=AppMode.ADVANCED_CHAT) + @console_variable_admission("app", permission=RBACPermission.APP_EDIT) @model_validate(ConversationVariableUpdatePayload) - def post(self, req_data: ConversationVariableUpdatePayload, current_user: Account, app_model: App): - - workflow_service = WorkflowService() - - conversation_variables_list = [ - variable.model_dump(mode="json", exclude_unset=True) for variable in req_data.conversation_variables - ] - conversation_variables = [ - variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list - ] - - workflow_service.update_draft_workflow_conversation_variables( - app_model=app_model, - account=current_user, - conversation_variables=conversation_variables, - session=db.session(), - ) - + def post(self, req_data: ConversationVariableUpdatePayload, request_context: RequestContext, app_id: UUID): + values = [variable.model_dump(mode="json", exclude_unset=True) for variable in req_data.conversation_variables] + application_services().console_workflow_variables.update_conversation(request_context, str(app_id), values) return {"result": "success"} @@ -573,18 +317,17 @@ class SystemVariableCollectionApi(Resource): "System variables retrieved successfully", console_ns.models[WorkflowDraftVariableListResponse.__name__], ) - @_api_prerequisite - @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) - def get(self, current_user: Account, app_model: App): - return dump_response( - WorkflowDraftVariableListResponse, - _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID, current_user.id), + @console_variable_admission("app") + def get(self, request_context: RequestContext, app_id: UUID): + variables = application_services().console_workflow_variables.system( + request_context, WorkflowOwner(str(app_id), "app") ) + return dump_response(WorkflowDraftVariableListResponse, variables) @console_ns.route("/apps//workflows/draft/environment-variables") class EnvironmentVariableCollectionApi(Resource): - @console_ns.doc("get_environment_variables") + @console_ns.doc("get_environment_variables", summary="Get environment variables") @console_ns.doc(description="Get environment variables for workflow") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.response( @@ -593,38 +336,12 @@ class EnvironmentVariableCollectionApi(Resource): console_ns.models[EnvironmentVariableListResponse.__name__], ) @console_ns.response(404, "Draft workflow not found") - @_api_prerequisite - @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) - def get(self, _current_user: Account, app_model: App): - """ - Get draft workflow - """ - # fetch draft workflow by app_model - workflow_service = WorkflowService() - workflow = workflow_service.get_draft_workflow(app_model=app_model, session=db.session()) - if workflow is None: - raise DraftWorkflowNotExist() - - env_vars = workflow.environment_variables - env_vars_list = [] - for v in env_vars: - env_vars_list.append( - { - "id": v.id, - "type": "env", - "name": v.name, - "description": v.description, - "selector": v.selector, - "value_type": environment_variable_value_type(v), - "value": v.value, - # Do not track edited for env vars. - "edited": False, - "visible": True, - "editable": True, - } - ) - - return {"items": env_vars_list} + @console_variable_admission("app") + def get(self, request_context: RequestContext, app_id: UUID): + items = application_services().console_workflow_variables.environment( + request_context, WorkflowOwner(str(app_id), "app") + ) + return dump_response(EnvironmentVariableListResponse, {"items": items}) @console_ns.expect(console_ns.models[EnvironmentVariableUpdatePayload.__name__]) @console_ns.doc("update_environment_variables") @@ -635,39 +352,14 @@ def get(self, _current_user: Account, app_model: App): "Environment variables updated successfully", console_ns.models[SimpleResultResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @edit_permission_required - @rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp())) - @with_current_user - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @console_variable_admission("app", permission=RBACPermission.APP_EDIT) @model_validate(EnvironmentVariableUpdatePayload) - def post(self, req_data: EnvironmentVariableUpdatePayload, current_user: Account, app_model: App): - - workflow_service = WorkflowService() - - environment_variables_list = [ - variable.model_dump(mode="json", exclude_unset=True) for variable in req_data.environment_variables - ] - environment_variables = [ - variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list - ] - - if req_data.patch: - workflow_service.patch_draft_workflow_environment_variables( - app_model=app_model, - account=current_user, - environment_variables=environment_variables, - deleted_environment_variable_ids=req_data.deleted_environment_variable_ids, - session=db.session(), - ) - else: - workflow_service.update_draft_workflow_environment_variables( - app_model=app_model, - account=current_user, - environment_variables=environment_variables, - session=db.session(), - ) - + def post(self, req_data: EnvironmentVariableUpdatePayload, request_context: RequestContext, app_id: UUID): + values = [variable.model_dump(mode="json", exclude_unset=True) for variable in req_data.environment_variables] + application_services().console_workflow_variables.update_environment( + request_context, + str(app_id), + values, + deleted_ids=req_data.deleted_environment_variable_ids if req_data.patch else None, + ) return {"result": "success"} diff --git a/api/controllers/console/app/workflow_variable_admission.py b/api/controllers/console/app/workflow_variable_admission.py new file mode 100644 index 00000000000000..9ff35f9af8e9ec --- /dev/null +++ b/api/controllers/console/app/workflow_variable_admission.py @@ -0,0 +1,65 @@ +"""Admission and error mapping for all Console draft-variable owners.""" + +from collections.abc import Callable +from functools import wraps +from typing import Concatenate, Literal + +from controllers.common.errors import AccessDeniedError, InvalidArgumentError, NotFoundError +from controllers.common.rbac import DatasetByPipeline, PlainApp, RBACCheck +from controllers.console.app.error import AppNotFoundError, DraftWorkflowNotExist, DraftWorkflowNotSync +from controllers.console.datasets.error import PipelineNotFoundError +from controllers.console.flask_admission import console_account_admission +from controllers.console.wraps import RBACPermission +from enums.account import TenantAccountRole +from machinery.context import RequestContext +from services.errors.app import WorkflowHashNotEqualError, WorkflowNotFoundError +from services.errors.base import NoPermissionError +from services.workflow.contracts import DraftWorkflowMissingError +from services.workflow.variable_contracts import ( + DraftVariableChangedError, + DraftVariableNotFoundError, + DraftVariableOwnerNotFoundError, + InvalidDraftVariableError, +) + + +def console_variable_admission[T, **P, R]( + kind: Literal["app", "pipeline", "snippet"], *, permission: RBACPermission = RBACPermission.APP_VIEW_LAYOUT +): + checks = ( + [RBACCheck(RBACPermission.DATASET_EDIT, DatasetByPipeline())] + if kind == "pipeline" + else [RBACCheck(permission, PlainApp())] + if kind == "app" + else [] + ) + + def decorate(view: Callable[Concatenate[T, RequestContext, P], R]): + @console_account_admission( + allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}), + rbac_checks=checks, + ) + @wraps(view) + def admitted(self: T, context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R: + try: + return view(self, context, *args, **kwargs) + except DraftVariableOwnerNotFoundError as error: + if error.kind == "app": + raise AppNotFoundError() from error + if error.kind == "pipeline": + raise PipelineNotFoundError() from error + raise NotFoundError(str(error)) from error + except DraftWorkflowMissingError as error: + raise DraftWorkflowNotExist() from error + except (DraftVariableNotFoundError, WorkflowNotFoundError) as error: + raise NotFoundError(str(error)) from error + except (WorkflowHashNotEqualError, DraftVariableChangedError) as error: + raise DraftWorkflowNotSync() from error + except InvalidDraftVariableError as error: + raise InvalidArgumentError(str(error)) from error + except NoPermissionError as error: + raise AccessDeniedError() from error + + return admitted + + return decorate diff --git a/api/controllers/console/datasets/__init__.py b/api/controllers/console/datasets/__init__.py new file mode 100644 index 00000000000000..da5d572f761b80 --- /dev/null +++ b/api/controllers/console/datasets/__init__.py @@ -0,0 +1 @@ +"""Console dataset adapters.""" diff --git a/api/controllers/console/datasets/rag_pipeline/__init__.py b/api/controllers/console/datasets/rag_pipeline/__init__.py new file mode 100644 index 00000000000000..4fd882ef555c78 --- /dev/null +++ b/api/controllers/console/datasets/rag_pipeline/__init__.py @@ -0,0 +1 @@ +"""Console adapters for RAG pipelines.""" diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py index 336eab18c5de5d..ea0cead3b059d8 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py @@ -1,56 +1,28 @@ -import logging -from collections.abc import Callable -from functools import wraps -from typing import Any, Concatenate, NoReturn +from typing import Any from uuid import UUID from flask import Response from flask_restx import Resource from pydantic import BaseModel, Field -from sqlalchemy.orm import sessionmaker -from controllers.common.errors import InvalidArgumentError, NotFoundError -from controllers.common.rbac import DatasetByPipeline, RBACCheck from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns -from controllers.console.app.error import ( - DraftWorkflowNotExist, -) from controllers.console.app.workflow_draft_variable import ( EnvironmentVariableListResponse, - ensure_variable_access, ) -from controllers.console.datasets.wraps import get_rag_pipeline +from controllers.console.app.workflow_variable_admission import console_variable_admission from controllers.console.wraps import ( - RBACPermission, - account_initialization_required, - edit_permission_required, model_validate, - rbac_permission_required, - setup_required, - with_current_user, ) -from core.app.file_access import DatabaseFileAccessController -from core.workflow.llm_environment_variable import LLMEnvironmentVariable, environment_variable_value_type -from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID -from extensions.ext_database import db -from factories.file_factory import build_from_mapping, build_from_mappings -from factories.variable_factory import build_segment_with_type +from extensions.ext_application_services import application_services from fields.workflow_draft_variable_fields import ( WorkflowDraftVariableListResponse, WorkflowDraftVariableListWithoutValueResponse, WorkflowDraftVariableResponse, ) -from graphon.variables.types import SegmentType from libs.helper import dump_response -from libs.login import login_required -from models import Account -from models.dataset import Pipeline -from services.rag_pipeline.rag_pipeline import RagPipelineService -from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService - -logger = logging.getLogger(__name__) -_file_access_controller = DatabaseFileAccessController() +from machinery.context import RequestContext +from services.workflow.contracts import WorkflowOwner class PaginationQuery(BaseModel): @@ -66,33 +38,6 @@ class WorkflowDraftVariablePatchPayload(BaseModel): register_schema_models(console_ns, PaginationQuery, WorkflowDraftVariablePatchPayload) -def _api_prerequisite[T, **P, R]( - f: Callable[Concatenate[T, Account, P], R], -) -> Callable[Concatenate[T, P], R | Response]: - """Common prerequisites for all draft workflow variable APIs. - - It ensures the following conditions are satisfied: - - - Dify has been properly set up. - - The request user has logged in and initialized. - - The requested RAG pipeline belongs to the current tenant. - - The request user has legacy edit permission or Dataset edit RBAC access. - """ - - @setup_required - @login_required - @account_initialization_required - @get_rag_pipeline - @edit_permission_required - @rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetByPipeline())) - @with_current_user - @wraps(f) - def wrapper(self: T, current_user: Account, *args: P.args, **kwargs: P.kwargs) -> R | Response: - return f(self, current_user, *args, **kwargs) - - return wrapper - - @console_ns.route("/rag/pipelines//workflows/draft/variables") class RagPipelineVariableCollectionApi(Resource): @console_ns.doc(params=query_params_from_model(PaginationQuery)) @@ -101,62 +46,24 @@ class RagPipelineVariableCollectionApi(Resource): "Workflow variables retrieved successfully", console_ns.models[WorkflowDraftVariableListWithoutValueResponse.__name__], ) - @_api_prerequisite + @console_variable_admission("pipeline") @model_validate(PaginationQuery) - def get(self, req_data: PaginationQuery, current_user: Account, pipeline: Pipeline): - """ - Get draft workflow - """ - - # fetch draft workflow by app_model - rag_pipeline_service = RagPipelineService(db.session()) - workflow_exist = rag_pipeline_service.is_workflow_exist(pipeline=pipeline) - if not workflow_exist: - raise DraftWorkflowNotExist() - - # fetch draft workflow by app_model - with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: - draft_var_srv = WorkflowDraftVariableService( - session=session, - ) - workflow_vars = draft_var_srv.list_variables_without_values( - app_id=pipeline.id, - page=req_data.page, - limit=req_data.limit, - user_id=current_user.id, + def get(self, req_data: PaginationQuery, request_context: RequestContext, pipeline_id: UUID): + """List draft pipeline variables without loading their values.""" + variables = application_services().console_workflow_variables.list_variables( + request_context, WorkflowOwner(str(pipeline_id), "pipeline"), page=req_data.page, limit=req_data.limit ) - - return dump_response(WorkflowDraftVariableListWithoutValueResponse, workflow_vars) + return dump_response(WorkflowDraftVariableListWithoutValueResponse, variables) @console_ns.response(204, "Workflow variables deleted successfully") - @_api_prerequisite - def delete(self, current_user: Account, pipeline: Pipeline): - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), + @console_variable_admission("pipeline") + def delete(self, request_context: RequestContext, pipeline_id: UUID): + application_services().console_workflow_variables.delete_all( + request_context, WorkflowOwner(str(pipeline_id), "pipeline") ) - draft_var_srv.delete_user_workflow_variables(pipeline.id, user_id=current_user.id) - db.session.commit() return Response("", 204) -def validate_node_id(node_id: str) -> NoReturn | None: - if node_id in [ - CONVERSATION_VARIABLE_NODE_ID, - SYSTEM_VARIABLE_NODE_ID, - ]: - # NOTE(QuantumGhost): While we store the system and conversation variables as node variables - # with specific `node_id` in database, we still want to make the API separated. By disallowing - # accessing system and conversation variables in `WorkflowDraftNodeVariableListApi`, - # we mitigate the risk that user of the API depending on the implementation detail of the API. - # - # ref: [Hyrum's Law](https://www.hyrumslaw.com/) - - raise InvalidArgumentError( - f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}", - ) - return None - - @console_ns.route("/rag/pipelines//workflows/draft/nodes//variables") class RagPipelineNodeVariableCollectionApi(Resource): @console_ns.response( @@ -164,48 +71,33 @@ class RagPipelineNodeVariableCollectionApi(Resource): "Node variables retrieved successfully", console_ns.models[WorkflowDraftVariableListResponse.__name__], ) - @_api_prerequisite - def get(self, current_user: Account, pipeline: Pipeline, node_id: str): - validate_node_id(node_id) - with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: - draft_var_srv = WorkflowDraftVariableService( - session=session, - ) - node_vars = draft_var_srv.list_node_variables(pipeline.id, node_id, user_id=current_user.id) - - return dump_response(WorkflowDraftVariableListResponse, node_vars) + @console_variable_admission("pipeline") + def get(self, request_context: RequestContext, pipeline_id: UUID, node_id: str): + variables = application_services().console_workflow_variables.node( + request_context, WorkflowOwner(str(pipeline_id), "pipeline"), node_id + ) + return dump_response(WorkflowDraftVariableListResponse, variables) @console_ns.response(204, "Node variables deleted successfully") - @_api_prerequisite - def delete(self, current_user: Account, pipeline: Pipeline, node_id: str): - validate_node_id(node_id) - srv = WorkflowDraftVariableService(db.session()) - srv.delete_node_variables(pipeline.id, node_id, user_id=current_user.id) - db.session.commit() + @console_variable_admission("pipeline") + def delete(self, request_context: RequestContext, pipeline_id: UUID, node_id: str): + application_services().console_workflow_variables.delete_node( + request_context, WorkflowOwner(str(pipeline_id), "pipeline"), node_id + ) return Response("", 204) @console_ns.route("/rag/pipelines//workflows/draft/variables/") class RagPipelineVariableApi(Resource): - _PATCH_NAME_FIELD = "name" - _PATCH_VALUE_FIELD = "value" - @console_ns.response( 200, "Variable retrieved successfully", console_ns.models[WorkflowDraftVariableResponse.__name__], ) - @_api_prerequisite - def get(self, current_user: Account, pipeline: Pipeline, variable_id: UUID): - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), - ) - variable_id_str = str(variable_id) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id_str), - app_id=pipeline.id, - variable_id=variable_id_str, - current_user_id=current_user.id, + @console_variable_admission("pipeline") + def get(self, request_context: RequestContext, pipeline_id: UUID, variable_id: UUID): + variable = application_services().console_workflow_variables.get( + request_context, WorkflowOwner(str(pipeline_id), "pipeline"), str(variable_id) ) return dump_response(WorkflowDraftVariableResponse, variable) @@ -214,98 +106,31 @@ def get(self, current_user: Account, pipeline: Pipeline, variable_id: UUID): "Variable updated successfully", console_ns.models[WorkflowDraftVariableResponse.__name__], ) - @_api_prerequisite + @console_variable_admission("pipeline") @console_ns.expect(console_ns.models[WorkflowDraftVariablePatchPayload.__name__]) @model_validate(WorkflowDraftVariablePatchPayload) def patch( self, req_data: WorkflowDraftVariablePatchPayload, - current_user: Account, - pipeline: Pipeline, + request_context: RequestContext, + pipeline_id: UUID, variable_id: UUID, ): - # Request payload for file types: - # - # Local File: - # - # { - # "type": "image", - # "transfer_method": "local_file", - # "url": "", - # "upload_file_id": "daded54f-72c7-4f8e-9d18-9b0abdd9f190" - # } - # - # Remote File: - # - # - # { - # "type": "image", - # "transfer_method": "remote_url", - # "url": "http://127.0.0.1:5001/files/1602650a-4fe4-423c-85a2-af76c083e3c4/file-preview?timestamp=1750041099&nonce=...&sign=...=", - # "upload_file_id": "1602650a-4fe4-423c-85a2-af76c083e3c4" - # } - - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), - ) - args = req_data.model_dump(exclude_none=True) - - variable_id_str = str(variable_id) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id_str), - app_id=pipeline.id, - variable_id=variable_id_str, - current_user_id=current_user.id, + variable = application_services().console_workflow_variables.patch( + request_context, + WorkflowOwner(str(pipeline_id), "pipeline"), + str(variable_id), + name=req_data.name, + value=req_data.value, ) - - new_name = args.get(self._PATCH_NAME_FIELD, None) - raw_value = args.get(self._PATCH_VALUE_FIELD, None) - if new_name is None and raw_value is None: - return dump_response(WorkflowDraftVariableResponse, variable) - - new_value = None - if raw_value is not None: - match variable.value_type: - case SegmentType.FILE: - if not isinstance(raw_value, dict): - raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( - mapping=raw_value, - tenant_id=pipeline.tenant_id, - access_controller=_file_access_controller, - ) - case SegmentType.ARRAY_FILE: - if not isinstance(raw_value, list): - raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( - mappings=raw_value, - tenant_id=pipeline.tenant_id, - access_controller=_file_access_controller, - ) - case _: - pass - new_value = build_segment_with_type(variable.value_type, raw_value) - draft_var_srv.update_variable(variable, name=new_name, value=new_value) - db.session.commit() return dump_response(WorkflowDraftVariableResponse, variable) @console_ns.response(204, "Variable deleted successfully") - @_api_prerequisite - def delete(self, current_user: Account, pipeline: Pipeline, variable_id: UUID): - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), + @console_variable_admission("pipeline") + def delete(self, request_context: RequestContext, pipeline_id: UUID, variable_id: UUID): + application_services().console_workflow_variables.delete( + request_context, WorkflowOwner(str(pipeline_id), "pipeline"), str(variable_id) ) - variable_id_str = str(variable_id) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id_str), - app_id=pipeline.id, - variable_id=variable_id_str, - current_user_id=current_user.id, - ) - draft_var_srv.delete_variable(variable) - db.session.commit() return Response("", 204) @@ -317,45 +142,14 @@ class RagPipelineVariableResetApi(Resource): console_ns.models[WorkflowDraftVariableResponse.__name__], ) @console_ns.response(204, "Variable reset (no content)") - @_api_prerequisite - def put(self, current_user: Account, pipeline: Pipeline, variable_id: UUID): - draft_var_srv = WorkflowDraftVariableService( - session=db.session(), - ) - - rag_pipeline_service = RagPipelineService(db.session()) - draft_workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline) - if draft_workflow is None: - raise NotFoundError( - f"Draft workflow not found, pipeline_id={pipeline.id}", - ) - variable_id_str = str(variable_id) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id_str), - app_id=pipeline.id, - variable_id=variable_id_str, - current_user_id=current_user.id, + @console_variable_admission("pipeline") + def put(self, request_context: RequestContext, pipeline_id: UUID, variable_id: UUID): + variable = application_services().console_workflow_variables.reset( + request_context, WorkflowOwner(str(pipeline_id), "pipeline"), str(variable_id) ) - - resetted = draft_var_srv.reset_variable(draft_workflow, variable) - db.session.commit() - if resetted is None: + if variable is None: return Response("", 204) - return dump_response(WorkflowDraftVariableResponse, resetted) - - -def _get_variable_list(pipeline: Pipeline, node_id: str, current_user_id: str) -> WorkflowDraftVariableList: - with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: - draft_var_srv = WorkflowDraftVariableService( - session=session, - ) - if node_id == CONVERSATION_VARIABLE_NODE_ID: - draft_vars = draft_var_srv.list_conversation_variables(pipeline.id, user_id=current_user_id) - elif node_id == SYSTEM_VARIABLE_NODE_ID: - draft_vars = draft_var_srv.list_system_variables(pipeline.id, user_id=current_user_id) - else: - draft_vars = draft_var_srv.list_node_variables(app_id=pipeline.id, node_id=node_id, user_id=current_user_id) - return draft_vars + return dump_response(WorkflowDraftVariableResponse, variable) @console_ns.route("/rag/pipelines//workflows/draft/system-variables") @@ -365,53 +159,25 @@ class RagPipelineSystemVariableCollectionApi(Resource): "System variables retrieved successfully", console_ns.models[WorkflowDraftVariableListResponse.__name__], ) - @_api_prerequisite - def get(self, current_user: Account, pipeline: Pipeline): - return dump_response( - WorkflowDraftVariableListResponse, - _get_variable_list(pipeline, SYSTEM_VARIABLE_NODE_ID, current_user.id), + @console_variable_admission("pipeline") + def get(self, request_context: RequestContext, pipeline_id: UUID): + variables = application_services().console_workflow_variables.system( + request_context, WorkflowOwner(str(pipeline_id), "pipeline") ) + return dump_response(WorkflowDraftVariableListResponse, variables) @console_ns.route("/rag/pipelines//workflows/draft/environment-variables") class RagPipelineEnvironmentVariableCollectionApi(Resource): + @console_ns.doc(summary="Get environment variables") @console_ns.response( 200, "Environment variables retrieved successfully", console_ns.models[EnvironmentVariableListResponse.__name__], ) - @_api_prerequisite - def get(self, _current_user: Account, pipeline: Pipeline): - """ - Get draft workflow - """ - # fetch draft workflow by app_model - rag_pipeline_service = RagPipelineService(db.session()) - workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline) - if workflow is None: - raise DraftWorkflowNotExist() - - env_vars = workflow.environment_variables - env_vars_list = [] - for v in env_vars: - env_vars_list.append( - { - "id": v.id, - "type": "env", - "name": v.name, - "description": v.description, - "selector": v.selector, - "value_type": ( - environment_variable_value_type(v) - if isinstance(v, LLMEnvironmentVariable) - else v.value_type.value - ), - "value": v.value, - # Do not track edited for env vars. - "edited": False, - "visible": True, - "editable": True, - } - ) - - return {"items": env_vars_list} + @console_variable_admission("pipeline") + def get(self, request_context: RequestContext, pipeline_id: UUID): + items = application_services().console_workflow_variables.environment( + request_context, WorkflowOwner(str(pipeline_id), "pipeline") + ) + return dump_response(EnvironmentVariableListResponse, {"items": items}) diff --git a/api/controllers/console/snippets/__init__.py b/api/controllers/console/snippets/__init__.py new file mode 100644 index 00000000000000..05048048a1c162 --- /dev/null +++ b/api/controllers/console/snippets/__init__.py @@ -0,0 +1 @@ +"""Console adapters for Snippet workflows.""" diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py index f3cf9c0a284828..b6d13b7e126f65 100644 --- a/api/controllers/console/snippets/snippet_workflow_draft_variable.py +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -10,90 +10,31 @@ Other routes mirror `workflow_draft_variable` app APIs under `/snippets/...`. """ -from collections.abc import Callable -from functools import wraps -from typing import Any, Concatenate +from uuid import UUID from flask import Response from flask_restx import Resource -from sqlalchemy.orm import Session -from controllers.common.errors import InvalidArgumentError, NotFoundError from controllers.common.schema import query_params_from_model -from controllers.common.session import with_session from controllers.console import console_ns -from controllers.console.app.error import DraftWorkflowNotExist from controllers.console.app.workflow_draft_variable import ( EnvironmentVariableListResponse, WorkflowDraftVariableListQuery, WorkflowDraftVariableUpdatePayload, - ensure_variable_access, - validate_node_id, ) -from controllers.console.snippets.snippet_workflow import get_snippet +from controllers.console.app.workflow_variable_admission import console_variable_admission from controllers.console.wraps import ( - account_initialization_required, - edit_permission_required, model_validate, - setup_required, - with_current_user, ) -from core.app.file_access import DatabaseFileAccessController -from core.db.session_factory import session_factory -from core.workflow.llm_environment_variable import environment_variable_value_type -from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID -from factories.file_factory import build_from_mapping, build_from_mappings -from factories.variable_factory import build_segment_with_type +from extensions.ext_application_services import application_services from fields.workflow_draft_variable_fields import ( WorkflowDraftVariableListResponse, WorkflowDraftVariableListWithoutValueResponse, WorkflowDraftVariableResponse, ) -from graphon.variables.types import SegmentType from libs.helper import dump_response -from libs.login import login_required -from models import Account -from models.snippet import CustomizedSnippet -from models.workflow import WorkflowDraftVariable -from services.snippet_service import SnippetService -from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService - -_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: frozenset[str] = frozenset( - {SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID} -) -_file_access_controller = DatabaseFileAccessController() - - -def _snippet_service() -> SnippetService: - return SnippetService(session_factory.get_session_maker()) - - -def _ensure_snippet_draft_variable_row_allowed( - *, - variable: WorkflowDraftVariable, - variable_id: str, -) -> None: - """Snippet scope only supports canvas-node draft variables; treat sys/conversation rows as not found.""" - if variable.node_id in _SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: - raise NotFoundError(description=f"variable not found, id={variable_id}") - - -def _snippet_draft_var_prerequisite[T, **P, R]( - f: Callable[Concatenate[T, Account, P], R], -) -> Callable[Concatenate[T, P], R | Response]: - """Setup, auth, snippet resolution, and tenant edit permission (same stack as snippet workflow APIs).""" - - @setup_required - @login_required - @account_initialization_required - @get_snippet - @edit_permission_required - @with_current_user - @wraps(f) - def wrapper(self: T, current_user: Account, *args: P.args, **kwargs: P.kwargs) -> R | Response: - return f(self, current_user, *args, **kwargs) - - return wrapper +from machinery.context import RequestContext +from services.workflow.contracts import WorkflowOwner @console_ns.route("/snippets//workflows/draft/variables") @@ -106,39 +47,22 @@ class SnippetWorkflowVariableCollectionApi(Resource): "Workflow variables retrieved successfully", console_ns.models[WorkflowDraftVariableListWithoutValueResponse.__name__], ) - @_snippet_draft_var_prerequisite + @console_variable_admission("snippet") @model_validate(WorkflowDraftVariableListQuery) - @with_session(write=False) - def get( - self, - session: Session, - args: WorkflowDraftVariableListQuery, - current_user: Account, - snippet: CustomizedSnippet, - ) -> dict[str, Any]: - snippet_service = _snippet_service() - if snippet_service.get_draft_workflow(snippet=snippet) is None: - raise DraftWorkflowNotExist() - - draft_var_srv = WorkflowDraftVariableService(session=session) - workflow_vars = draft_var_srv.list_variables_without_values( - app_id=snippet.id, - page=args.page, - limit=args.limit, - user_id=current_user.id, - exclude_node_ids=_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS, + def get(self, req_data: WorkflowDraftVariableListQuery, request_context: RequestContext, snippet_id: UUID): + variables = application_services().console_workflow_variables.list_variables( + request_context, WorkflowOwner(str(snippet_id), "snippet"), page=req_data.page, limit=req_data.limit ) - - return dump_response(WorkflowDraftVariableListWithoutValueResponse, workflow_vars) + return dump_response(WorkflowDraftVariableListWithoutValueResponse, variables) @console_ns.doc("delete_snippet_workflow_variables") @console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)") @console_ns.response(204, "Workflow variables deleted successfully") - @_snippet_draft_var_prerequisite - @with_session - def delete(self, session: Session, current_user: Account, snippet: CustomizedSnippet) -> Response: - draft_var_srv = WorkflowDraftVariableService(session=session) - draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id) + @console_variable_admission("snippet") + def delete(self, request_context: RequestContext, snippet_id: UUID): + application_services().console_workflow_variables.delete_all( + request_context, WorkflowOwner(str(snippet_id), "snippet") + ) return Response("", 204) @@ -151,24 +75,21 @@ class SnippetNodeVariableCollectionApi(Resource): "Node variables retrieved successfully", console_ns.models[WorkflowDraftVariableListResponse.__name__], ) - @_snippet_draft_var_prerequisite - @with_session(write=False) - def get(self, session: Session, current_user: Account, snippet: CustomizedSnippet, node_id: str) -> dict[str, Any]: - validate_node_id(node_id) - draft_var_srv = WorkflowDraftVariableService(session=session) - node_vars = draft_var_srv.list_node_variables(snippet.id, node_id, user_id=current_user.id) - - return dump_response(WorkflowDraftVariableListResponse, node_vars) + @console_variable_admission("snippet") + def get(self, request_context: RequestContext, snippet_id: UUID, node_id: str): + variables = application_services().console_workflow_variables.node( + request_context, WorkflowOwner(str(snippet_id), "snippet"), node_id + ) + return dump_response(WorkflowDraftVariableListResponse, variables) @console_ns.doc("delete_snippet_node_variables") @console_ns.doc(description="Delete all variables for a specific node (snippet draft workflow)") @console_ns.response(204, "Node variables deleted successfully") - @_snippet_draft_var_prerequisite - @with_session - def delete(self, session: Session, current_user: Account, snippet: CustomizedSnippet, node_id: str) -> Response: - validate_node_id(node_id) - srv = WorkflowDraftVariableService(session) - srv.delete_node_variables(snippet.id, node_id, user_id=current_user.id) + @console_variable_admission("snippet") + def delete(self, request_context: RequestContext, snippet_id: UUID, node_id: str): + application_services().console_workflow_variables.delete_node( + request_context, WorkflowOwner(str(snippet_id), "snippet"), node_id + ) return Response("", 204) @@ -182,19 +103,11 @@ class SnippetVariableApi(Resource): console_ns.models[WorkflowDraftVariableResponse.__name__], ) @console_ns.response(404, "Variable not found") - @_snippet_draft_var_prerequisite - @with_session(write=False) - def get( - self, session: Session, current_user: Account, snippet: CustomizedSnippet, variable_id: str - ) -> dict[str, Any]: - draft_var_srv = WorkflowDraftVariableService(session=session) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id), - app_id=snippet.id, - variable_id=variable_id, - current_user_id=current_user.id, + @console_variable_admission("snippet") + def get(self, request_context: RequestContext, snippet_id: UUID, variable_id: UUID): + variable = application_services().console_workflow_variables.get( + request_context, WorkflowOwner(str(snippet_id), "snippet"), str(variable_id) ) - _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) return dump_response(WorkflowDraftVariableResponse, variable) @console_ns.doc("update_snippet_workflow_variable") @@ -206,72 +119,33 @@ def get( console_ns.models[WorkflowDraftVariableResponse.__name__], ) @console_ns.response(404, "Variable not found") - @_snippet_draft_var_prerequisite - @with_session + @console_variable_admission("snippet") @model_validate(WorkflowDraftVariableUpdatePayload) def patch( self, req_data: WorkflowDraftVariableUpdatePayload, - session: Session, - current_user: Account, - snippet: CustomizedSnippet, - variable_id: str, - ) -> dict[str, Any]: - draft_var_srv = WorkflowDraftVariableService(session=session) - - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id), - app_id=snippet.id, - variable_id=variable_id, - current_user_id=current_user.id, + request_context: RequestContext, + snippet_id: UUID, + variable_id: UUID, + ): + variable = application_services().console_workflow_variables.patch( + request_context, + WorkflowOwner(str(snippet_id), "snippet"), + str(variable_id), + name=req_data.name, + value=req_data.value, ) - _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) - - new_name = req_data.name - raw_value = req_data.value - if new_name is None and raw_value is None: - return dump_response(WorkflowDraftVariableResponse, variable) - - new_value = None - if raw_value is not None: - if variable.value_type == SegmentType.FILE: - if not isinstance(raw_value, dict): - raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") - raw_value = build_from_mapping( - mapping=raw_value, - tenant_id=snippet.tenant_id, - access_controller=_file_access_controller, - ) - elif variable.value_type == SegmentType.ARRAY_FILE: - if not isinstance(raw_value, list): - raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") - if len(raw_value) > 0 and not isinstance(raw_value[0], dict): - raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") - raw_value = build_from_mappings( - mappings=raw_value, - tenant_id=snippet.tenant_id, - access_controller=_file_access_controller, - ) - new_value = build_segment_with_type(variable.value_type, raw_value) - draft_var_srv.update_variable(variable, name=new_name, value=new_value) return dump_response(WorkflowDraftVariableResponse, variable) @console_ns.doc("delete_snippet_workflow_variable") @console_ns.doc(description="Delete a draft workflow variable (snippet scope)") @console_ns.response(204, "Variable deleted successfully") @console_ns.response(404, "Variable not found") - @_snippet_draft_var_prerequisite - @with_session - def delete(self, session: Session, current_user: Account, snippet: CustomizedSnippet, variable_id: str) -> Response: - draft_var_srv = WorkflowDraftVariableService(session=session) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id), - app_id=snippet.id, - variable_id=variable_id, - current_user_id=current_user.id, + @console_variable_admission("snippet") + def delete(self, request_context: RequestContext, snippet_id: UUID, variable_id: UUID): + application_services().console_workflow_variables.delete( + request_context, WorkflowOwner(str(snippet_id), "snippet"), str(variable_id) ) - _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) - draft_var_srv.delete_variable(variable) return Response("", 204) @@ -286,30 +160,14 @@ class SnippetVariableResetApi(Resource): ) @console_ns.response(204, "Variable reset (no content)") @console_ns.response(404, "Variable not found") - @_snippet_draft_var_prerequisite - @with_session - def put( - self, session: Session, current_user: Account, snippet: CustomizedSnippet, variable_id: str - ) -> Response | Any: - draft_var_srv = WorkflowDraftVariableService(session=session) - snippet_service = _snippet_service() - draft_workflow = snippet_service.get_draft_workflow(snippet=snippet) - if draft_workflow is None: - raise NotFoundError( - f"Draft workflow not found, snippet_id={snippet.id}", - ) - variable = ensure_variable_access( - variable=draft_var_srv.get_variable(variable_id=variable_id), - app_id=snippet.id, - variable_id=variable_id, - current_user_id=current_user.id, + @console_variable_admission("snippet") + def put(self, request_context: RequestContext, snippet_id: UUID, variable_id: UUID): + variable = application_services().console_workflow_variables.reset( + request_context, WorkflowOwner(str(snippet_id), "snippet"), str(variable_id) ) - _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) - - resetted = draft_var_srv.reset_variable(draft_workflow, variable) - if resetted is None: + if variable is None: return Response("", 204) - return dump_response(WorkflowDraftVariableResponse, resetted) + return dump_response(WorkflowDraftVariableResponse, variable) @console_ns.route("/snippets//workflows/draft/conversation-variables") @@ -323,9 +181,12 @@ class SnippetConversationVariableCollectionApi(Resource): "Conversation variables retrieved successfully", console_ns.models[WorkflowDraftVariableListResponse.__name__], ) - @_snippet_draft_var_prerequisite - def get(self, _current_user: Account, snippet: CustomizedSnippet) -> dict[str, Any]: - return dump_response(WorkflowDraftVariableListResponse, WorkflowDraftVariableList(variables=[])) + @console_variable_admission("snippet") + def get(self, request_context: RequestContext, snippet_id: UUID): + variables = application_services().console_workflow_variables.conversation( + request_context, WorkflowOwner(str(snippet_id), "snippet") + ) + return dump_response(WorkflowDraftVariableListResponse, variables) @console_ns.route("/snippets//workflows/draft/system-variables") @@ -339,9 +200,12 @@ class SnippetSystemVariableCollectionApi(Resource): "System variables retrieved successfully", console_ns.models[WorkflowDraftVariableListResponse.__name__], ) - @_snippet_draft_var_prerequisite - def get(self, _current_user: Account, snippet: CustomizedSnippet) -> dict[str, Any]: - return dump_response(WorkflowDraftVariableListResponse, WorkflowDraftVariableList(variables=[])) + @console_variable_admission("snippet") + def get(self, request_context: RequestContext, snippet_id: UUID): + variables = application_services().console_workflow_variables.system( + request_context, WorkflowOwner(str(snippet_id), "snippet") + ) + return dump_response(WorkflowDraftVariableListResponse, variables) @console_ns.route("/snippets//workflows/draft/environment-variables") @@ -354,28 +218,9 @@ class SnippetEnvironmentVariableCollectionApi(Resource): console_ns.models[EnvironmentVariableListResponse.__name__], ) @console_ns.response(404, "Draft workflow not found") - @_snippet_draft_var_prerequisite - def get(self, _current_user: Account, snippet: CustomizedSnippet) -> dict[str, list[dict[str, Any]]]: - snippet_service = _snippet_service() - workflow = snippet_service.get_draft_workflow(snippet=snippet) - if workflow is None: - raise DraftWorkflowNotExist() - - env_vars_list: list[dict[str, Any]] = [] - for v in workflow.environment_variables: - env_vars_list.append( - { - "id": v.id, - "type": "env", - "name": v.name, - "description": v.description, - "selector": v.selector, - "value_type": environment_variable_value_type(v), - "value": v.value, - "edited": False, - "visible": True, - "editable": True, - } - ) - - return {"items": env_vars_list} + @console_variable_admission("snippet") + def get(self, request_context: RequestContext, snippet_id: UUID): + items = application_services().console_workflow_variables.environment( + request_context, WorkflowOwner(str(snippet_id), "snippet") + ) + return dump_response(EnvironmentVariableListResponse, {"items": items}) diff --git a/api/controllers/service_api/__init__.py b/api/controllers/service_api/__init__.py index 3569bbea9b146c..bc2ee64b406f69 100644 --- a/api/controllers/service_api/__init__.py +++ b/api/controllers/service_api/__init__.py @@ -1,7 +1,7 @@ from flask import Blueprint from flask_restx import Namespace -from controllers.common.errors import register_permission_error_handler +from controllers.common.errors import register_permission_error_handler, register_resource_error_handlers from libs.external_api import ExternalApi bp = Blueprint("service_api", __name__, url_prefix="/v1") @@ -14,6 +14,7 @@ register_default_root=False, ) register_permission_error_handler(api) +register_resource_error_handlers(api) service_api_ns = Namespace("service_api", description="Service operations", path="/") diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 10763ce96af197..457ed72afe19ff 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -37,6 +37,10 @@ ) from extensions.application_services.trial_app import TrialAppServices, build_trial_app_services from extensions.application_services.workflow import build_workflow_suggestions +from extensions.application_services.workflow_variables import ( + build_console_workflow_variables, + build_workflow_variable_service, +) from extensions.application_services.workspace import ( WorkspaceServices, build_workspace_membership_services, @@ -186,7 +190,9 @@ from services.web_passport_service import WebPassportService from services.webapp_access_adapters import EnterpriseWebAppAccessPolicyGateway from services.webapp_access_query_service import WebAppAccessQueryService, WebAppAccessUnavailableError +from services.workflow.console_variable_service import ConsoleWorkflowVariableService from services.workflow.generation.suggestions import WorkflowInstructionSuggestions +from services.workflow.variable_service import WorkflowVariableService from services.workflow_app_log_query_service import WorkflowAppLogQueryService from services.workflow_run_service import WorkflowRunService from services.workflow_statistic_query_service import WorkflowStatisticQueryService @@ -240,6 +246,8 @@ class AppScopedEndUserServices: @dataclass(frozen=True, slots=True) class ApplicationServices: + workflow_variables: WorkflowVariableService + console_workflow_variables: ConsoleWorkflowVariableService workflow_suggestions: WorkflowInstructionSuggestions agent_apps: AgentAppServices advanced_prompt_templates: AdvancedPromptTemplateService @@ -493,7 +501,12 @@ def build_application_services( registration=account_services.lifecycle, invitation_tokens=invitation_tokens, ) + workflow_variables = build_workflow_variable_service(database_client=database_client) return ApplicationServices( + workflow_variables=workflow_variables, + console_workflow_variables=build_console_workflow_variables( + database_client=database_client, variables=workflow_variables + ), workflow_suggestions=build_workflow_suggestions(database_client), accounts=account_services, apps=apps, diff --git a/api/fields/workflow_draft_variable_fields.py b/api/fields/workflow_draft_variable_fields.py index 32a68199cc7b0a..832923f7f0cb01 100644 --- a/api/fields/workflow_draft_variable_fields.py +++ b/api/fields/workflow_draft_variable_fields.py @@ -10,7 +10,7 @@ from graphon.variables.segment_group import SegmentGroup from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment from models.workflow import WorkflowDraftVariable -from services.workflow.variable_contracts import DraftVariableView +from services.workflow.variable_contracts import ConsoleVariableList, DraftVariableView, WorkflowDraftVariableList type JSONValue = str | int | float | bool | dict[str, "JSONValue"] | list["JSONValue"] | None @@ -144,7 +144,7 @@ class WorkflowDraftVariableListWithoutValueResponse(ResponseModel): @model_validator(mode="before") @classmethod def _from_workflow_draft_variable_list(cls, value: Any) -> Any: - if hasattr(value, "variables") and hasattr(value, "total"): + if isinstance(value, WorkflowDraftVariableList | ConsoleVariableList): return {"items": value.variables, "total": value.total} return value @@ -155,6 +155,6 @@ class WorkflowDraftVariableListResponse(ResponseModel): @model_validator(mode="before") @classmethod def _from_workflow_draft_variable_list(cls, value: Any) -> Any: - if hasattr(value, "variables"): + if isinstance(value, WorkflowDraftVariableList | ConsoleVariableList): return {"items": value.variables} return value diff --git a/api/services/errors/dataset.py b/api/services/errors/dataset.py index d36cd1111c78f8..de8f6f342fb7f0 100644 --- a/api/services/errors/dataset.py +++ b/api/services/errors/dataset.py @@ -1,6 +1,10 @@ from services.errors.base import BaseServiceError +class DatasetNotFoundError(Exception): + """The requested dataset is unavailable.""" + + class DatasetNameDuplicateError(BaseServiceError): pass diff --git a/api/services/errors/document.py b/api/services/errors/document.py index 7327b9d032b7db..d5eb86c162af36 100644 --- a/api/services/errors/document.py +++ b/api/services/errors/document.py @@ -1,5 +1,17 @@ from services.errors.base import BaseServiceError +class DocumentNotFoundError(Exception): + """The requested document is unavailable.""" + + +class DocumentSourceNotFoundError(Exception): + """A document has no available source file for download.""" + + +class DocumentAccessDeniedError(Exception): + """The actor cannot access the document's dataset.""" + + class DocumentIndexingError(BaseServiceError): pass diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index 20e761e6000c72..0618eee34880e1 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -422,3 +422,23 @@ def workflow_suggestions(sqlite_session_factory: sessionmaker[Session]): from extensions.application_services.workflow import build_workflow_suggestions return build_workflow_suggestions(sqlite_session_factory) + + +@pytest.fixture +def workflow_application( + app: Flask, sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch +) -> ApplicationServices: + from unittest.mock import Mock + + from enums import DeploymentEdition + from extensions.ext_application_services import build_application_services + from extensions.ext_redis import RedisClientWrapper + + services = build_application_services( + database_client=sqlite_session_factory, + deployment_edition=DeploymentEdition.COMMUNITY, + initialization_password="", + redis=Mock(spec=RedisClientWrapper), + ) + monkeypatch.setitem(app.extensions, "application_services", services) + return services diff --git a/api/tests/unit_tests/controllers/common/test_errors.py b/api/tests/unit_tests/controllers/common/test_errors.py index 417882ceef0907..06ef4e293d942f 100644 --- a/api/tests/unit_tests/controllers/common/test_errors.py +++ b/api/tests/unit_tests/controllers/common/test_errors.py @@ -25,6 +25,7 @@ from libs.external_api import ExternalApi from services.errors.base import NoPermissionError from services.errors.workspace import WorkspaceApplicationError +from services.workflow.variable_contracts import DraftVariableChangedError @pytest.mark.parametrize("source_api", [console_api, service_api], ids=["console", "service_api"]) @@ -70,6 +71,26 @@ def get(self) -> ResponseReturnValue: assert response.json == {"code": "unknown", "message": "Internal Server Error", "status": 500} +def test_console_maps_draft_variable_concurrency_error() -> None: + app = Flask(__name__) + api = Api(app, doc=False) + api.error_handlers = console_api.error_handlers.copy() + + class Endpoint(Resource): + def put(self) -> ResponseReturnValue: + raise DraftVariableChangedError("internal-variable-id") + + api.add_resource(Endpoint, "/variable") + response = app.test_client().put("/variable") + + assert response.status_code == 409 + assert response.json == { + "code": "draft_variable_changed", + "message": "Variable changed; refresh and retry with the current value.", + "status": 409, + } + + class TestFilenameNotExistsError: def test_defaults(self): error = FilenameNotExistsError() @@ -181,3 +202,49 @@ def test_defaults(self): assert error.code == 400 assert error.error_code == "no_file_uploaded" assert error.description == "Please upload your file." + + +@pytest.mark.parametrize("surface", ["console", "service_api"]) +@pytest.mark.parametrize( + ("kind", "status", "code"), + [ + ("annotation", 404, "not_found"), + ("dataset", 404, "not_found"), + ("document", 404, "not_found"), + ("source", 404, "not_found"), + ("permission", 403, "forbidden"), + ], +) +def test_resource_domain_failures_keep_http_contract(surface, kind, status, code): + from importlib import import_module + + from flask import Flask + from flask_restx import Resource + + from libs.external_api import ExternalApi + from services.annotation.errors import AnnotationResourceNotFoundError + from services.errors.dataset import DatasetNotFoundError + from services.errors.document import DocumentAccessDeniedError, DocumentNotFoundError, DocumentSourceNotFoundError + + failures = { + "annotation": AnnotationResourceNotFoundError, + "dataset": DatasetNotFoundError, + "document": DocumentNotFoundError, + "source": DocumentSourceNotFoundError, + "permission": DocumentAccessDeniedError, + } + domain_error = failures[kind]("resource unavailable") + app = Flask(__name__) + app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False) + api = ExternalApi(app) + # Exercise the production surface's handlers and ordering, including the generic catch-all. + api.error_handlers = import_module(f"controllers.{surface}").api.error_handlers.copy() + + class Failure(Resource): + def get(self): + raise domain_error + + api.add_resource(Failure, "/failure") + response = app.test_client().get("/failure") + assert response.status_code == status + assert response.json == {"code": code, "status": status, "message": "resource unavailable"} diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_variable_conflicts.py b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_conflicts.py new file mode 100644 index 00000000000000..2f4db21f4a1752 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_conflicts.py @@ -0,0 +1,214 @@ +"""Real draft snapshot conflicts use the Console HTTP error contract.""" + +import json +from dataclasses import dataclass +from inspect import unwrap + +import pytest +from flask import Flask +from flask.typing import ResponseReturnValue +from flask_restx import Api, Resource +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from controllers.console import api as console_api +from controllers.console.app import workflow_draft_variable as controller +from controllers.console.app import workflow_variable_admission as admission +from controllers.console.wraps import model_validate +from graphon.variables import StringVariable +from machinery.context import RequestContext +from models.model import AppMode +from models.workflow import Workflow +from services.errors.app import WorkflowHashNotEqualError +from services.workflow.console_variable_service import ConsoleWorkflowVariableService +from services.workflow.contracts import WorkflowOwner +from services.workflow.variable_contracts import DraftVariableContext +from tests.unit_tests.model_factories import make_account, make_app, make_workflow + + +@pytest.mark.parametrize("concurrent_writes", [1, 2, 3]) +@pytest.mark.parametrize("delete", [False, True]) +def test_environment_patch_rebases_with_bounded_retries( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + console_workflow_variables: ConsoleWorkflowVariableService, + concurrent_writes: int, + delete: bool, +) -> None: + account, app = make_account(), make_app(mode=AppMode.ADVANCED_CHAT) + workflow = make_workflow() + target = StringVariable(id="target", name="target", value="before") + workflow.environment_variables = [target] + with sqlite_session_factory.begin() as session: + session.add_all([account, app, workflow]) + context = RequestContext("request", None, account.id, app.tenant_id) + service = console_workflow_variables + read = service._definitions.variable_context + reads = 0 + + def interleave(context: RequestContext, owner: WorkflowOwner, *, include_draft: bool) -> DraftVariableContext: + nonlocal reads + snapshot = read(context, owner, include_draft=include_draft) + reads += 1 + if reads <= concurrent_writes: + with sqlite_session_factory.begin() as session: + current = session.get(Workflow, workflow.id) + assert current is not None + current.environment_variables = [target, StringVariable(id="other", name="other", value=str(reads))] + return snapshot + + monkeypatch.setattr(service._definitions, "variable_context", interleave) + + def patch() -> None: + service.update_environment( + context, + app.id, + [] if delete else [target.model_copy(update={"value": "after"}).model_dump(mode="json")], + deleted_ids=[target.id] if delete else [], + ) + + if concurrent_writes == 3: + with pytest.raises(WorkflowHashNotEqualError): + patch() + else: + patch() + assert reads == min(3, concurrent_writes + 1) + with sqlite_session_factory() as session: + saved = session.get(Workflow, workflow.id) + assert saved is not None + values = {variable.id: variable.value for variable in saved.environment_variables} + assert values["other"] == str(concurrent_writes) + if concurrent_writes == 3: + assert values["target"] == "before" + elif delete: + assert "target" not in values + else: + assert values["target"] == "after" + + +@pytest.mark.parametrize("operation", ["conversation", "environment-replace", "environment-patch"]) +@pytest.mark.parametrize( + "concurrent_change", [None, "canvas", "features", "conversation", "environment", "same-variable"] +) +def test_variable_update_conflict_uses_production_http_error_mapping( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + console_workflow_variables: ConsoleWorkflowVariableService, + operation: str, + concurrent_change: str | None, +) -> None: + account = make_account() + app_model = make_app(mode=AppMode.ADVANCED_CHAT) + with sqlite_session_factory.begin() as session: + session.add_all([account, app_model, make_workflow()]) + context = RequestContext("request", None, account.id, app_model.tenant_id) + service = console_workflow_variables + read_snapshot = service._definitions.variable_context + + def read_before_concurrent_commit( + context: RequestContext, owner: WorkflowOwner, *, include_draft: bool + ) -> DraftVariableContext: + snapshot = read_snapshot(context, owner, include_draft=include_draft) + if concurrent_change is not None: + # Commit a second editor's update after the read and before the + # original request reaches the repository's snapshot comparison. + with sqlite_session_factory.begin() as session: + current = session.get(Workflow, snapshot.snapshot.id) + assert current is not None + if concurrent_change == "canvas": + current.graph = json.dumps({"nodes": [], "edges": [], "viewport": {"zoom": 2}}) + elif concurrent_change == "features": + current.features = '{"file_upload": {"enabled": true}}' + elif concurrent_change == "conversation": + current.conversation_variables = [StringVariable(id="other", name="saved", value="concurrent edit")] + else: + current.environment_variables = [ + StringVariable( + id="variable-1" if concurrent_change == "same-variable" else "other", + name="saved", + value="concurrent edit", + ) + ] + return snapshot + + monkeypatch.setattr(service._definitions, "variable_context", read_before_concurrent_commit) + + @dataclass + class Services: + console_workflow_variables: ConsoleWorkflowVariableService + + monkeypatch.setattr(controller, "application_services", lambda: Services(service)) + + def account_admission(**_kwargs): + def decorate(view): + def admitted(self, app_id): + return view(self, context, app_id) + + return admitted + + return decorate + + monkeypatch.setattr(admission, "console_account_admission", account_admission) + variable = {"id": "variable-1", "name": "requested", "value_type": "string", "value": "new value"} + if operation == "conversation": + endpoint = controller.ConversationVariableCollectionApi() + payload_type = controller.ConversationVariableUpdatePayload + payload = {"conversation_variables": [variable]} + else: + endpoint = controller.EnvironmentVariableCollectionApi() + payload_type = controller.EnvironmentVariableUpdatePayload + payload = {"environment_variables": [variable], "patch": operation == "environment-patch"} + + app = Flask(__name__) + api = Api(app, doc=False) + api.error_handlers = console_api.error_handlers.copy() + + mapped_post = admission.console_variable_admission("app")(model_validate(payload_type)(unwrap(endpoint.post))) + + class Endpoint(Resource): + def post(self) -> ResponseReturnValue: + # Admission is pre-supplied; keep the actual controller method, + # service, repository and production exception-handler ordering. + return mapped_post(endpoint, app_model.id) + + api.add_resource(Endpoint, "/variables") + response = app.test_client().post("/variables", json=payload) + conflicts = (operation == "conversation" and concurrent_change == "conversation") or ( + operation != "conversation" + and ( + concurrent_change == "same-variable" + or (operation == "environment-replace" and concurrent_change == "environment") + ) + ) + if not conflicts: + assert response.status_code == 200 + assert response.json == {"result": "success"} + else: + assert response.status_code == 409 + assert response.json == { + "code": "draft_workflow_not_sync", + "message": "Workflow graph might have been modified, please refresh and resubmit.", + "status": 409, + } + with sqlite_session_factory() as session: + workflow = session.scalar(select(Workflow).where(Workflow.app_id == app_model.id)) + assert workflow is not None + requested_values = ( + workflow.conversation_variables if operation == "conversation" else workflow.environment_variables + ) + if not conflicts: + assert ("requested", "new value") in [(value.name, value.value) for value in requested_values] + else: + assert all(value.name != "requested" for value in requested_values) + if concurrent_change == "canvas": + assert workflow.graph_dict["viewport"] == {"zoom": 2} + elif concurrent_change == "features": + assert workflow.features_dict == {"file_upload": {"enabled": True}} + elif concurrent_change == "conversation": + assert [(value.name, value.value) for value in workflow.conversation_variables] == [ + ("saved", "concurrent edit") + ] + elif concurrent_change in ("environment", "same-variable"): + assert ("saved", "concurrent edit") in [ + (value.name, value.value) for value in workflow.environment_variables + ] diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py new file mode 100644 index 00000000000000..ba4a14df7faacb --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py @@ -0,0 +1,276 @@ +"""Transport delegates all three scopes without ORM objects or Sessions.""" + +from dataclasses import dataclass, field +from inspect import unwrap +from uuid import UUID + +import pytest +from flask import Flask, Response +from flask_restx import Resource + +from controllers.console import api as console_api +from controllers.console.app import workflow_draft_variable as app +from controllers.console.app import workflow_variable_admission as admission +from controllers.console.datasets.rag_pipeline import rag_pipeline_draft_variable as pipeline +from controllers.console.snippets import snippet_workflow_draft_variable as snippet +from controllers.console.wraps import RBACPermission +from graphon.variables import StringSegment +from libs.external_api import ExternalApi +from machinery.context import RequestContext +from services.workflow.contracts import DraftWorkflowMissingError, WorkflowOwner +from services.workflow.variable_contracts import ( + ConsoleVariableList, + DraftVariableChangedError, + DraftVariableNotFoundError, + DraftVariableOwnerNotFoundError, + DraftVariableView, + InvalidDraftVariableError, +) +from tests.unit_tests.controllers.rbac_introspection import rbac_checks + +CONTEXT = RequestContext("request", None, "account-1", "tenant-1") +OWNER_ID = UUID("00000000-0000-0000-0000-000000000001") +VARIABLE_ID = UUID("00000000-0000-0000-0000-000000000002") +VARIABLE = DraftVariableView( + str(VARIABLE_ID), + "node", + "answer", + "", + ["node", "answer"], + "string", + False, + True, + False, + StringSegment(value="answer"), + None, +) + + +@dataclass +class Variables: + calls: list = field(default_factory=list) + + def record(self, op, context, owner, *args, **kwargs): + self.calls.append((op, context, owner, args, kwargs)) + + def list_variables(self, *args, **kwargs): + self.record("list_variables", *args, **kwargs) + return ConsoleVariableList([VARIABLE], 1) + + def node(self, *args): + self.record("node", *args) + return ConsoleVariableList([VARIABLE]) + + def system(self, *args): + self.record("system", *args) + return ConsoleVariableList([]) + + def conversation(self, *args): + self.record("conversation", *args) + return ConsoleVariableList([]) + + def environment(self, *args): + self.record("environment", *args) + return [] + + def get(self, *args): + self.record("get", *args) + return VARIABLE + + def patch(self, *args, **kwargs): + self.record("patch", *args, **kwargs) + return VARIABLE + + def reset(self, *args): + self.record("reset", *args) + + def delete(self, *args): + self.record("delete", *args) + + def delete_all(self, *args): + self.record("delete_all", *args) + + def delete_node(self, *args): + self.record("delete_node", *args) + + def update_conversation(self, *args): + self.record("update_conversation", *args) + + def update_environment(self, *args, **kwargs): + self.record("update_environment", *args, **kwargs) + + +@dataclass +class Services: + console_workflow_variables: Variables + + +ENDPOINTS = [ + (app, "app", app.WorkflowVariableCollectionApi().get, "list_variables", app.WorkflowDraftVariableListQuery(), ""), + (app, "app", app.WorkflowVariableCollectionApi().delete, "delete_all", None, ""), + (app, "app", app.NodeVariableCollectionApi().get, "node", None, "node"), + (app, "app", app.NodeVariableCollectionApi().delete, "delete_node", None, "node"), + (app, "app", app.VariableApi().get, "get", None, "variable"), + (app, "app", app.VariableApi().patch, "patch", app.WorkflowDraftVariableUpdatePayload(), "variable"), + (app, "app", app.VariableApi().delete, "delete", None, "variable"), + (app, "app", app.VariableResetApi().put, "reset", None, "variable"), + (app, "app", app.ConversationVariableCollectionApi().get, "conversation", None, ""), + ( + app, + "app", + app.ConversationVariableCollectionApi().post, + "update_conversation", + app.ConversationVariableUpdatePayload(conversation_variables=[]), + "", + ), + (app, "app", app.SystemVariableCollectionApi().get, "system", None, ""), + (app, "app", app.EnvironmentVariableCollectionApi().get, "environment", None, ""), + ( + app, + "app", + app.EnvironmentVariableCollectionApi().post, + "update_environment", + app.EnvironmentVariableUpdatePayload(environment_variables=[]), + "", + ), + ( + pipeline, + "pipeline", + pipeline.RagPipelineVariableCollectionApi().get, + "list_variables", + pipeline.PaginationQuery(), + "", + ), + (pipeline, "pipeline", pipeline.RagPipelineVariableCollectionApi().delete, "delete_all", None, ""), + (pipeline, "pipeline", pipeline.RagPipelineNodeVariableCollectionApi().get, "node", None, "node"), + (pipeline, "pipeline", pipeline.RagPipelineNodeVariableCollectionApi().delete, "delete_node", None, "node"), + (pipeline, "pipeline", pipeline.RagPipelineVariableApi().get, "get", None, "variable"), + ( + pipeline, + "pipeline", + pipeline.RagPipelineVariableApi().patch, + "patch", + pipeline.WorkflowDraftVariablePatchPayload(), + "variable", + ), + (pipeline, "pipeline", pipeline.RagPipelineVariableApi().delete, "delete", None, "variable"), + (pipeline, "pipeline", pipeline.RagPipelineVariableResetApi().put, "reset", None, "variable"), + (pipeline, "pipeline", pipeline.RagPipelineSystemVariableCollectionApi().get, "system", None, ""), + (pipeline, "pipeline", pipeline.RagPipelineEnvironmentVariableCollectionApi().get, "environment", None, ""), + ( + snippet, + "snippet", + snippet.SnippetWorkflowVariableCollectionApi().get, + "list_variables", + snippet.WorkflowDraftVariableListQuery(), + "", + ), + (snippet, "snippet", snippet.SnippetWorkflowVariableCollectionApi().delete, "delete_all", None, ""), + (snippet, "snippet", snippet.SnippetNodeVariableCollectionApi().get, "node", None, "node"), + (snippet, "snippet", snippet.SnippetNodeVariableCollectionApi().delete, "delete_node", None, "node"), + (snippet, "snippet", snippet.SnippetVariableApi().get, "get", None, "variable"), + ( + snippet, + "snippet", + snippet.SnippetVariableApi().patch, + "patch", + snippet.WorkflowDraftVariableUpdatePayload(), + "variable", + ), + (snippet, "snippet", snippet.SnippetVariableApi().delete, "delete", None, "variable"), + (snippet, "snippet", snippet.SnippetVariableResetApi().put, "reset", None, "variable"), + (snippet, "snippet", snippet.SnippetConversationVariableCollectionApi().get, "conversation", None, ""), + (snippet, "snippet", snippet.SnippetSystemVariableCollectionApi().get, "system", None, ""), + (snippet, "snippet", snippet.SnippetEnvironmentVariableCollectionApi().get, "environment", None, ""), +] + + +@pytest.mark.parametrize(("module", "kind", "endpoint", "operation", "payload", "suffix"), ENDPOINTS) +def test_endpoint_delegates_and_serializes(monkeypatch, module, kind, endpoint, operation, payload, suffix): + variables = Variables() + monkeypatch.setattr(module, "application_services", lambda: Services(variables)) + args = [] if payload is None else [payload] + args += [CONTEXT, OWNER_ID] + if suffix: + args += [VARIABLE_ID if suffix == "variable" else "node"] + response = unwrap(endpoint)(endpoint.__self__, *args) + assert len(variables.calls) == 1 + call = variables.calls[0] + assert call[:3] == ( + operation, + CONTEXT, + str(OWNER_ID) if operation.startswith("update_") else WorkflowOwner(str(OWNER_ID), kind), + ) + if suffix: + assert call[3][0] == (str(VARIABLE_ID) if suffix == "variable" else "node") + if operation.startswith("delete") or operation == "reset": + assert isinstance(response, Response) + assert response.status_code == 204 + assert response.data == b"" + elif operation in {"get", "patch"}: + assert response["value"] == "answer" + elif operation == "list_variables": + assert response["items"][0]["id"] == str(VARIABLE_ID) + if kind == "pipeline": + assert [check.scene for check in rbac_checks(endpoint)] == [RBACPermission.DATASET_EDIT] + elif kind == "app": + assert [check.scene for check in rbac_checks(endpoint)] == [ + RBACPermission.APP_EDIT if operation.startswith("update_") else RBACPermission.APP_VIEW_LAYOUT + ] + + +@pytest.mark.parametrize( + ("failure", "status", "code"), + [ + (DraftVariableNotFoundError("missing"), 404, "not_found"), + (DraftWorkflowMissingError(), 404, "draft_workflow_not_exist"), + (InvalidDraftVariableError("invalid file"), 400, "invalid_param"), + (DraftVariableChangedError(), 409, "draft_workflow_not_sync"), + (DraftVariableOwnerNotFoundError("app"), 404, "app_not_found"), + (DraftVariableOwnerNotFoundError("pipeline"), 404, "pipeline_not_found"), + (DraftVariableOwnerNotFoundError("snippet"), 404, "not_found"), + ], +) +def test_domain_errors_use_production_http_mapping(monkeypatch, failure, status, code): + def account_admission(**_kwargs): + def decorate(view): + def admitted(self): + return view(self, CONTEXT) + + return admitted + + return decorate + + monkeypatch.setattr(admission, "console_account_admission", account_admission) + web = Flask(__name__) + web.config["RESTX_ERROR_404_HELP"] = False + api = ExternalApi(web) + api.error_handlers = console_api.error_handlers.copy() + + class Endpoint(Resource): + @admission.console_variable_admission("pipeline") + def get(self, _context): + raise failure + + api.add_resource(Endpoint, "/variables") + response = web.test_client().get("/variables") + assert response.status_code == status + assert response.json["code"] == code + + +@pytest.mark.parametrize("patch", [False, True]) +def test_environment_payload_preserves_patch_and_replacement(monkeypatch, patch): + variables = Variables() + monkeypatch.setattr(app, "application_services", lambda: Services(variables)) + value = {"id": "env-a", "name": "a", "value_type": "string", "value": "new-a"} + payload = app.EnvironmentVariableUpdatePayload( + environment_variables=[value], patch=patch, deleted_environment_variable_ids=["env-b"] if patch else [] + ) + endpoint = app.EnvironmentVariableCollectionApi() + response = unwrap(endpoint.post)(endpoint, payload, CONTEXT, OWNER_ID) + assert response == {"result": "success"} + operation, context, owner, args, kwargs = variables.calls[0] + assert (operation, context, owner) == ("update_environment", CONTEXT, str(OWNER_ID)) + assert args[0][0]["id"] == "env-a" + assert args[0][0]["value"] == "new-a" + assert kwargs == {"deleted_ids": ["env-b"] if patch else None} diff --git a/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py b/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py index 709ae1e72a4d62..c80d012061e9d8 100644 --- a/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py +++ b/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py @@ -19,7 +19,7 @@ from libs.helper import dump_response from libs.uuid_utils import uuidv7 from models.workflow import WorkflowDraftVariable, WorkflowDraftVariableFile -from services.workflow_draft_variable_service import WorkflowDraftVariableList +from services.workflow.variable_contracts import WorkflowDraftVariableList _TEST_APP_ID = "test_app_id" _TEST_NODE_EXEC_ID = str(uuid.uuid4()) diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py deleted file mode 100644 index c5f43180ceee89..00000000000000 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py +++ /dev/null @@ -1,470 +0,0 @@ -from collections.abc import Callable, Iterator -from inspect import unwrap -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -import pytest -from flask import Flask, Response -from sqlalchemy import Engine -from sqlalchemy.orm import Session, scoped_session, sessionmaker - -from controllers.common.errors import InvalidArgumentError, NotFoundError -from controllers.common.rbac import RBACResourceScope -from controllers.console import console_ns -from controllers.console.app.error import DraftWorkflowNotExist -from controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable import ( - PaginationQuery, - RagPipelineEnvironmentVariableCollectionApi, - RagPipelineNodeVariableCollectionApi, - RagPipelineSystemVariableCollectionApi, - RagPipelineVariableApi, - RagPipelineVariableCollectionApi, - RagPipelineVariableResetApi, - WorkflowDraftVariablePatchPayload, -) -from controllers.console.wraps import RBACPermission -from core.workflow.llm_environment_variable import LLMEnvironmentVariable -from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID -from factories.variable_factory import build_segment -from graphon.file import File, FileTransferMethod, FileType -from graphon.variables import IntegerVariable, StringVariable -from models.account import Account, TenantAccountRole -from models.dataset import Pipeline -from models.workflow import Workflow, WorkflowDraftVariable, WorkflowType -from tests.unit_tests.controllers.rbac_introspection import rbac_checks -from tests.unit_tests.model_factories import make_workflow - - -@pytest.fixture -def sqlite_db(sqlite_engine: Engine, sqlite_session_factory: sessionmaker[Session]) -> Iterator[SimpleNamespace]: - session = scoped_session(sqlite_session_factory) - yield SimpleNamespace(engine=sqlite_engine, session=session) - session.remove() - - -def _pipeline(*, pipeline_id: str = "p1", tenant_id: str = "t1") -> Pipeline: - pipeline = Pipeline(tenant_id=tenant_id, name="Pipeline") - pipeline.id = pipeline_id - return pipeline - - -def _workflow(pipeline: Pipeline, environment_variables=()) -> Workflow: - return make_workflow( - tenant_id=pipeline.tenant_id, - app_id=pipeline.id, - workflow_type=WorkflowType.RAG_PIPELINE, - graph="{}", - environment_variables=environment_variables, - ) - - -def _variable(*, app_id: str = "p1", user_id: str = "account-1", file_value: bool = False) -> WorkflowDraftVariable: - if file_value: - value = build_segment( - File( - file_id="file-1", - file_type=FileType.IMAGE, - transfer_method=FileTransferMethod.REMOTE_URL, - remote_url="https://example.com/image.png", - filename="image.png", - extension=".png", - mime_type="image/png", - size=1, - ) - ) - else: - value = build_segment("value") - return WorkflowDraftVariable.new_node_variable( - app_id=app_id, - user_id=user_id, - node_id="node-1", - name="variable", - value=value, - node_execution_id="execution-1", - ) - - -@pytest.fixture -def editor_user() -> Account: - user = Account(name="Test User", email="user@example.com") - user.id = "account-1" - user.role = TenantAccountRole.EDITOR - return user - - -@pytest.fixture -def restx_config(app): - return patch.dict(app.config, {"RESTX_MASK_HEADER": "X-Fields"}) - - -def test_rag_draft_variable_routes_require_dataset_edit_permission() -> None: - route = RagPipelineVariableApi.get - legacy_gate = unwrap(route, stop=lambda decorator: "edit_permission_required" in decorator.__code__.co_qualname) - assert "edit_permission_required" in legacy_gate.__code__.co_qualname - - [check] = rbac_checks(route) - assert check.locator.scope == RBACResourceScope.DATASET - assert check.scene == RBACPermission.DATASET_EDIT - - -class TestRagPipelineVariableCollectionApi: - def test_get_variables_success(self, app: Flask, sqlite_db, editor_user, restx_config): - api = RagPipelineVariableCollectionApi() - method = unwrap(api.get) - - pipeline = _pipeline() - - rag_srv = MagicMock() - rag_srv.is_workflow_exist.return_value = True - - var_list = MagicMock() - var_list.variables = [] - var_list.total = None - - draft_srv = MagicMock() - draft_srv.list_variables_without_values.return_value = var_list - - with ( - app.test_request_context("/?page=1&limit=10"), - restx_config, - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.RagPipelineService", - return_value=rag_srv, - ), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", - return_value=draft_srv, - ), - ): - result = method(api, PaginationQuery(page=1, limit=10), editor_user, pipeline) - - assert result == {"items": [], "total": None} - draft_srv.list_variables_without_values.assert_called_once_with( - app_id="p1", - page=1, - limit=10, - user_id="account-1", - ) - - def test_get_variables_workflow_not_exist(self, app: Flask, sqlite_db, editor_user): - api = RagPipelineVariableCollectionApi() - method = unwrap(api.get) - - pipeline = _pipeline() - - rag_srv = MagicMock() - rag_srv.is_workflow_exist.return_value = False - - with ( - app.test_request_context("/"), - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.RagPipelineService", - return_value=rag_srv, - ), - ): - with pytest.raises(DraftWorkflowNotExist): - method(api, PaginationQuery(), editor_user, pipeline) - - def test_delete_variables_success(self, app: Flask, sqlite_db, editor_user): - api = RagPipelineVariableCollectionApi() - method = unwrap(api.delete) - - pipeline = _pipeline() - - with ( - app.test_request_context("/"), - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService"), - ): - result = method(api, editor_user, pipeline) - - assert isinstance(result, Response) - assert result.status_code == 204 - - -class TestRagPipelineNodeVariableCollectionApi: - def test_get_node_variables_success(self, app: Flask, sqlite_db, editor_user, restx_config): - api = RagPipelineNodeVariableCollectionApi() - method = unwrap(api.get) - - pipeline = _pipeline() - - var_list = MagicMock() - var_list.variables = [] - - srv = MagicMock() - srv.list_node_variables.return_value = var_list - - with ( - app.test_request_context("/"), - restx_config, - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", - return_value=srv, - ), - ): - result = method(api, editor_user, pipeline, "node1") - - assert result == {"items": []} - srv.list_node_variables.assert_called_once_with("p1", "node1", user_id="account-1") - - def test_get_node_variables_invalid_node(self, app: Flask, editor_user): - api = RagPipelineNodeVariableCollectionApi() - method = unwrap(api.get) - - with ( - app.test_request_context("/"), - ): - with pytest.raises(InvalidArgumentError): - method(api, editor_user, _pipeline(), SYSTEM_VARIABLE_NODE_ID) - - -class TestRagPipelineVariableApi: - def test_get_variable_not_found(self, app: Flask, sqlite_db, editor_user): - api = RagPipelineVariableApi() - method = unwrap(api.get) - - srv = MagicMock() - srv.get_variable.return_value = None - - with ( - app.test_request_context("/"), - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", - return_value=srv, - ), - ): - with pytest.raises(NotFoundError): - method(api, editor_user, _pipeline(), "v1") - - def test_patch_variable_invalid_file_payload(self, app: Flask, sqlite_db, editor_user): - api = RagPipelineVariableApi() - method = unwrap(api.patch) - - pipeline = _pipeline() - variable = _variable(file_value=True) - - srv = MagicMock() - srv.get_variable.return_value = variable - - payload = {"value": "invalid"} - - with ( - app.test_request_context("/", json=payload), - patch.object(type(console_ns), "payload", payload), - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", - return_value=srv, - ), - ): - with pytest.raises(InvalidArgumentError): - method(api, WorkflowDraftVariablePatchPayload.model_validate(payload), editor_user, pipeline, "v1") - - def test_delete_variable_success(self, app: Flask, sqlite_db, editor_user): - api = RagPipelineVariableApi() - method = unwrap(api.delete) - - pipeline = _pipeline() - variable = _variable() - - srv = MagicMock() - srv.get_variable.return_value = variable - - with ( - app.test_request_context("/"), - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", - return_value=srv, - ), - ): - result = method(api, editor_user, pipeline, "v1") - - assert result.status_code == 204 - - -@pytest.mark.parametrize( - ("api_type", "method", "payload"), - [ - (RagPipelineVariableApi, RagPipelineVariableApi.get, None), - ( - RagPipelineVariableApi, - RagPipelineVariableApi.patch, - WorkflowDraftVariablePatchPayload(name="new name"), - ), - (RagPipelineVariableApi, RagPipelineVariableApi.delete, None), - (RagPipelineVariableResetApi, RagPipelineVariableResetApi.put, None), - ], -) -def test_direct_variable_access_rejects_different_user( - app: Flask, - sqlite_db: SimpleNamespace, - editor_user: Account, - api_type: type[RagPipelineVariableApi] | type[RagPipelineVariableResetApi], - method: Callable[..., object], - payload: WorkflowDraftVariablePatchPayload | None, -) -> None: - api = api_type() - method = unwrap(method) - pipeline = _pipeline() - variable = _variable(user_id="account-2") - draft_service = MagicMock() - draft_service.get_variable.return_value = variable - rag_service = MagicMock() - rag_service.get_draft_workflow.return_value = _workflow(pipeline) - if payload is not None: - call_args = (api, payload, editor_user, pipeline, "v1") - else: - call_args = (api, editor_user, pipeline, "v1") - - with ( - app.test_request_context("/"), - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch.object(sqlite_db.session, "commit") as commit, - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.RagPipelineService", - return_value=rag_service, - ), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", - return_value=draft_service, - ), - pytest.raises(NotFoundError), - ): - method(*call_args) - - draft_service.update_variable.assert_not_called() - draft_service.delete_variable.assert_not_called() - draft_service.reset_variable.assert_not_called() - commit.assert_not_called() - - -class TestRagPipelineVariableResetApi: - def test_reset_variable_success(self, app: Flask, sqlite_db, editor_user): - api = RagPipelineVariableResetApi() - method = unwrap(api.put) - - pipeline = _pipeline() - workflow = _workflow(pipeline) - variable = _variable() - - srv = MagicMock() - srv.get_variable.return_value = variable - srv.reset_variable.return_value = variable - - rag_srv = MagicMock() - rag_srv.get_draft_workflow.return_value = workflow - - with ( - app.test_request_context("/"), - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.RagPipelineService", - return_value=rag_srv, - ), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", - return_value=srv, - ), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.dump_response", - return_value={"id": "v1"}, - ), - ): - result = method(api, editor_user, pipeline, "v1") - - assert result == {"id": "v1"} - - -class TestSystemAndEnvironmentVariablesApi: - def test_system_variables_success(self, app: Flask, sqlite_db, editor_user, restx_config): - api = RagPipelineSystemVariableCollectionApi() - method = unwrap(api.get) - - pipeline = _pipeline() - - var_list = MagicMock() - var_list.variables = [] - - srv = MagicMock() - srv.list_system_variables.return_value = var_list - - with ( - app.test_request_context("/"), - restx_config, - patch("controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.db", sqlite_db), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.WorkflowDraftVariableService", - return_value=srv, - ), - ): - result = method(api, editor_user, pipeline) - - assert result == {"items": []} - srv.list_system_variables.assert_called_once_with("p1", user_id="account-1") - - def test_environment_variables_success(self, app: Flask, editor_user): - api = RagPipelineEnvironmentVariableCollectionApi() - method = unwrap(api.get) - - env_var = StringVariable( - id="e1", - name="ENV", - description="d", - selector=["env", "ENV"], - value="x", - ) - - pipeline = _pipeline() - workflow = _workflow(pipeline, [env_var]) - - rag_srv = MagicMock() - rag_srv.get_draft_workflow.return_value = workflow - - with ( - app.test_request_context("/"), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.RagPipelineService", - return_value=rag_srv, - ), - ): - result = method(api, editor_user, pipeline) - - assert len(result["items"]) == 1 - assert result["items"][0]["value_type"] == "string" - - def test_environment_variables_preserve_number_subtype_and_llm_type(self, app: Flask, editor_user): - api = RagPipelineEnvironmentVariableCollectionApi() - method = unwrap(api.get) - number_var = IntegerVariable( - id="number", - name="NUMBER", - description="", - selector=["env", "NUMBER"], - value=1, - ) - llm_var = LLMEnvironmentVariable( - id="llm", - name="MODEL", - value={"provider": "provider", "name": "model", "mode": "chat"}, - selector=["env", "MODEL"], - ) - pipeline = _pipeline() - rag_srv = MagicMock() - rag_srv.get_draft_workflow.return_value = _workflow(pipeline, [number_var, llm_var]) - - with ( - app.test_request_context("/"), - patch( - "controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable.RagPipelineService", - return_value=rag_srv, - ), - ): - result = method(api, editor_user, pipeline) - - assert [item["value_type"] for item in result["items"]] == ["integer", "llm"] diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py deleted file mode 100644 index 578b2b1c03b6c6..00000000000000 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py +++ /dev/null @@ -1,327 +0,0 @@ -import json -from inspect import unwrap -from unittest.mock import Mock - -import pytest -from flask import Flask -from sqlalchemy import select -from sqlalchemy.orm import Session - -from controllers.console.snippets import snippet_workflow_draft_variable as module -from graphon.variables import StringSegment, StringVariable -from models.account import Account, AccountStatus -from models.snippet import CustomizedSnippet -from models.workflow import Workflow, WorkflowDraftVariable - - -def _make_account() -> Account: - account = Account( - name="tester", - email="tester@example.com", - status=AccountStatus.ACTIVE, - ) - account.id = "user-1" # type: ignore[assignment] - return account - - -def _make_snippet(snippet_id: str = "snippet-1") -> CustomizedSnippet: - return CustomizedSnippet( - id=snippet_id, - tenant_id="tenant-1", - name="Snippet", - description="Description", - type="node", - created_by="user-1", - ) - - -def _make_workflow(*, environment_variables: list[StringVariable] | None = None) -> Workflow: - workflow = Workflow.new( - tenant_id="tenant-1", - app_id="snippet-1", - type="workflow", - version=Workflow.VERSION_DRAFT, - graph=json.dumps({"nodes": [], "edges": []}), - features="{}", - created_by="user-1", - environment_variables=environment_variables or [], - conversation_variables=[], - rag_pipeline_variables=[], - ) - workflow.id = "workflow-1" - return workflow - - -def _make_node_variable( - variable_id: str, - *, - app_id: str = "snippet-1", - user_id: str = "user-1", - node_id: str = "llm-1", - name: str | None = None, - node_execution_id: str | None = "execution-1", -) -> WorkflowDraftVariable: - """Create a valid node variable for persisted controller tests.""" - variable = WorkflowDraftVariable.new_node_variable( - app_id=app_id, - user_id=user_id, - node_id=node_id, - name=name or variable_id, - value=StringSegment(value=f"value-{variable_id}"), - node_execution_id=node_execution_id or "execution-1", - ) - variable.id = variable_id - variable.node_execution_id = node_execution_id - return variable - - -@pytest.fixture -def app() -> Flask: - app = Flask("test_snippet_workflow_draft_variable") - app.config["TESTING"] = True - return app - - -def _persist_variables(sqlite_session: Session, *variables: WorkflowDraftVariable) -> None: - sqlite_session.add_all(variables) - sqlite_session.commit() - - -def _variable_ids(session: Session) -> set[str]: - return set(session.scalars(select(WorkflowDraftVariable.id))) - - -def test_ensure_snippet_draft_variable_row_allowed_rejects_system_variable() -> None: - variable = WorkflowDraftVariable.new_sys_variable( - app_id="snippet-1", - user_id="user-1", - name="query", - value=StringSegment(value="query"), - node_execution_id="execution-1", - editable=True, - ) - - with pytest.raises(module.NotFoundError, match="variable not found"): - module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") - - -def test_ensure_snippet_draft_variable_row_allowed_rejects_conversation_variable() -> None: - variable = WorkflowDraftVariable.new_conversation_variable( - app_id="snippet-1", - user_id="user-1", - name="conversation-name", - value=StringSegment(value="value"), - ) - - with pytest.raises(module.NotFoundError, match="variable not found"): - module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") - - -def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable() -> None: - variable = _make_node_variable("var-1") - - module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") - - -def test_conversation_variables_returns_empty_list(app: Flask) -> None: - api = module.SnippetConversationVariableCollectionApi() - handler = unwrap(api.get) - - with app.test_request_context("/"): - result = handler(api, _make_account(), snippet=_make_snippet()) - - assert result == {"items": []} - - -def test_system_variables_returns_empty_list(app: Flask) -> None: - api = module.SnippetSystemVariableCollectionApi() - handler = unwrap(api.get) - - with app.test_request_context("/"): - result = handler(api, _make_account(), snippet=_make_snippet()) - - assert result == {"items": []} - - -def test_delete_variable_collection_deletes_only_current_user_variables( - app: Flask, - sqlite_session: Session, -) -> None: - matching = _make_node_variable("matching", name="matching") - matching_second = _make_node_variable("matching-second", node_id="tool-1", name="matching-second") - other_user = _make_node_variable("other-user", user_id="user-2", name="other-user") - other_snippet = _make_node_variable("other-snippet", app_id="snippet-2", name="other-snippet") - _persist_variables(sqlite_session, matching, matching_second, other_user, other_snippet) - api = module.SnippetWorkflowVariableCollectionApi() - handler = unwrap(api.delete) - - with app.test_request_context("/", method="DELETE"): - response = handler(api, sqlite_session, _make_account(), snippet=_make_snippet()) - - assert response.status_code == 204 - assert _variable_ids(sqlite_session) == {other_user.id, other_snippet.id} - - -def test_variable_collection_get_raises_when_draft_workflow_missing( - app: Flask, - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, -) -> None: - monkeypatch.setattr(module.SnippetService, "get_draft_workflow", Mock(return_value=None)) - - api = module.SnippetWorkflowVariableCollectionApi() - handler = unwrap(api.get) - - with app.test_request_context("/"): - with pytest.raises(module.DraftWorkflowNotExist): - handler( - api, - sqlite_session, - module.WorkflowDraftVariableListQuery(page=1, limit=20), - _make_account(), - snippet=_make_snippet(), - ) - - -def test_node_variable_collection_get_lists_persisted_node_variables( - app: Flask, - sqlite_session: Session, -) -> None: - matching = _make_node_variable("matching", name="matching") - other_node = _make_node_variable("other-node", node_id="tool-1", name="other-node") - other_user = _make_node_variable("other-user", user_id="user-2", name="other-user") - other_snippet = _make_node_variable("other-snippet", app_id="snippet-2", name="other-snippet") - _persist_variables(sqlite_session, matching, other_node, other_user, other_snippet) - api = module.SnippetNodeVariableCollectionApi() - handler = unwrap(api.get) - - with app.test_request_context("/"): - result = handler(api, sqlite_session, _make_account(), snippet=_make_snippet(), node_id="llm-1") - - assert [variable["id"] for variable in result["items"]] == [matching.id] - - -def test_node_variable_collection_delete_deletes_only_requested_node_variables( - app: Flask, - sqlite_session: Session, -) -> None: - matching = _make_node_variable("matching", name="matching") - matching_second = _make_node_variable("matching-second", name="matching-second") - other_node = _make_node_variable("other-node", node_id="tool-1", name="other-node") - other_user = _make_node_variable("other-user", user_id="user-2", name="other-user") - _persist_variables(sqlite_session, matching, matching_second, other_node, other_user) - api = module.SnippetNodeVariableCollectionApi() - handler = unwrap(api.delete) - - with app.test_request_context("/", method="DELETE"): - response = handler(api, sqlite_session, _make_account(), snippet=_make_snippet(), node_id="llm-1") - - assert response.status_code == 204 - assert _variable_ids(sqlite_session) == {other_node.id, other_user.id} - - -def test_variable_patch_returns_persisted_variable_without_committing_when_no_changes( - app: Flask, - sqlite_session: Session, -) -> None: - variable = _make_node_variable("var-1") - _persist_variables(sqlite_session, variable) - api = module.SnippetVariableApi() - handler = unwrap(api.patch) - with app.test_request_context("/", method="PATCH", json={}): - result = handler( - api, - module.WorkflowDraftVariableUpdatePayload(), - sqlite_session, - _make_account(), - snippet=_make_snippet(), - variable_id="var-1", - ) - - assert result["id"] == variable.id - - -def test_variable_delete_deletes_persisted_variable( - app: Flask, - sqlite_session: Session, -) -> None: - variable = _make_node_variable("var-1") - retained = _make_node_variable("var-2", name="retained") - _persist_variables(sqlite_session, variable, retained) - api = module.SnippetVariableApi() - handler = unwrap(api.delete) - - with app.test_request_context("/", method="DELETE"): - response = handler( - api, - sqlite_session, - _make_account(), - snippet=_make_snippet(), - variable_id=variable.id, - ) - - assert response.status_code == 204 - assert _variable_ids(sqlite_session) == {retained.id} - - -def test_variable_reset_deletes_variable_without_node_execution( - app: Flask, - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, -) -> None: - variable = _make_node_variable("var-1", node_execution_id=None) - _persist_variables(sqlite_session, variable) - monkeypatch.setattr(module.SnippetService, "get_draft_workflow", Mock(return_value=_make_workflow())) - api = module.SnippetVariableResetApi() - handler = unwrap(api.put) - - with app.test_request_context("/", method="PUT"): - response = handler( - api, - sqlite_session, - _make_account(), - snippet=_make_snippet(), - variable_id=variable.id, - ) - - assert response.status_code == 204 - assert _variable_ids(sqlite_session) == set() - - -def test_environment_variables_returns_workflow_environment_variables( - app: Flask, - monkeypatch: pytest.MonkeyPatch, -) -> None: - env_var = StringVariable( - id="env-1", - name="API_KEY", - description="secret", - selector=["env", "API_KEY"], - value="sk-test", - ) - monkeypatch.setattr( - module.SnippetService, "get_draft_workflow", Mock(return_value=_make_workflow(environment_variables=[env_var])) - ) - - api = module.SnippetEnvironmentVariableCollectionApi() - handler = unwrap(api.get) - - with app.test_request_context("/"): - result = handler(api, _make_account(), snippet=_make_snippet()) - - assert result == { - "items": [ - { - "id": "env-1", - "type": "env", - "name": "API_KEY", - "description": "secret", - "selector": ["env", "API_KEY"], - "value_type": "string", - "value": "sk-test", - "edited": False, - "visible": True, - "editable": True, - } - ] - } From e63fe294e8ab25a2a5be30e1b52197ca77ba908d Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Fri, 9 Oct 2026 12:37:05 +0800 Subject: [PATCH 2/8] fix(api): preserve variable transport contracts and shared error definitions --- api/controllers/common/errors.py | 9 - api/tests/unit_tests/conftest.py | 4 +- .../app/test_workflow_variable_conflicts.py | 18 +- .../app/test_workflow_variable_transport.py | 161 +++++++++++++----- 4 files changed, 128 insertions(+), 64 deletions(-) diff --git a/api/controllers/common/errors.py b/api/controllers/common/errors.py index f5ec273bae6666..3743628b4584b9 100644 --- a/api/controllers/common/errors.py +++ b/api/controllers/common/errors.py @@ -140,15 +140,6 @@ class AuthenticationRequiredError(BaseHTTPException): description = "Authentication is required." -class InternalServerError(BaseHTTPException): - error_code = "internal_server_error" - code = 500 - description = ( - "The server encountered an internal error and was unable to complete your request. " - "Either the server is overloaded or there is an error in the application." - ) - - class UnsupportedMediaTypeError(BaseHTTPException): error_code = "unsupported_media_type" code = 415 diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index f875044cd31b79..b7e36402f139c6 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -462,8 +462,6 @@ def workflow_suggestions(sqlite_session_factory: sessionmaker[Session]) -> Workf def workflow_application( app: Flask, sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch ) -> ApplicationServices: - from unittest.mock import Mock - from enums import DeploymentEdition from extensions.ext_application_services import build_application_services from extensions.ext_redis import RedisClientWrapper @@ -472,7 +470,7 @@ def workflow_application( database_client=sqlite_session_factory, deployment_edition=DeploymentEdition.COMMUNITY, initialization_password="", - redis=Mock(spec=RedisClientWrapper), + redis=create_autospec(RedisClientWrapper, instance=True, spec_set=True), ) monkeypatch.setitem(app.extensions, "application_services", services) return services diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_variable_conflicts.py b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_conflicts.py index 2f4db21f4a1752..6bdca2aeeddf94 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_variable_conflicts.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_conflicts.py @@ -1,8 +1,10 @@ """Real draft snapshot conflicts use the Console HTTP error contract.""" import json +from collections.abc import Callable from dataclasses import dataclass from inspect import unwrap +from typing import Concatenate import pytest from flask import Flask @@ -109,11 +111,13 @@ def read_before_concurrent_commit( context: RequestContext, owner: WorkflowOwner, *, include_draft: bool ) -> DraftVariableContext: snapshot = read_snapshot(context, owner, include_draft=include_draft) + workflow_snapshot = snapshot.snapshot + assert workflow_snapshot is not None if concurrent_change is not None: # Commit a second editor's update after the read and before the # original request reaches the repository's snapshot comparison. with sqlite_session_factory.begin() as session: - current = session.get(Workflow, snapshot.snapshot.id) + current = session.get(Workflow, workflow_snapshot.id) assert current is not None if concurrent_change == "canvas": current.graph = json.dumps({"nodes": [], "edges": [], "viewport": {"zoom": 2}}) @@ -139,10 +143,14 @@ class Services: monkeypatch.setattr(controller, "application_services", lambda: Services(service)) - def account_admission(**_kwargs): - def decorate(view): - def admitted(self, app_id): - return view(self, context, app_id) + def account_admission[T, **P, R]( + **_kwargs: object, + ) -> Callable[[Callable[Concatenate[T, RequestContext, P], R]], Callable[Concatenate[T, P], R]]: + def decorate( + view: Callable[Concatenate[T, RequestContext, P], R], + ) -> Callable[Concatenate[T, P], R]: + def admitted(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R: + return view(self, context, *args, **kwargs) return admitted diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py index ba4a14df7faacb..20e6e3edd4d926 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py @@ -1,7 +1,10 @@ """Transport delegates all three scopes without ORM objects or Sessions.""" +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from inspect import unwrap +from types import MethodType, ModuleType +from typing import Literal from uuid import UUID import pytest @@ -45,59 +48,92 @@ None, ) +type RecordedCall = tuple[ + str, + RequestContext, + WorkflowOwner | str, + tuple[object, ...], + dict[str, object], +] + @dataclass class Variables: - calls: list = field(default_factory=list) - - def record(self, op, context, owner, *args, **kwargs): + calls: list[RecordedCall] = field(default_factory=list) + + def record( + self, + op: str, + context: RequestContext, + owner: WorkflowOwner | str, + *args: object, + **kwargs: object, + ) -> None: self.calls.append((op, context, owner, args, kwargs)) - def list_variables(self, *args, **kwargs): - self.record("list_variables", *args, **kwargs) + def list_variables( + self, context: RequestContext, owner: WorkflowOwner, *, page: int, limit: int + ) -> ConsoleVariableList: + self.record("list_variables", context, owner, page=page, limit=limit) return ConsoleVariableList([VARIABLE], 1) - def node(self, *args): - self.record("node", *args) + def node(self, context: RequestContext, owner: WorkflowOwner, node_id: str) -> ConsoleVariableList: + self.record("node", context, owner, node_id) return ConsoleVariableList([VARIABLE]) - def system(self, *args): - self.record("system", *args) + def system(self, context: RequestContext, owner: WorkflowOwner) -> ConsoleVariableList: + self.record("system", context, owner) return ConsoleVariableList([]) - def conversation(self, *args): - self.record("conversation", *args) + def conversation(self, context: RequestContext, owner: WorkflowOwner) -> ConsoleVariableList: + self.record("conversation", context, owner) return ConsoleVariableList([]) - def environment(self, *args): - self.record("environment", *args) + def environment(self, context: RequestContext, owner: WorkflowOwner) -> list[dict[str, object]]: + self.record("environment", context, owner) return [] - def get(self, *args): - self.record("get", *args) + def get(self, context: RequestContext, owner: WorkflowOwner, variable_id: str) -> DraftVariableView: + self.record("get", context, owner, variable_id) return VARIABLE - def patch(self, *args, **kwargs): - self.record("patch", *args, **kwargs) + def patch( + self, + context: RequestContext, + owner: WorkflowOwner, + variable_id: str, + *, + name: str | None, + value: object, + ) -> DraftVariableView: + self.record("patch", context, owner, variable_id, name=name, value=value) return VARIABLE - def reset(self, *args): - self.record("reset", *args) + def reset(self, context: RequestContext, owner: WorkflowOwner, variable_id: str) -> DraftVariableView | None: + self.record("reset", context, owner, variable_id) + return None - def delete(self, *args): - self.record("delete", *args) + def delete(self, context: RequestContext, owner: WorkflowOwner, variable_id: str) -> None: + self.record("delete", context, owner, variable_id) - def delete_all(self, *args): - self.record("delete_all", *args) + def delete_all(self, context: RequestContext, owner: WorkflowOwner) -> None: + self.record("delete_all", context, owner) - def delete_node(self, *args): - self.record("delete_node", *args) + def delete_node(self, context: RequestContext, owner: WorkflowOwner, node_id: str) -> None: + self.record("delete_node", context, owner, node_id) - def update_conversation(self, *args): - self.record("update_conversation", *args) + def update_conversation(self, context: RequestContext, app_id: str, values: Sequence[dict[str, object]]) -> None: + self.record("update_conversation", context, app_id, values) - def update_environment(self, *args, **kwargs): - self.record("update_environment", *args, **kwargs) + def update_environment( + self, + context: RequestContext, + app_id: str, + values: Sequence[dict[str, object]], + *, + deleted_ids: Sequence[str] | None, + ) -> None: + self.record("update_environment", context, app_id, values, deleted_ids=deleted_ids) @dataclass @@ -105,7 +141,22 @@ class Services: console_workflow_variables: Variables -ENDPOINTS = [ +type EndpointCase = tuple[ + ModuleType, + Literal["app", "pipeline", "snippet"], + MethodType, + str, + object | None, + Literal["", "node", "variable"], +] + + +def response_mapping(value: object) -> dict[str, object]: + assert isinstance(value, Mapping) + return {key: item for key, item in value.items() if isinstance(key, str)} + + +ENDPOINTS: list[EndpointCase] = [ (app, "app", app.WorkflowVariableCollectionApi().get, "list_variables", app.WorkflowDraftVariableListQuery(), ""), (app, "app", app.WorkflowVariableCollectionApi().delete, "delete_all", None, ""), (app, "app", app.NodeVariableCollectionApi().get, "node", None, "node"), @@ -186,14 +237,22 @@ class Services: @pytest.mark.parametrize(("module", "kind", "endpoint", "operation", "payload", "suffix"), ENDPOINTS) -def test_endpoint_delegates_and_serializes(monkeypatch, module, kind, endpoint, operation, payload, suffix): +def test_endpoint_delegates_and_serializes( + monkeypatch: pytest.MonkeyPatch, + module: ModuleType, + kind: Literal["app", "pipeline", "snippet"], + endpoint: MethodType, + operation: str, + payload: object | None, + suffix: Literal["", "node", "variable"], +) -> None: variables = Variables() monkeypatch.setattr(module, "application_services", lambda: Services(variables)) - args = [] if payload is None else [payload] - args += [CONTEXT, OWNER_ID] + arguments: list[object] = [] if payload is None else [payload] + arguments += [CONTEXT, OWNER_ID] if suffix: - args += [VARIABLE_ID if suffix == "variable" else "node"] - response = unwrap(endpoint)(endpoint.__self__, *args) + arguments.append(VARIABLE_ID if suffix == "variable" else "node") + response = unwrap(endpoint)(endpoint.__self__, *arguments) assert len(variables.calls) == 1 call = variables.calls[0] assert call[:3] == ( @@ -208,9 +267,11 @@ def test_endpoint_delegates_and_serializes(monkeypatch, module, kind, endpoint, assert response.status_code == 204 assert response.data == b"" elif operation in {"get", "patch"}: - assert response["value"] == "answer" + assert response_mapping(response)["value"] == "answer" elif operation == "list_variables": - assert response["items"][0]["id"] == str(VARIABLE_ID) + items = response_mapping(response)["items"] + assert isinstance(items, list) + assert response_mapping(items[0])["id"] == str(VARIABLE_ID) if kind == "pipeline": assert [check.scene for check in rbac_checks(endpoint)] == [RBACPermission.DATASET_EDIT] elif kind == "app": @@ -231,11 +292,15 @@ def test_endpoint_delegates_and_serializes(monkeypatch, module, kind, endpoint, (DraftVariableOwnerNotFoundError("snippet"), 404, "not_found"), ], ) -def test_domain_errors_use_production_http_mapping(monkeypatch, failure, status, code): - def account_admission(**_kwargs): - def decorate(view): - def admitted(self): - return view(self, CONTEXT) +def test_domain_errors_use_production_http_mapping( + monkeypatch: pytest.MonkeyPatch, failure: Exception, status: int, code: str +) -> None: + def account_admission( + **_kwargs: object, + ) -> Callable[[Callable[..., object]], Callable[..., object]]: + def decorate(view: Callable[..., object]) -> Callable[..., object]: + def admitted(resource: Resource) -> object: + return view(resource, CONTEXT) return admitted @@ -249,17 +314,17 @@ def admitted(self): class Endpoint(Resource): @admission.console_variable_admission("pipeline") - def get(self, _context): + def get(self, _context: RequestContext) -> None: raise failure api.add_resource(Endpoint, "/variables") response = web.test_client().get("/variables") assert response.status_code == status - assert response.json["code"] == code + assert response_mapping(response.get_json())["code"] == code @pytest.mark.parametrize("patch", [False, True]) -def test_environment_payload_preserves_patch_and_replacement(monkeypatch, patch): +def test_environment_payload_preserves_patch_and_replacement(monkeypatch: pytest.MonkeyPatch, patch: bool) -> None: variables = Variables() monkeypatch.setattr(app, "application_services", lambda: Services(variables)) value = {"id": "env-a", "name": "a", "value_type": "string", "value": "new-a"} @@ -271,6 +336,8 @@ def test_environment_payload_preserves_patch_and_replacement(monkeypatch, patch) assert response == {"result": "success"} operation, context, owner, args, kwargs = variables.calls[0] assert (operation, context, owner) == ("update_environment", CONTEXT, str(OWNER_ID)) - assert args[0][0]["id"] == "env-a" - assert args[0][0]["value"] == "new-a" + values = args[0] + assert isinstance(values, Sequence) + assert response_mapping(values[0])["id"] == "env-a" + assert response_mapping(values[0])["value"] == "new-a" assert kwargs == {"deleted_ids": ["env-b"] if patch else None} From 91af5de72339fe8396195d6e86911c3b40735b0c Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Fri, 9 Oct 2026 12:51:57 +0800 Subject: [PATCH 3/8] fix(api): remove agent import cycle and align variable controller tests --- api/services/agent/__init__.py | 5 +- .../app/test_workflow_draft_variable.py | 4 ++ .../controllers/console/app/test_app_apis.py | 66 +++++++++---------- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/api/services/agent/__init__.py b/api/services/agent/__init__.py index c3a5ebaec4a1ae..c95169198a764a 100644 --- a/api/services/agent/__init__.py +++ b/api/services/agent/__init__.py @@ -1,4 +1 @@ -from .composer_service import AgentComposerService -from .roster_service import AgentRosterService - -__all__ = ["AgentComposerService", "AgentRosterService"] +"""Agent application services.""" diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py index a071d22ee9112e..4f164143f14d42 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py @@ -288,6 +288,10 @@ def test_variable_api_patch_success( assert payload["id"] == variable.id assert payload["name"] == "renamed_var" + # The request updates the variable through an independently scoped repository + # session, so invalidate the fixture session's cached instance before verifying + # the committed database state. + db_session_with_containers.expire(variable) refreshed = db_session_with_containers.scalar( select(WorkflowDraftVariable).where(WorkflowDraftVariable.id == variable.id) ) diff --git a/api/tests/unit_tests/controllers/console/app/test_app_apis.py b/api/tests/unit_tests/controllers/console/app/test_app_apis.py index bb9ed018407d24..d61014851a712f 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_apis.py @@ -513,31 +513,31 @@ def test_workflow_variable_creation(self): def test_workflow_variable_collection_get(self, database_app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = workflow_draft_variable_module.WorkflowVariableCollectionApi() method = unwrap(api.get) - - class DummyDraftService: - def __init__(self, session: Session): - self.session = session - - def list_variables_without_values(self, **_kwargs): - assert self.session.get_bind() is db.engine - return {"items": [], "total": 0} - - class DummyWorkflowService: - def is_workflow_exist(self, *args, **kwargs): - return True - - monkeypatch.setattr(workflow_draft_variable_module, "WorkflowDraftVariableService", DummyDraftService) - monkeypatch.setattr(workflow_draft_variable_module, "WorkflowService", DummyWorkflowService) + workflow_variables = MagicMock() + empty_items: list[object] = [] + workflow_variables.list_variables.return_value = {"items": empty_items, "total": 0} + monkeypatch.setattr( + workflow_draft_variable_module, + "application_services", + lambda: SimpleNamespace(console_workflow_variables=workflow_variables), + ) + request_context = _make_request_context() with database_app.test_request_context("/?page=1&limit=20"): result = method( api, WorkflowDraftVariableListQuery(page=1, limit=20), - _make_account(), - app_model=_make_app("app-1"), + request_context, + app_id=uuid.UUID(APP_ID), ) assert result == {"items": [], "total": 0} + workflow_variables.list_variables.assert_called_once() + args = workflow_variables.list_variables.call_args.args + assert args[0] == request_context + assert args[1].id == APP_ID + assert args[1].kind == "app" + assert workflow_variables.list_variables.call_args.kwargs == {"page": 1, "limit": 20} def test_environment_variable_update_payload_preserves_full_replace_default(self) -> None: payload = EnvironmentVariableUpdatePayload(environment_variables=[]) @@ -569,16 +569,13 @@ def test_environment_variable_collection_post_routes_patch_to_service( ) -> None: api = workflow_draft_variable_module.EnvironmentVariableCollectionApi() method = unwrap(api.post) - captured: dict = {} - - class DummyWorkflowService: - def patch_draft_workflow_environment_variables(self, **kwargs) -> None: - captured.update(kwargs) - - def update_draft_workflow_environment_variables(self, **_kwargs) -> None: - raise AssertionError("patch request must not use full replacement") - - monkeypatch.setattr(workflow_draft_variable_module, "WorkflowService", DummyWorkflowService) + workflow_variables = MagicMock() + monkeypatch.setattr( + workflow_draft_variable_module, + "application_services", + lambda: SimpleNamespace(console_workflow_variables=workflow_variables), + ) + request_context = _make_request_context() with database_app.test_request_context( "/", @@ -595,16 +592,17 @@ def update_draft_workflow_environment_variables(self, **_kwargs) -> None: patch=True, deleted_environment_variable_ids=["env-b"], ), - _make_account(), - app_model=_make_app(), + request_context, + app_id=uuid.UUID(APP_ID), ) assert result == {"result": "success"} - assert [(variable.id, variable.value) for variable in captured["environment_variables"]] == [("env-a", "new-a")] - assert captured["deleted_environment_variable_ids"] == ["env-b"] - assert captured["app_model"].id == APP_ID - assert captured["account"].id == USER_ID - assert captured["session"].get_bind() is db.engine + workflow_variables.update_environment.assert_called_once_with( + request_context, + APP_ID, + [{"id": "env-a", "name": "a", "value_type": "string", "value": "new-a"}], + deleted_ids=["env-b"], + ) class TestWorkflowStatisticEndpoints: From f3f6a81ad71e2c896c9f8fa68f73400464ecf0dc Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Fri, 9 Oct 2026 12:52:41 +0800 Subject: [PATCH 4/8] docs(api): regenerate workflow variable endpoint contracts --- api/openapi/markdown/console-openapi.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 2d2e98b7506aa2..77599b37080a6c 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -3947,7 +3947,7 @@ Update conversation variables for workflow draft | 200 | Conversation variables updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /apps/{app_id}/workflows/draft/environment-variables -**Get draft workflow** +**Get environment variables** Get environment variables for workflow @@ -4517,7 +4517,7 @@ Delete all draft workflow variables | 204 | Workflow variables deleted successfully | ### [GET] /apps/{app_id}/workflows/draft/variables -**Get draft workflow** +**List draft workflow variables without loading their values** Get draft workflow variables @@ -7855,7 +7855,7 @@ Update account-level Step-by-step Tour state | 200 | Datasource variables set successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/environment-variables -**Get draft workflow** +**Get environment variables** #### Parameters @@ -8057,7 +8057,7 @@ Update account-level Step-by-step Tour state | 204 | Workflow variables deleted successfully | ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/variables -**Get draft workflow** +**List draft pipeline variables without loading their values** #### Parameters @@ -8109,6 +8109,12 @@ Update account-level Step-by-step Tour state | pipeline_id | path | | Yes | string (uuid) | | variable_id | path | | Yes | string (uuid) | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowDraftVariablePatchPayload](#workflowdraftvariablepatchpayload)
| + #### Responses | Code | Description | Schema | From 76588a88f9f6fc7ee0d6410b8c9a23b9b5e41af4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:56:26 +0000 Subject: [PATCH 5/8] [autofix.ci] apply automated fixes --- .../generated/api/console/apps/orpc.gen.ts | 8 ++++---- .../generated/api/console/rag/orpc.gen.ts | 14 +++++++++----- .../generated/api/console/rag/types.gen.ts | 7 ++++++- .../contracts/generated/api/console/rag/zod.gen.ts | 11 +++++++++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 8df754ae8f6ee2..2fc36a797230b2 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -3232,7 +3232,7 @@ export const conversationVariables2 = { } /** - * Get draft workflow + * Get environment variables * * Get environment variables for workflow */ @@ -3243,7 +3243,7 @@ export const get64 = oc method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftEnvironmentVariables', path: '/apps/{app_id}/workflows/draft/environment-variables', - summary: 'Get draft workflow', + summary: 'Get environment variables', tags: ['console'], }) .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesPath })) @@ -4028,7 +4028,7 @@ export const delete13 = oc .output(zDeleteAppsByAppIdWorkflowsDraftVariablesResponse) /** - * Get draft workflow + * List draft workflow variables without loading their values * * Get draft workflow variables */ @@ -4039,7 +4039,7 @@ export const get75 = oc method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftVariables', path: '/apps/{app_id}/workflows/draft/variables', - summary: 'Get draft workflow', + summary: 'List draft workflow variables without loading their values', tags: ['console'], }) .input( diff --git a/packages/contracts/generated/api/console/rag/orpc.gen.ts b/packages/contracts/generated/api/console/rag/orpc.gen.ts index d2a27ca561d997..a56903c731aa67 100644 --- a/packages/contracts/generated/api/console/rag/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rag/orpc.gen.ts @@ -75,6 +75,7 @@ import { zPatchRagPipelineCustomizedTemplatesByTemplateIdResponse, zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath, zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse, + zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody, zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath, zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse, zPostRagPipelineCustomizedTemplatesByTemplateIdPath, @@ -609,7 +610,7 @@ export const datasource = { } /** - * Get draft workflow + * Get environment variables */ export const get12 = oc .route({ @@ -617,7 +618,7 @@ export const get12 = oc method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariables', path: '/rag/pipelines/{pipeline_id}/workflows/draft/environment-variables', - summary: 'Get draft workflow', + summary: 'Get environment variables', tags: ['console'], }) .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesPath })) @@ -924,7 +925,10 @@ export const patch2 = oc tags: ['console'], }) .input( - z.object({ params: zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath }), + z.object({ + body: zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody, + params: zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath, + }), ) .output(zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse) @@ -948,7 +952,7 @@ export const delete4 = oc .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse) /** - * Get draft workflow + * List draft pipeline variables without loading their values */ export const get19 = oc .route({ @@ -956,7 +960,7 @@ export const get19 = oc method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftVariables', path: '/rag/pipelines/{pipeline_id}/workflows/draft/variables', - summary: 'Get draft workflow', + summary: 'List draft pipeline variables without loading their values', tags: ['console'], }) .input( diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 70953beb7726cf..ff52d7d6d80a3c 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -277,6 +277,11 @@ export type WorkflowDraftVariableResponse = { visible: boolean } +export type WorkflowDraftVariablePatchPayload = { + name?: string | null + value?: unknown | null +} + export type RagPipelineWorkflowPublishResponse = { created_at: number result: string @@ -1428,7 +1433,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdRespon GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses] export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdData = { - body?: never + body: WorkflowDraftVariablePatchPayload path: { pipeline_id: string variable_id: string diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index a5afe6f494881d..983cd6b4018472 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -126,6 +126,14 @@ export const zDraftWorkflowRunPayload = z.object({ start_node_id: z.string(), }) +/** + * WorkflowDraftVariablePatchPayload + */ +export const zWorkflowDraftVariablePatchPayload = z.object({ + name: z.string().nullish(), + value: z.unknown().nullish(), +}) + /** * RagPipelineWorkflowPublishResponse */ @@ -1382,6 +1390,9 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = zWorkflowDraftVariableResponse +export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody = + zWorkflowDraftVariablePatchPayload + export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath = z.object({ pipeline_id: z.uuid(), variable_id: z.uuid(), From 6b329c608efce2f82e6945d4170d3ba2e0e41fd2 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Mon, 26 Oct 2026 20:31:40 +0800 Subject: [PATCH 6/8] Use the relocated RBAC introspection helper in variable transport tests --- .../controllers/console/app/test_workflow_variable_transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py index 20e6e3edd4d926..3da1b03595564f 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py @@ -29,7 +29,7 @@ DraftVariableView, InvalidDraftVariableError, ) -from tests.unit_tests.controllers.rbac_introspection import rbac_checks +from tests.helpers.unit.controllers.rbac_introspection import rbac_checks CONTEXT = RequestContext("request", None, "account-1", "tenant-1") OWNER_ID = UUID("00000000-0000-0000-0000-000000000001") From c97280bf82f45eed00b2534cd42859d5c33cc179 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:31:39 +0000 Subject: [PATCH 7/8] [autofix.ci] apply automated fixes --- .../generated/api/console/apps/errors.gen.ts | 24 ------------------- .../generated/api/console/errors.gen.ts | 19 --------------- .../generated/api/console/rag/errors.gen.ts | 18 -------------- 3 files changed, 61 deletions(-) diff --git a/packages/contracts/generated/api/console/apps/errors.gen.ts b/packages/contracts/generated/api/console/apps/errors.gen.ts index 1ca81cfa7c000b..9231b7f4b1510f 100644 --- a/packages/contracts/generated/api/console/apps/errors.gen.ts +++ b/packages/contracts/generated/api/console/apps/errors.gen.ts @@ -919,17 +919,10 @@ export const get65Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const get66Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const get67Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const post44Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const get68Errors = { - app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } -export const post45Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post46Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const post47Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } @@ -993,10 +986,6 @@ export const post56Errors = { app_not_found: { status: 404, data: zAppNotFoundEr export const post57Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const delete11Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const get72Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post58Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, @@ -1025,8 +1014,6 @@ export const get76Errors = { workflow_run_not_found: { status: 404, data: zWorkflowRunNotFoundError }, } -export const get77Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post59Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, @@ -1037,18 +1024,7 @@ export const post60Errors = { rate_limit_error: { status: 429, data: zRateLimitError }, } -export const put6Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const delete12Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const get78Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const patch2Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const delete13Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const get79Errors = { - app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } diff --git a/packages/contracts/generated/api/console/errors.gen.ts b/packages/contracts/generated/api/console/errors.gen.ts index 34e0844610ffc4..502c92c4e6b72a 100644 --- a/packages/contracts/generated/api/console/errors.gen.ts +++ b/packages/contracts/generated/api/console/errors.gen.ts @@ -1,11 +1,8 @@ // This file is auto-generated by scripts/add-orpc-error-maps.ts export { delete10Errors as 'DELETE /apps/{app_id}/workflow/comments/{comment_id}' } from './apps/errors.gen.ts' export { delete10Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/research-tasks/{task_id}' } from './knowledge-fs/errors.gen.ts' -export { delete11Errors as 'DELETE /apps/{app_id}/workflows/draft/nodes/{node_id}/variables' } from './apps/errors.gen.ts' export { delete11Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/sources/{source_id}' } from './knowledge-fs/errors.gen.ts' -export { delete12Errors as 'DELETE /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { delete12Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}' } from './knowledge-fs/errors.gen.ts' -export { delete13Errors as 'DELETE /apps/{app_id}/workflows/draft/variables' } from './apps/errors.gen.ts' export { delete13Errors as 'DELETE /knowledge-fs/uploads/{upload_id}' } from './knowledge-fs/errors.gen.ts' export { delete14Errors as 'DELETE /apps/{app_id}/workflows/{workflow_id}' } from './apps/errors.gen.ts' export { delete16Errors as 'DELETE /workspaces/current/skills/{skill_id}' } from './workspaces/errors.gen.ts' @@ -14,15 +11,12 @@ export { delete2Errors as 'DELETE /apps/{app_id}/agent/config/skills/{name}' } f export { delete2Errors as 'DELETE /datasets/external-knowledge-api/{external_knowledge_api_id}' } from './datasets/errors.gen.ts' export { delete2Errors as 'DELETE /installed-apps/{installed_app_id}/saved-messages/{message_id}' } from './installed-apps/errors.gen.ts' export { delete2Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/app-bindings/{caller_kind}/{app_id}' } from './knowledge-fs/errors.gen.ts' -export { delete2Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' } from './rag/errors.gen.ts' export { delete3Errors as 'DELETE /agent/{agent_id}/config/files/{name}' } from './agent/errors.gen.ts' export { delete3Errors as 'DELETE /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } from './datasets/errors.gen.ts' export { delete3Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/documents/bulk' } from './knowledge-fs/errors.gen.ts' -export { delete3Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { delete4Errors as 'DELETE /agent/{agent_id}/config/skills/{name}' } from './agent/errors.gen.ts' export { delete4Errors as 'DELETE /apps/{app_id}/annotations' } from './apps/errors.gen.ts' export { delete4Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/documents/{document_id}' } from './knowledge-fs/errors.gen.ts' -export { delete4Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/variables' } from './rag/errors.gen.ts' export { delete4Errors as 'DELETE /workspaces/current/members/{member_id}' } from './workspaces/errors.gen.ts' export { delete5Errors as 'DELETE /agent/{agent_id}/trace-config' } from './agent/errors.gen.ts' export { delete5Errors as 'DELETE /apps/{app_id}/chat-conversations/{conversation_id}' } from './apps/errors.gen.ts' @@ -63,7 +57,6 @@ export { get13Errors as 'GET /trial-apps/{app_id}/messages/{message_id}/suggeste export { get14Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/content' } from './agent/errors.gen.ts' export { get14Errors as 'GET /apps/{app_id}/agent/config/skills/{name}/inspect' } from './apps/errors.gen.ts' export { get14Errors as 'GET /knowledge-fs/spaces/{control_space_id}/documents/{document_id}/revisions' } from './knowledge-fs/errors.gen.ts' -export { get14Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' } from './rag/errors.gen.ts' export { get14Errors as 'GET /snippets/{snippet_id}/workflows/draft/variables' } from './snippets/errors.gen.ts' export { get14Errors as 'GET /trial-apps/{app_id}/parameters' } from './trial-apps/errors.gen.ts' export { get15Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/download' } from './agent/errors.gen.ts' @@ -80,12 +73,10 @@ export { get16Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/proces export { get16Errors as 'GET /trial-apps/{app_id}/workflows' } from './trial-apps/errors.gen.ts' export { get17Errors as 'GET /agent/{agent_id}/config/skills/{name}/inspect' } from './agent/errors.gen.ts' export { get17Errors as 'GET /knowledge-fs/spaces/{control_space_id}/external-access' } from './knowledge-fs/errors.gen.ts' -export { get17Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/system-variables' } from './rag/errors.gen.ts' export { get17Errors as 'GET /trial-apps/{app_id}' } from './trial-apps/errors.gen.ts' export { get18Errors as 'GET /agent/{agent_id}/config/skills' } from './agent/errors.gen.ts' export { get18Errors as 'GET /datasets/{dataset_id}/documents/{document_id}/notion/sync' } from './datasets/errors.gen.ts' export { get18Errors as 'GET /knowledge-fs/spaces/{control_space_id}/golden-questions' } from './knowledge-fs/errors.gen.ts' -export { get18Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { get19Errors as 'GET /agent/{agent_id}/log-sources' } from './agent/errors.gen.ts' export { get19Errors as 'GET /knowledge-fs/spaces/{control_space_id}/jobs/{job_id}' } from './knowledge-fs/errors.gen.ts' export { get19Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/variables' } from './rag/errors.gen.ts' @@ -228,7 +219,6 @@ export { get63Errors as 'GET /apps/{app_id}/workflow/statistics/daily-terminals' export { get64Errors as 'GET /apps/{app_id}/workflow/statistics/token-costs' } from './apps/errors.gen.ts' export { get65Errors as 'GET /apps/{app_id}/workflows/default-workflow-block-configs/{block_type}' } from './apps/errors.gen.ts' export { get66Errors as 'GET /apps/{app_id}/workflows/default-workflow-block-configs' } from './apps/errors.gen.ts' -export { get67Errors as 'GET /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' export { get68Errors as 'GET /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' export { get69Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates' } from './apps/errors.gen.ts' export { get6Errors as 'GET /agent/{agent_id}/chat-messages' } from './agent/errors.gen.ts' @@ -239,13 +229,10 @@ export { get6Errors as 'GET /rag/pipelines/{pipeline_id}/exports' } from './rag/ export { get6Errors as 'GET /trial-apps/{app_id}/agent/config/skills/{name}/files/content' } from './trial-apps/errors.gen.ts' export { get70Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './apps/errors.gen.ts' export { get71Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run' } from './apps/errors.gen.ts' -export { get72Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/variables' } from './apps/errors.gen.ts' export { get73Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/events' } from './apps/errors.gen.ts' export { get74Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview' } from './apps/errors.gen.ts' export { get75Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}' } from './apps/errors.gen.ts' export { get76Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs' } from './apps/errors.gen.ts' -export { get77Errors as 'GET /apps/{app_id}/workflows/draft/system-variables' } from './apps/errors.gen.ts' -export { get78Errors as 'GET /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { get79Errors as 'GET /apps/{app_id}/workflows/draft/variables' } from './apps/errors.gen.ts' export { get7Errors as 'GET /account/profile' } from './account/errors.gen.ts' export { get7Errors as 'GET /agent/{agent_id}/composer/candidates' } from './agent/errors.gen.ts' @@ -287,11 +274,9 @@ export { getErrors as 'GET /installed-apps/{installed_app_id}/conversations' } f export { getErrors as 'GET /remote-files/{url}' } from './remote-files/errors.gen.ts' export { getErrors as 'GET /trial-apps/{app_id}/agent-composer' } from './trial-apps/errors.gen.ts' export { getErrors as 'GET /website/crawl/status/{job_id}' } from './website/errors.gen.ts' -export { patch2Errors as 'PATCH /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { patch2Errors as 'PATCH /datasets/{dataset_id}/documents/status/{action}/batch' } from './datasets/errors.gen.ts' export { patch2Errors as 'PATCH /installed-apps/{installed_app_id}/conversations/{c_id}/unpin' } from './installed-apps/errors.gen.ts' export { patch2Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}/golden-questions/{question_id}' } from './knowledge-fs/errors.gen.ts' -export { patch2Errors as 'PATCH /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { patch3Errors as 'PATCH /apps/{app_id}/workflows/{workflow_id}' } from './apps/errors.gen.ts' export { patch3Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/processing/pause' } from './datasets/errors.gen.ts' export { patch3Errors as 'PATCH /installed-apps/{installed_app_id}' } from './installed-apps/errors.gen.ts' @@ -452,9 +437,7 @@ export { post42Errors as 'POST /apps/{app_id}/workflow/comments/{comment_id}/res export { post42Errors as 'POST /knowledge-fs/uploads' } from './knowledge-fs/errors.gen.ts' export { post43Errors as 'POST /apps/{app_id}/workflow/comments' } from './apps/errors.gen.ts' export { post43Errors as 'POST /workspaces/current/plugin/upload/github' } from './workspaces/errors.gen.ts' -export { post44Errors as 'POST /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' export { post44Errors as 'POST /workspaces/current/plugin/upload/pkg' } from './workspaces/errors.gen.ts' -export { post45Errors as 'POST /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' export { post46Errors as 'POST /apps/{app_id}/workflows/draft/features' } from './apps/errors.gen.ts' export { post47Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test' } from './apps/errors.gen.ts' export { post48Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview' } from './apps/errors.gen.ts' @@ -559,10 +542,8 @@ export { put4Errors as 'PUT /apps/{app_id}/workflow/comments/{comment_id}' } fro export { put4Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/sync-policy' } from './knowledge-fs/errors.gen.ts' export { put5Errors as 'PUT /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './apps/errors.gen.ts' export { put5Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/tags' } from './knowledge-fs/errors.gen.ts' -export { put6Errors as 'PUT /apps/{app_id}/workflows/draft/variables/{variable_id}/reset' } from './apps/errors.gen.ts' export { putErrors as 'PUT /agent/{agent_id}/build-draft' } from './agent/errors.gen.ts' export { putErrors as 'PUT /apps/{app_id}/network-access-group' } from './apps/errors.gen.ts' export { putErrors as 'PUT /knowledge-fs/spaces/{control_space_id}/app-bindings' } from './knowledge-fs/errors.gen.ts' -export { putErrors as 'PUT /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}/reset' } from './rag/errors.gen.ts' export { putErrors as 'PUT /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './snippets/errors.gen.ts' export { putErrors as 'PUT /workspaces/current/agents/{agent_id}/skills' } from './workspaces/errors.gen.ts' diff --git a/packages/contracts/generated/api/console/rag/errors.gen.ts b/packages/contracts/generated/api/console/rag/errors.gen.ts index f87498e70afbc6..501d339d7f2b47 100644 --- a/packages/contracts/generated/api/console/rag/errors.gen.ts +++ b/packages/contracts/generated/api/console/rag/errors.gen.ts @@ -78,7 +78,6 @@ export const post10Errors = { pipeline_not_found: { status: 404, data: zPipeline export const get12Errors = { draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - pipeline_not_found: { status: 404, data: zPipelineNotFoundError }, } export const post11Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } @@ -89,10 +88,6 @@ export const get13Errors = { pipeline_not_found: { status: 404, data: zPipelineN export const post13Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } -export const delete2Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const get14Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - export const get15Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } export const get16Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } @@ -102,21 +97,8 @@ export const post14Errors = { rate_limit_error: { status: 429, data: zRateLimitError }, } -export const get17Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const putErrors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const delete3Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const get18Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const patch2Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const delete4Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - export const get19Errors = { draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - pipeline_not_found: { status: 404, data: zPipelineNotFoundError }, } export const get20Errors = { From 5ae42efe38f9f01c5ec907182adb0838c6582507 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Sat, 31 Oct 2026 21:35:03 +0800 Subject: [PATCH 8/8] Regenerate API error contracts from merged workflow variable handlers --- .../generated/api/console/apps/errors.gen.ts | 24 ------------------- .../generated/api/console/errors.gen.ts | 19 --------------- 2 files changed, 43 deletions(-) diff --git a/packages/contracts/generated/api/console/apps/errors.gen.ts b/packages/contracts/generated/api/console/apps/errors.gen.ts index fe263698edf6ed..b462c78d7d9d11 100644 --- a/packages/contracts/generated/api/console/apps/errors.gen.ts +++ b/packages/contracts/generated/api/console/apps/errors.gen.ts @@ -1067,17 +1067,10 @@ export const get65Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const get66Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const get67Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const post46Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const get68Errors = { - app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } -export const post47Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post48Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const post49Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } @@ -1143,10 +1136,6 @@ export const post59Errors = { app_not_found: { status: 404, data: zAppNotFoundEr export const post60Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const delete11Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const get72Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post61Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, @@ -1175,8 +1164,6 @@ export const get76Errors = { workflow_run_not_found: { status: 404, data: zWorkflowRunNotFoundError }, } -export const get77Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post62Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, @@ -1187,18 +1174,7 @@ export const post63Errors = { rate_limit_error: { status: 429, data: zRateLimitError }, } -export const put6Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const delete12Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const get78Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const patch2Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const delete13Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const get79Errors = { - app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } diff --git a/packages/contracts/generated/api/console/errors.gen.ts b/packages/contracts/generated/api/console/errors.gen.ts index a76961cb811b58..69d7b030395261 100644 --- a/packages/contracts/generated/api/console/errors.gen.ts +++ b/packages/contracts/generated/api/console/errors.gen.ts @@ -1,12 +1,9 @@ // This file is auto-generated by scripts/add-orpc-error-maps.ts export { delete10Errors as 'DELETE /apps/{app_id}/workflow/comments/{comment_id}' } from './apps/errors.gen.ts' export { delete10Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/research-tasks/{task_id}' } from './knowledge-fs/errors.gen.ts' -export { delete11Errors as 'DELETE /apps/{app_id}/workflows/draft/nodes/{node_id}/variables' } from './apps/errors.gen.ts' export { delete11Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/sources/{source_id}' } from './knowledge-fs/errors.gen.ts' -export { delete12Errors as 'DELETE /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { delete12Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}' } from './knowledge-fs/errors.gen.ts' export { delete12Errors as 'DELETE /workspaces/current/network-access-groups/{group_id}' } from './workspaces/errors.gen.ts' -export { delete13Errors as 'DELETE /apps/{app_id}/workflows/draft/variables' } from './apps/errors.gen.ts' export { delete13Errors as 'DELETE /knowledge-fs/uploads/{upload_id}' } from './knowledge-fs/errors.gen.ts' export { delete14Errors as 'DELETE /apps/{app_id}/workflows/{workflow_id}' } from './apps/errors.gen.ts' export { delete20Errors as 'DELETE /workspaces/current/skills/{skill_id}' } from './workspaces/errors.gen.ts' @@ -15,15 +12,12 @@ export { delete2Errors as 'DELETE /apps/{app_id}/agent/config/skills/{name}' } f export { delete2Errors as 'DELETE /datasets/external-knowledge-api/{external_knowledge_api_id}' } from './datasets/errors.gen.ts' export { delete2Errors as 'DELETE /installed-apps/{installed_app_id}/saved-messages/{message_id}' } from './installed-apps/errors.gen.ts' export { delete2Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/app-bindings/{caller_kind}/{app_id}' } from './knowledge-fs/errors.gen.ts' -export { delete2Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' } from './rag/errors.gen.ts' export { delete3Errors as 'DELETE /agent/{agent_id}/config/files/{name}' } from './agent/errors.gen.ts' export { delete3Errors as 'DELETE /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } from './datasets/errors.gen.ts' export { delete3Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/documents/bulk' } from './knowledge-fs/errors.gen.ts' -export { delete3Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { delete4Errors as 'DELETE /agent/{agent_id}/config/skills/{name}' } from './agent/errors.gen.ts' export { delete4Errors as 'DELETE /apps/{app_id}/annotations' } from './apps/errors.gen.ts' export { delete4Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/documents/{document_id}' } from './knowledge-fs/errors.gen.ts' -export { delete4Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/variables' } from './rag/errors.gen.ts' export { delete5Errors as 'DELETE /agent/{agent_id}/trace-config' } from './agent/errors.gen.ts' export { delete5Errors as 'DELETE /apps/{app_id}/chat-conversations/{conversation_id}' } from './apps/errors.gen.ts' export { delete5Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/golden-questions/{question_id}' } from './knowledge-fs/errors.gen.ts' @@ -66,7 +60,6 @@ export { get13Errors as 'GET /trial-apps/{app_id}/messages/{message_id}/suggeste export { get14Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/content' } from './agent/errors.gen.ts' export { get14Errors as 'GET /apps/{app_id}/agent/config/skills/{name}/inspect' } from './apps/errors.gen.ts' export { get14Errors as 'GET /knowledge-fs/spaces/{control_space_id}/documents/{document_id}/revisions' } from './knowledge-fs/errors.gen.ts' -export { get14Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' } from './rag/errors.gen.ts' export { get14Errors as 'GET /snippets/{snippet_id}/workflows/draft/variables' } from './snippets/errors.gen.ts' export { get14Errors as 'GET /trial-apps/{app_id}/parameters' } from './trial-apps/errors.gen.ts' export { get15Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/download' } from './agent/errors.gen.ts' @@ -83,12 +76,10 @@ export { get16Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/proces export { get16Errors as 'GET /trial-apps/{app_id}/workflows' } from './trial-apps/errors.gen.ts' export { get17Errors as 'GET /agent/{agent_id}/config/skills/{name}/inspect' } from './agent/errors.gen.ts' export { get17Errors as 'GET /knowledge-fs/spaces/{control_space_id}/external-access' } from './knowledge-fs/errors.gen.ts' -export { get17Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/system-variables' } from './rag/errors.gen.ts' export { get17Errors as 'GET /trial-apps/{app_id}' } from './trial-apps/errors.gen.ts' export { get18Errors as 'GET /agent/{agent_id}/config/skills' } from './agent/errors.gen.ts' export { get18Errors as 'GET /datasets/{dataset_id}/documents/{document_id}/notion/sync' } from './datasets/errors.gen.ts' export { get18Errors as 'GET /knowledge-fs/spaces/{control_space_id}/golden-questions' } from './knowledge-fs/errors.gen.ts' -export { get18Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { get19Errors as 'GET /agent/{agent_id}/log-sources' } from './agent/errors.gen.ts' export { get19Errors as 'GET /knowledge-fs/spaces/{control_space_id}/jobs/{job_id}' } from './knowledge-fs/errors.gen.ts' export { get19Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/variables' } from './rag/errors.gen.ts' @@ -239,7 +230,6 @@ export { get63Errors as 'GET /apps/{app_id}/workflow/statistics/daily-terminals' export { get64Errors as 'GET /apps/{app_id}/workflow/statistics/token-costs' } from './apps/errors.gen.ts' export { get65Errors as 'GET /apps/{app_id}/workflows/default-workflow-block-configs/{block_type}' } from './apps/errors.gen.ts' export { get66Errors as 'GET /apps/{app_id}/workflows/default-workflow-block-configs' } from './apps/errors.gen.ts' -export { get67Errors as 'GET /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' export { get68Errors as 'GET /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' export { get69Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates' } from './apps/errors.gen.ts' export { get6Errors as 'GET /agent/{agent_id}/chat-messages' } from './agent/errors.gen.ts' @@ -250,13 +240,10 @@ export { get6Errors as 'GET /rag/pipelines/{pipeline_id}/exports' } from './rag/ export { get6Errors as 'GET /trial-apps/{app_id}/agent/config/skills/{name}/files/content' } from './trial-apps/errors.gen.ts' export { get70Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './apps/errors.gen.ts' export { get71Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run' } from './apps/errors.gen.ts' -export { get72Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/variables' } from './apps/errors.gen.ts' export { get73Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/events' } from './apps/errors.gen.ts' export { get74Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview' } from './apps/errors.gen.ts' export { get75Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}' } from './apps/errors.gen.ts' export { get76Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs' } from './apps/errors.gen.ts' -export { get77Errors as 'GET /apps/{app_id}/workflows/draft/system-variables' } from './apps/errors.gen.ts' -export { get78Errors as 'GET /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { get79Errors as 'GET /apps/{app_id}/workflows/draft/variables' } from './apps/errors.gen.ts' export { get7Errors as 'GET /account/profile' } from './account/errors.gen.ts' export { get7Errors as 'GET /agent/{agent_id}/composer/candidates' } from './agent/errors.gen.ts' @@ -298,11 +285,9 @@ export { getErrors as 'GET /remote-files/{url}' } from './remote-files/errors.ge export { getErrors as 'GET /trial-apps/{app_id}/agent-composer' } from './trial-apps/errors.gen.ts' export { getErrors as 'GET /website/crawl/status/{job_id}' } from './website/errors.gen.ts' export { getErrors as 'GET /workflow-run-archives/downloads/{download_id}' } from './workflow-run-archives/errors.gen.ts' -export { patch2Errors as 'PATCH /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { patch2Errors as 'PATCH /datasets/{dataset_id}/documents/status/{action}/batch' } from './datasets/errors.gen.ts' export { patch2Errors as 'PATCH /installed-apps/{installed_app_id}/conversations/{c_id}/unpin' } from './installed-apps/errors.gen.ts' export { patch2Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}/golden-questions/{question_id}' } from './knowledge-fs/errors.gen.ts' -export { patch2Errors as 'PATCH /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { patch3Errors as 'PATCH /apps/{app_id}/workflows/{workflow_id}' } from './apps/errors.gen.ts' export { patch3Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/processing/pause' } from './datasets/errors.gen.ts' export { patch3Errors as 'PATCH /installed-apps/{installed_app_id}' } from './installed-apps/errors.gen.ts' @@ -455,9 +440,7 @@ export { post44Errors as 'POST /apps/{app_id}/workflow/comments/{comment_id}/res export { post44Errors as 'POST /workspaces/current/plugin/list/installations/ids' } from './workspaces/errors.gen.ts' export { post45Errors as 'POST /apps/{app_id}/workflow/comments' } from './apps/errors.gen.ts' export { post45Errors as 'POST /workspaces/current/plugin/list/latest-versions' } from './workspaces/errors.gen.ts' -export { post46Errors as 'POST /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' export { post46Errors as 'POST /workspaces/current/plugin/parameters/dynamic-options-with-credentials' } from './workspaces/errors.gen.ts' -export { post47Errors as 'POST /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' export { post48Errors as 'POST /apps/{app_id}/workflows/draft/features' } from './apps/errors.gen.ts' export { post48Errors as 'POST /workspaces/current/plugin/tasks/delete_all' } from './workspaces/errors.gen.ts' export { post49Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test' } from './apps/errors.gen.ts' @@ -571,12 +554,10 @@ export { put4Errors as 'PUT /apps/{app_id}/workflow/comments/{comment_id}' } fro export { put4Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/sync-policy' } from './knowledge-fs/errors.gen.ts' export { put5Errors as 'PUT /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './apps/errors.gen.ts' export { put5Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/tags' } from './knowledge-fs/errors.gen.ts' -export { put6Errors as 'PUT /apps/{app_id}/workflows/draft/variables/{variable_id}/reset' } from './apps/errors.gen.ts' export { put6Errors as 'PUT /workspaces/current/members/{member_id}/update-role' } from './workspaces/errors.gen.ts' export { put9Errors as 'PUT /workspaces/current/network-access-groups/{group_id}' } from './workspaces/errors.gen.ts' export { putErrors as 'PUT /agent/{agent_id}/build-draft' } from './agent/errors.gen.ts' export { putErrors as 'PUT /apps/{app_id}/network-access-group' } from './apps/errors.gen.ts' export { putErrors as 'PUT /knowledge-fs/spaces/{control_space_id}/app-bindings' } from './knowledge-fs/errors.gen.ts' -export { putErrors as 'PUT /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}/reset' } from './rag/errors.gen.ts' export { putErrors as 'PUT /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './snippets/errors.gen.ts' export { putErrors as 'PUT /workspaces/current/agents/{agent_id}/skills' } from './workspaces/errors.gen.ts'