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 a8fdaf9810b32f..7b061be1fe1cf6 100644 --- a/api/controllers/common/errors.py +++ b/api/controllers/common/errors.py @@ -1,14 +1,19 @@ +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 werkzeug.exceptions import HTTPException from core.logging.context import get_request_id from libs.exception import BaseHTTPException from libs.external_api import ExternalApi +from services.annotation.errors import AnnotationResourceNotFoundError from services.credentials.refresh import CredentialRefreshUnavailableError from services.errors.base import NoPermissionError +from services.errors.dataset import DatasetNotFoundError +from services.errors.document import DocumentAccessDeniedError, DocumentNotFoundError, DocumentSourceNotFoundError class FilenameNotExistsError(BaseHTTPException): @@ -167,6 +172,96 @@ 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 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: ExternalApi) -> 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) + for error_type in ( + AnnotationResourceNotFoundError, + DatasetNotFoundError, + DocumentNotFoundError, + DocumentSourceNotFoundError, + ): + api.document_error_handler(error_type, status=HTTPStatus.NOT_FOUND, code=NotFoundError.error_code) + api.document_error_handler( + DocumentAccessDeniedError, + status=HTTPStatus.FORBIDDEN, + code=ForbiddenError.error_code, + ) + + def register_permission_error_handler(api: ExternalApi) -> 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 96253ec6c6f03b..f732b305146872 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -15,6 +15,7 @@ UnauthorizedError, register_credential_refresh_error_handler, register_permission_error_handler, + register_resource_error_handlers, ) from controllers.console.error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout from controllers.console.workspace.error import AccountNotInitializedError @@ -26,6 +27,7 @@ WorkflowRunArchiveInvalidMonthError, WorkflowRunArchiveNotFoundError, ) +from services.workflow.variable_contracts import DraftVariableChangedError bp = Blueprint("console", __name__, url_prefix="/console/api") register_app_access_error_metadata(bp, surface="console") @@ -37,6 +39,7 @@ description="Console management APIs for app configuration, monitoring, and administration", ) register_permission_error_handler(api) +register_resource_error_handlers(api) register_credential_refresh_error_handler(api) # Errors the Console client handles centrally (bootstrap redirects, forced logout, malformed # input); any operation may answer with them, so no operation lists them. The shared @@ -117,6 +120,26 @@ def _handle_workflow_run_archive_download_not_ready_error( api.document_error_handler(WorkflowRunArchiveDownloadNotReadyError, status=HTTPStatus.CONFLICT, code="conflict") +@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) +api.document_error_handler( + DraftVariableChangedError, + status=HTTPStatus.CONFLICT, + code="draft_variable_changed", +) + + 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 a008d937eca58d..7db03a2d550f79 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -1,39 +1,20 @@ -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.error import DraftWorkflowNotExist +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,17 +23,10 @@ WorkflowDraftVariableResponse, WorkflowDraftVariableWithoutValueResponse, ) -from graphon.variables.types import SegmentType from libs.external_api import error_responses 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): @@ -163,46 +137,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): @error_responses(DraftWorkflowNotExist) @@ -216,64 +150,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") @@ -284,35 +178,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"}) @@ -322,18 +207,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) @@ -346,99 +223,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) @@ -454,49 +265,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") @@ -510,22 +286,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") @@ -536,31 +302,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"} @@ -574,19 +320,18 @@ 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): @error_responses(DraftWorkflowNotExist) - @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( @@ -595,38 +340,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") @@ -637,38 +356,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 aa69080197c8e4..4e911bed016726 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,57 +1,30 @@ -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.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.external_api import error_responses 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): @@ -67,33 +40,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): @error_responses(DraftWorkflowNotExist) @@ -103,62 +49,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( @@ -166,48 +74,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) @@ -216,98 +109,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) @@ -319,45 +145,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") @@ -367,54 +162,26 @@ 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): @error_responses(DraftWorkflowNotExist) + @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 a9dc94fe26b385..719b76d63e81b8 100644 --- a/api/controllers/console/snippets/snippet_workflow_draft_variable.py +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -10,91 +10,33 @@ 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.external_api import error_responses 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") @@ -108,39 +50,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) @@ -153,24 +78,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) @@ -184,19 +106,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") @@ -208,72 +122,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) @@ -288,30 +163,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") @@ -325,9 +184,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") @@ -341,9 +203,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") @@ -357,28 +222,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 fd6ba352a99986..4bc9aa1abe775b 100644 --- a/api/controllers/service_api/__init__.py +++ b/api/controllers/service_api/__init__.py @@ -10,6 +10,7 @@ NotFoundError, UnauthorizedError, register_permission_error_handler, + register_resource_error_handlers, ) from libs.external_api import ExternalApi @@ -24,6 +25,7 @@ register_default_root=False, ) register_permission_error_handler(api) +register_resource_error_handlers(api) # ``invalid_param`` is what the surface's ValueError handler already emits on any operation; # ``not_found``, ``unauthorized`` and ``internal_server_error`` are the codes Werkzeug's own # exceptions already produce on every operation. diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index fa82127cff3a85..906e4cb840dec0 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -41,6 +41,10 @@ from extensions.application_services.resource_access_token import build_resource_access_token_service 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, @@ -236,7 +240,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 @@ -296,6 +302,8 @@ class AppScopedEndUserServices: @dataclass(frozen=True, slots=True) class ApplicationServices: + workflow_variables: WorkflowVariableService + console_workflow_variables: ConsoleWorkflowVariableService workflow_suggestions: WorkflowInstructionSuggestions annotation_commands: AnnotationCommandService annotation_imports: AnnotationImportService @@ -587,6 +595,7 @@ def build_application_services( registration=account_services.lifecycle, invitation_tokens=invitation_tokens, ) + workflow_variables = build_workflow_variable_service(database_client=database_client) conversations = ConversationRepository(session_factory=database_client) conversation_names = ConversationNameService(conversations=conversations, generate_name=generate_conversation_name) conversation_commands = ConversationCommandService( @@ -605,6 +614,10 @@ def build_application_services( ) 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), annotation_commands=AnnotationCommandService( annotations=annotations, diff --git a/api/fields/workflow_draft_variable_fields.py b/api/fields/workflow_draft_variable_fields.py index 884abaf61ece6b..716f5cf0c77af6 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 RecursiveJSONValue = str | int | float | bool | dict[str, "RecursiveJSONValue"] | list["RecursiveJSONValue"] | 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/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index f7a1a956b08f2c..046929b65e3daa 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -4647,7 +4647,7 @@ Get conversation variables for workflow | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Conversation variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| -| 404 | Draft workflow not found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| 404 | Draft workflow not found | **application/json**: [ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [POST] /apps/{app_id}/workflows/draft/conversation-variables @@ -4670,11 +4670,10 @@ Update conversation variables for workflow draft | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Conversation variables updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /apps/{app_id}/workflows/draft/environment-variables -**Get draft workflow** +**Get environment variables** Get environment variables for workflow @@ -4689,7 +4688,7 @@ Get environment variables for workflow | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Environment variables retrieved successfully | **application/json**: [EnvironmentVariableListResponse](#environmentvariablelistresponse)
| -| 404 | Draft workflow not found | **application/json**: [AppNotFoundError](#appnotfounderror)
[DraftWorkflowNotExistError](#draftworkflownotexisterror)
[ErrorResponse](#errorresponse)
| +| 404 | Draft workflow not found | **application/json**: [DraftWorkflowNotExistError](#draftworkflownotexisterror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [POST] /apps/{app_id}/workflows/draft/environment-variables @@ -4712,7 +4711,6 @@ Update environment variables for workflow draft | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Environment variables updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [POST] /apps/{app_id}/workflows/draft/features @@ -5118,7 +5116,6 @@ Delete all variables for a specific node | Code | Description | Schema | | ---- | ----------- | ------ | | 204 | Node variables deleted successfully | | -| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /apps/{app_id}/workflows/draft/nodes/{node_id}/variables @@ -5136,7 +5133,6 @@ Get variables for a specific node | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| -| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [POST] /apps/{app_id}/workflows/draft/run @@ -5253,7 +5249,6 @@ Get system variables for workflow | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| -| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [POST] /apps/{app_id}/workflows/draft/trigger/run @@ -5324,11 +5319,10 @@ Delete all draft workflow variables | Code | Description | Schema | | ---- | ----------- | ------ | | 204 | Workflow variables deleted successfully | | -| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /apps/{app_id}/workflows/draft/variables -**Get draft workflow** +**List draft workflow variables without loading their values** Get draft workflow variables @@ -5345,7 +5339,7 @@ Get draft workflow variables | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValueResponse](#workflowdraftvariablelistwithoutvalueresponse)
| -| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[DraftWorkflowNotExistError](#draftworkflownotexisterror)
[ErrorResponse](#errorresponse)
| +| 404 | Not Found | **application/json**: [DraftWorkflowNotExistError](#draftworkflownotexisterror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [DELETE] /apps/{app_id}/workflows/draft/variables/{variable_id} @@ -5363,7 +5357,7 @@ Delete a workflow variable | Code | Description | Schema | | ---- | ----------- | ------ | | 204 | Variable deleted successfully | | -| 404 | Variable not found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| 404 | Variable not found | **application/json**: [ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /apps/{app_id}/workflows/draft/variables/{variable_id} @@ -5381,7 +5375,7 @@ Get a specific workflow variable | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| -| 404 | Variable not found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| 404 | Variable not found | **application/json**: [ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [PATCH] /apps/{app_id}/workflows/draft/variables/{variable_id} @@ -5405,7 +5399,7 @@ Update a workflow variable | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| -| 404 | Variable not found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| 404 | Variable not found | **application/json**: [ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [PUT] /apps/{app_id}/workflows/draft/variables/{variable_id}/reset @@ -5424,7 +5418,7 @@ Reset a workflow variable to its default value | ---- | ----------- | ------ | | 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| | 204 | Variable reset (no content) | | -| 404 | Variable not found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| 404 | Variable not found | **application/json**: [ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /apps/{app_id}/workflows/publish @@ -11928,7 +11922,7 @@ Update account-level Step-by-step Tour state | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/environment-variables -**Get draft workflow** +**Get environment variables** #### Parameters @@ -11941,7 +11935,7 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Environment variables retrieved successfully | **application/json**: [EnvironmentVariableListResponse](#environmentvariablelistresponse)
| -| 404 | Not Found | **application/json**: [DraftWorkflowNotExistError](#draftworkflownotexisterror)
[PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| +| 404 | Not Found | **application/json**: [DraftWorkflowNotExistError](#draftworkflownotexisterror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/iteration/nodes/{node_id}/run @@ -12045,7 +12039,6 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | | 204 | Node variables deleted successfully | | -| 404 | Not Found | **application/json**: [PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables @@ -12061,7 +12054,6 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| -| 404 | Not Found | **application/json**: [PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/pre-processing/parameters @@ -12136,7 +12128,6 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| -| 404 | Not Found | **application/json**: [PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [DELETE] /rag/pipelines/{pipeline_id}/workflows/draft/variables @@ -12151,11 +12142,10 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | | 204 | Workflow variables deleted successfully | | -| 404 | Not Found | **application/json**: [PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/variables -**Get draft workflow** +**List draft pipeline variables without loading their values** #### Parameters @@ -12170,7 +12160,7 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Workflow variables retrieved successfully | **application/json**: [WorkflowDraftVariableListWithoutValueResponse](#workflowdraftvariablelistwithoutvalueresponse)
| -| 404 | Not Found | **application/json**: [DraftWorkflowNotExistError](#draftworkflownotexisterror)
[PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| +| 404 | Not Found | **application/json**: [DraftWorkflowNotExistError](#draftworkflownotexisterror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [DELETE] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} @@ -12186,7 +12176,6 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | | 204 | Variable deleted successfully | | -| 404 | Not Found | **application/json**: [PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} @@ -12202,7 +12191,6 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Variable retrieved successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| -| 404 | Not Found | **application/json**: [PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [PATCH] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} @@ -12224,7 +12212,6 @@ Update account-level Step-by-step Tour state | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Variable updated successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| -| 404 | Not Found | **application/json**: [PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [PUT] /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}/reset @@ -12241,7 +12228,6 @@ Update account-level Step-by-step Tour state | ---- | ----------- | ------ | | 200 | Variable reset successfully | **application/json**: [WorkflowDraftVariableResponse](#workflowdraftvariableresponse)
| | 204 | Variable reset (no content) | | -| 404 | Not Found | **application/json**: [PipelineNotFoundError](#pipelinenotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/publish 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/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/e2e_tests/containers/controllers/console/app/test_workflow_draft_variable.py b/api/tests/e2e_tests/containers/controllers/console/app/test_workflow_draft_variable.py index fbaf21c6dc06b8..a12a518a13cc23 100644 --- a/api/tests/e2e_tests/containers/controllers/console/app/test_workflow_draft_variable.py +++ b/api/tests/e2e_tests/containers/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/conftest.py b/api/tests/unit_tests/conftest.py index e912e4977bf570..ec3cbbde112037 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -478,3 +478,21 @@ def workflow_suggestions(sqlite_session_factory: sessionmaker[Session]) -> Workf 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 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=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/common/test_errors.py b/api/tests/unit_tests/controllers/common/test_errors.py index 90fb8f3e35dcc9..374250da231c18 100644 --- a/api/tests/unit_tests/controllers/common/test_errors.py +++ b/api/tests/unit_tests/controllers/common/test_errors.py @@ -27,6 +27,7 @@ from services.credentials.refresh import CredentialRefreshUnavailableError 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"]) @@ -72,6 +73,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, + } + + @pytest.mark.parametrize("source_api", [console_api, inner_api], ids=["console", "inner_api"]) def test_refresh_unavailability_has_one_documented_response(source_api: ExternalApi) -> None: error = CredentialRefreshUnavailableError() @@ -202,3 +223,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_app.py b/api/tests/unit_tests/controllers/console/app/test_app.py index f607a903fe39f9..0c6ad2a8d8fd59 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app.py +++ b/api/tests/unit_tests/controllers/console/app/test_app.py @@ -549,31 +549,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_APIS_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_APIS_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=[]) @@ -605,16 +605,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( "/", @@ -631,18 +628,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_APIS_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_APIS_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_APIS_APP_ID, + [{"id": "env-a", "name": "a", "value_type": "string", "value": "new-a"}], + deleted_ids=["env-b"], + ) class TestWrapsEndpoints: def test_get_app_model_context(self): 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..83e95607ee5b31 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_conflicts.py @@ -0,0 +1,222 @@ +"""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 +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.helpers.unit.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) + 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, workflow_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[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 + + 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..3da1b03595564f --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_variable_transport.py @@ -0,0 +1,343 @@ +"""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 +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.helpers.unit.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, +) + +type RecordedCall = tuple[ + str, + RequestContext, + WorkflowOwner | str, + tuple[object, ...], + dict[str, object], +] + + +@dataclass +class Variables: + 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, 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, context: RequestContext, owner: WorkflowOwner, node_id: str) -> ConsoleVariableList: + self.record("node", context, owner, node_id) + return ConsoleVariableList([VARIABLE]) + + def system(self, context: RequestContext, owner: WorkflowOwner) -> ConsoleVariableList: + self.record("system", context, owner) + return ConsoleVariableList([]) + + def conversation(self, context: RequestContext, owner: WorkflowOwner) -> ConsoleVariableList: + self.record("conversation", context, owner) + return ConsoleVariableList([]) + + def environment(self, context: RequestContext, owner: WorkflowOwner) -> list[dict[str, object]]: + self.record("environment", context, owner) + return [] + + def get(self, context: RequestContext, owner: WorkflowOwner, variable_id: str) -> DraftVariableView: + self.record("get", context, owner, variable_id) + return VARIABLE + + 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, context: RequestContext, owner: WorkflowOwner, variable_id: str) -> DraftVariableView | None: + self.record("reset", context, owner, variable_id) + return None + + def delete(self, context: RequestContext, owner: WorkflowOwner, variable_id: str) -> None: + self.record("delete", context, owner, variable_id) + + def delete_all(self, context: RequestContext, owner: WorkflowOwner) -> None: + self.record("delete_all", context, owner) + + def delete_node(self, context: RequestContext, owner: WorkflowOwner, node_id: str) -> None: + self.record("delete_node", context, owner, node_id) + + 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, + 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 +class Services: + console_workflow_variables: Variables + + +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"), + (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: 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)) + arguments: list[object] = [] if payload is None else [payload] + arguments += [CONTEXT, OWNER_ID] + if suffix: + 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] == ( + 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_mapping(response)["value"] == "answer" + elif operation == "list_variables": + 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": + 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: 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 + + 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: RequestContext) -> None: + raise failure + + api.add_resource(Endpoint, "/variables") + response = web.test_client().get("/variables") + assert response.status_code == status + assert response_mapping(response.get_json())["code"] == code + + +@pytest.mark.parametrize("patch", [False, True]) +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"} + 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)) + 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} diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py index 8b9d692e757ea4..e40ff75143d6c1 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py @@ -2,7 +2,9 @@ from collections.abc import Iterator from inspect import unwrap -from unittest.mock import ANY, PropertyMock, patch +from types import SimpleNamespace +from unittest.mock import ANY, MagicMock, PropertyMock, patch +from uuid import UUID import pytest from flask import Flask @@ -10,8 +12,10 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound +from controllers.common.rbac import RBACResourceScope from controllers.console import console_ns from controllers.console.datasets.rag_pipeline import rag_pipeline as module +from controllers.console.datasets.rag_pipeline import rag_pipeline_draft_variable as draft_variable_module from controllers.console.datasets.rag_pipeline.rag_pipeline import ( CustomizedPipelineTemplateApi, CustomizedPipelineTemplatePayload, @@ -21,6 +25,18 @@ PipelineTemplateListQuery, PublishCustomizedPipelineTemplateApi, ) +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 machinery.context import RequestContext from models.account import Account, TenantAccountRole from models.dataset import Pipeline, PipelineCustomizedTemplate from models.engine import db @@ -473,6 +489,187 @@ def test_post_rejects_legacy_non_editor_before_dataset_acl(self) -> None: check_permission.assert_not_called() publish.assert_not_called() + +_DRAFT_PIPELINE_ID = UUID("11111111-1111-1111-1111-111111111111") + + +def _draft_variable_context() -> RequestContext: + return RequestContext("request-1", None, "account-1", "tenant-1") + + +@pytest.fixture +def console_workflow_variables(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + service = MagicMock() + monkeypatch.setattr( + draft_variable_module, + "application_services", + lambda: SimpleNamespace(console_workflow_variables=service), + ) + monkeypatch.setattr(draft_variable_module, "dump_response", lambda _schema, value: value) + return service + + +def _assert_pipeline_owner(call_args: tuple[object, ...], context: RequestContext) -> None: + assert call_args[0] == context + owner = call_args[1] + assert owner.id == str(_DRAFT_PIPELINE_ID) + assert owner.kind == "pipeline" + + +def test_rag_draft_variable_routes_require_dataset_edit_permission() -> None: + [check] = rbac_checks(RagPipelineVariableApi.get) + assert check.locator.scope == RBACResourceScope.DATASET + assert check.scene == RBACPermission.DATASET_EDIT + + +class TestRagPipelineDraftVariableApis: + def test_list_variables_uses_console_variable_service( + self, app: Flask, console_workflow_variables: MagicMock + ) -> None: + console_workflow_variables.list_variables.return_value = {"items": [], "total": 0} + context = _draft_variable_context() + api = RagPipelineVariableCollectionApi() + + with app.test_request_context("/?page=2&limit=10"): + result = unwrap(api.get)(api, PaginationQuery(page=2, limit=10), context, _DRAFT_PIPELINE_ID) + + assert result == {"items": [], "total": 0} + _assert_pipeline_owner(console_workflow_variables.list_variables.call_args.args, context) + assert console_workflow_variables.list_variables.call_args.kwargs == {"page": 2, "limit": 10} + + def test_delete_all_variables_uses_console_variable_service( + self, app: Flask, console_workflow_variables: MagicMock + ) -> None: + context = _draft_variable_context() + api = RagPipelineVariableCollectionApi() + + with app.test_request_context("/"): + response = unwrap(api.delete)(api, context, _DRAFT_PIPELINE_ID) + + assert response.status_code == 204 + _assert_pipeline_owner(console_workflow_variables.delete_all.call_args.args, context) + + def test_list_node_variables_uses_console_variable_service( + self, app: Flask, console_workflow_variables: MagicMock + ) -> None: + console_workflow_variables.node.return_value = {"items": []} + context = _draft_variable_context() + api = RagPipelineNodeVariableCollectionApi() + + with app.test_request_context("/"): + result = unwrap(api.get)(api, context, _DRAFT_PIPELINE_ID, "node-1") + + assert result == {"items": []} + _assert_pipeline_owner(console_workflow_variables.node.call_args.args, context) + assert console_workflow_variables.node.call_args.args[2] == "node-1" + + def test_delete_node_variables_uses_console_variable_service( + self, app: Flask, console_workflow_variables: MagicMock + ) -> None: + context = _draft_variable_context() + api = RagPipelineNodeVariableCollectionApi() + + with app.test_request_context("/"): + response = unwrap(api.delete)(api, context, _DRAFT_PIPELINE_ID, "node-1") + + assert response.status_code == 204 + _assert_pipeline_owner(console_workflow_variables.delete_node.call_args.args, context) + assert console_workflow_variables.delete_node.call_args.args[2] == "node-1" + + def test_get_variable_uses_console_variable_service( + self, app: Flask, console_workflow_variables: MagicMock + ) -> None: + console_workflow_variables.get.return_value = {"id": "variable-1"} + context = _draft_variable_context() + api = RagPipelineVariableApi() + variable_id = UUID("22222222-2222-2222-2222-222222222222") + + with app.test_request_context("/"): + result = unwrap(api.get)(api, context, _DRAFT_PIPELINE_ID, variable_id) + + assert result == {"id": "variable-1"} + _assert_pipeline_owner(console_workflow_variables.get.call_args.args, context) + assert console_workflow_variables.get.call_args.args[2] == str(variable_id) + + def test_patch_variable_uses_console_variable_service( + self, app: Flask, console_workflow_variables: MagicMock + ) -> None: + console_workflow_variables.patch.return_value = {"id": "variable-1", "name": "renamed"} + context = _draft_variable_context() + api = RagPipelineVariableApi() + variable_id = UUID("22222222-2222-2222-2222-222222222222") + payload = WorkflowDraftVariablePatchPayload(name="renamed", value="new value") + + with app.test_request_context("/", method="PATCH"): + result = unwrap(api.patch)(api, payload, context, _DRAFT_PIPELINE_ID, variable_id) + + assert result == {"id": "variable-1", "name": "renamed"} + _assert_pipeline_owner(console_workflow_variables.patch.call_args.args, context) + assert console_workflow_variables.patch.call_args.args[2] == str(variable_id) + assert console_workflow_variables.patch.call_args.kwargs == {"name": "renamed", "value": "new value"} + + def test_delete_variable_uses_console_variable_service( + self, app: Flask, console_workflow_variables: MagicMock + ) -> None: + context = _draft_variable_context() + api = RagPipelineVariableApi() + variable_id = UUID("22222222-2222-2222-2222-222222222222") + + with app.test_request_context("/"): + response = unwrap(api.delete)(api, context, _DRAFT_PIPELINE_ID, variable_id) + + assert response.status_code == 204 + _assert_pipeline_owner(console_workflow_variables.delete.call_args.args, context) + assert console_workflow_variables.delete.call_args.args[2] == str(variable_id) + + @pytest.mark.parametrize("returned", [{"id": "variable-1"}, None]) + def test_reset_variable_uses_console_variable_service( + self, + app: Flask, + console_workflow_variables: MagicMock, + returned: dict[str, str] | None, + ) -> None: + console_workflow_variables.reset.return_value = returned + context = _draft_variable_context() + api = RagPipelineVariableResetApi() + variable_id = UUID("22222222-2222-2222-2222-222222222222") + + with app.test_request_context("/"): + result = unwrap(api.put)(api, context, _DRAFT_PIPELINE_ID, variable_id) + + if returned is None: + assert result.status_code == 204 + else: + assert result == returned + _assert_pipeline_owner(console_workflow_variables.reset.call_args.args, context) + assert console_workflow_variables.reset.call_args.args[2] == str(variable_id) + + def test_list_system_variables_uses_console_variable_service( + self, app: Flask, console_workflow_variables: MagicMock + ) -> None: + console_workflow_variables.system.return_value = {"items": []} + context = _draft_variable_context() + api = RagPipelineSystemVariableCollectionApi() + + with app.test_request_context("/"): + result = unwrap(api.get)(api, context, _DRAFT_PIPELINE_ID) + + assert result == {"items": []} + _assert_pipeline_owner(console_workflow_variables.system.call_args.args, context) + + def test_list_environment_variables_uses_console_variable_service( + self, app: Flask, console_workflow_variables: MagicMock + ) -> None: + console_workflow_variables.environment.return_value = [{"id": "environment-1", "value_type": "integer"}] + context = _draft_variable_context() + api = RagPipelineEnvironmentVariableCollectionApi() + + with app.test_request_context("/"): + result = unwrap(api.get)(api, context, _DRAFT_PIPELINE_ID) + + assert result == {"items": [{"id": "environment-1", "value_type": "integer"}]} + _assert_pipeline_owner(console_workflow_variables.environment.call_args.args, context) + def test_post_rejects_legacy_dataset_acl_before_publish(self) -> None: api = PublishCustomizedPipelineTemplateApi() method = unwrap(api.post) 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 e9011cde1e4a0e..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.helpers.unit.controllers.rbac_introspection import rbac_checks -from tests.helpers.unit.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, - } - ] - } diff --git a/api/tests/unit_tests/fields/test_workflow_draft_variable_fields.py b/api/tests/unit_tests/fields/test_workflow_draft_variable_fields.py index c941310b6263e1..7c12c5b78264ab 100644 --- a/api/tests/unit_tests/fields/test_workflow_draft_variable_fields.py +++ b/api/tests/unit_tests/fields/test_workflow_draft_variable_fields.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 from tests.unit_tests.controllers.conftest import app_query_services as app_query_services # noqa: PLC0414 from tests.unit_tests.controllers.conftest import ( datasource_application_dependencies as datasource_application_dependencies, # noqa: PLC0414 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/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index f11cbfde52cde4..a22c12cfa7f571 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -3884,7 +3884,6 @@ export const get67 = oc }) .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftConversationVariablesPath })) .output(zGetAppsByAppIdWorkflowsDraftConversationVariablesResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) /** * Update conversation variables for workflow draft @@ -3905,7 +3904,6 @@ export const post46 = oc }), ) .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const conversationVariables2 = { get: get67, @@ -3913,7 +3911,7 @@ export const conversationVariables2 = { } /** - * Get draft workflow + * Get environment variables * * Get environment variables for workflow */ @@ -3924,15 +3922,12 @@ export const get68 = 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 })) .output(zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) - .errors({ - app_not_found: { status: 404, data: zAppNotFoundError }, - draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - }) + .errors({ draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError } }) /** * Update environment variables for workflow draft @@ -3953,7 +3948,6 @@ export const post47 = oc }), ) .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const environmentVariables = { get: get68, @@ -4460,7 +4454,6 @@ export const delete11 = oc }) .input(z.object({ params: zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath })) .output(zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) /** * Get variables for a specific node @@ -4476,7 +4469,6 @@ export const get72 = oc }) .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath })) .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const variables = { delete: delete11, @@ -4654,7 +4646,6 @@ export const get77 = oc }) .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftSystemVariablesPath })) .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const systemVariables = { get: get77, @@ -4741,7 +4732,6 @@ export const put6 = oc }) .input(z.object({ params: zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetPath })) .output(zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const reset = { put: put6, @@ -4762,7 +4752,6 @@ export const delete12 = oc }) .input(z.object({ params: zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdPath })) .output(zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) /** * Get a specific workflow variable @@ -4778,7 +4767,6 @@ export const get78 = oc }) .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftVariablesByVariableIdPath })) .output(zGetAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) /** * Update a workflow variable @@ -4799,7 +4787,6 @@ export const patch2 = oc }), ) .output(zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const byVariableId = { delete: delete12, @@ -4823,10 +4810,9 @@ export const delete13 = oc }) .input(z.object({ params: zDeleteAppsByAppIdWorkflowsDraftVariablesPath })) .output(zDeleteAppsByAppIdWorkflowsDraftVariablesResponse) - .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) /** - * Get draft workflow + * List draft workflow variables without loading their values * * Get draft workflow variables */ @@ -4837,7 +4823,7 @@ export const get79 = 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( @@ -4847,10 +4833,7 @@ export const get79 = oc }), ) .output(zGetAppsByAppIdWorkflowsDraftVariablesResponse) - .errors({ - app_not_found: { status: 404, data: zAppNotFoundError }, - draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - }) + .errors({ draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError } }) export const variables2 = { delete: delete13, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index c1fcd054d5878e..f038324f68e7e0 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -8501,7 +8501,7 @@ export type GetAppsByAppIdWorkflowsDraftConversationVariablesData = { } export type GetAppsByAppIdWorkflowsDraftConversationVariablesErrors = { - 404: AppNotFoundError | ErrorResponse + 404: ErrorResponse default: ErrorResponse } @@ -8525,7 +8525,6 @@ export type PostAppsByAppIdWorkflowsDraftConversationVariablesData = { } export type PostAppsByAppIdWorkflowsDraftConversationVariablesErrors = { - 404: AppNotFoundError | ErrorResponse default: ErrorResponse } @@ -8549,7 +8548,7 @@ export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesData = { } export type GetAppsByAppIdWorkflowsDraftEnvironmentVariablesErrors = { - 404: AppNotFoundError | DraftWorkflowNotExistError | ErrorResponse + 404: DraftWorkflowNotExistError | ErrorResponse default: ErrorResponse } @@ -8573,7 +8572,6 @@ export type PostAppsByAppIdWorkflowsDraftEnvironmentVariablesData = { } export type PostAppsByAppIdWorkflowsDraftEnvironmentVariablesErrors = { - 404: AppNotFoundError | ErrorResponse default: ErrorResponse } @@ -9050,7 +9048,6 @@ export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesData = { } export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesErrors = { - 404: AppNotFoundError | ErrorResponse default: ErrorResponse } @@ -9075,7 +9072,6 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesData = { } export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesErrors = { - 404: AppNotFoundError | ErrorResponse default: ErrorResponse } @@ -9234,7 +9230,6 @@ export type GetAppsByAppIdWorkflowsDraftSystemVariablesData = { } export type GetAppsByAppIdWorkflowsDraftSystemVariablesErrors = { - 404: AppNotFoundError | ErrorResponse default: ErrorResponse } @@ -9314,7 +9309,6 @@ export type DeleteAppsByAppIdWorkflowsDraftVariablesData = { } export type DeleteAppsByAppIdWorkflowsDraftVariablesErrors = { - 404: AppNotFoundError | ErrorResponse default: ErrorResponse } @@ -9341,7 +9335,7 @@ export type GetAppsByAppIdWorkflowsDraftVariablesData = { } export type GetAppsByAppIdWorkflowsDraftVariablesErrors = { - 404: AppNotFoundError | DraftWorkflowNotExistError | ErrorResponse + 404: DraftWorkflowNotExistError | ErrorResponse default: ErrorResponse } @@ -9366,7 +9360,7 @@ export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdData = { } export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: AppNotFoundError | ErrorResponse + 404: ErrorResponse default: ErrorResponse } @@ -9391,7 +9385,7 @@ export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdData = { } export type GetAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: AppNotFoundError | ErrorResponse + 404: ErrorResponse default: ErrorResponse } @@ -9416,7 +9410,7 @@ export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdData = { } export type PatchAppsByAppIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: AppNotFoundError | ErrorResponse + 404: ErrorResponse default: ErrorResponse } @@ -9441,7 +9435,7 @@ export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetData = { } export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetErrors = { - 404: AppNotFoundError | ErrorResponse + 404: ErrorResponse default: ErrorResponse } 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' 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 = { diff --git a/packages/contracts/generated/api/console/rag/orpc.gen.ts b/packages/contracts/generated/api/console/rag/orpc.gen.ts index 7c1620e0549447..b266709612c4db 100644 --- a/packages/contracts/generated/api/console/rag/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rag/orpc.gen.ts @@ -631,7 +631,7 @@ export const datasource = { } /** - * Get draft workflow + * Get environment variables */ export const get12 = oc .route({ @@ -639,15 +639,12 @@ 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 })) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesResponse) - .errors({ - draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - pipeline_not_found: { status: 404, data: zPipelineNotFoundError }, - }) + .errors({ draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError } }) export const environmentVariables = { get: get12, @@ -781,7 +778,6 @@ export const delete2 = oc z.object({ params: zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPath }), ) .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse) - .errors({ pipeline_not_found: { status: 404, data: zPipelineNotFoundError } }) export const get14 = oc .route({ @@ -793,7 +789,6 @@ export const get14 = oc }) .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPath })) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse) - .errors({ pipeline_not_found: { status: 404, data: zPipelineNotFoundError } }) export const variables = { delete: delete2, @@ -906,7 +901,6 @@ export const get17 = oc }) .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesPath })) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponse) - .errors({ pipeline_not_found: { status: 404, data: zPipelineNotFoundError } }) export const systemVariables = { get: get17, @@ -924,7 +918,6 @@ export const put = oc z.object({ params: zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetPath }), ) .output(zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponse) - .errors({ pipeline_not_found: { status: 404, data: zPipelineNotFoundError } }) export const reset = { put, @@ -943,7 +936,6 @@ export const delete3 = oc z.object({ params: zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath }), ) .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse) - .errors({ pipeline_not_found: { status: 404, data: zPipelineNotFoundError } }) export const get18 = oc .route({ @@ -955,7 +947,6 @@ export const get18 = oc }) .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath })) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse) - .errors({ pipeline_not_found: { status: 404, data: zPipelineNotFoundError } }) export const patch2 = oc .route({ @@ -972,7 +963,6 @@ export const patch2 = oc }), ) .output(zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse) - .errors({ pipeline_not_found: { status: 404, data: zPipelineNotFoundError } }) export const byVariableId = { delete: delete3, @@ -992,10 +982,9 @@ export const delete4 = oc }) .input(z.object({ params: zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesPath })) .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse) - .errors({ pipeline_not_found: { status: 404, data: zPipelineNotFoundError } }) /** - * Get draft workflow + * List draft pipeline variables without loading their values */ export const get19 = oc .route({ @@ -1003,7 +992,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( @@ -1013,10 +1002,7 @@ export const get19 = oc }), ) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse) - .errors({ - draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - pipeline_not_found: { status: 404, data: zPipelineNotFoundError }, - }) + .errors({ draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError } }) export const variables2 = { delete: delete4, diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 49341522373483..11a7d9a559d684 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -1623,7 +1623,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesData = } export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesErrors = { - 404: DraftWorkflowNotExistError | PipelineNotFoundError | ErrorResponse + 404: DraftWorkflowNotExistError | ErrorResponse default: ErrorResponse } @@ -1748,7 +1748,6 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesDa } export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesErrors = { - 404: PipelineNotFoundError | ErrorResponse default: ErrorResponse } @@ -1773,7 +1772,6 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesData } export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesErrors = { - 404: PipelineNotFoundError | ErrorResponse default: ErrorResponse } @@ -1874,7 +1872,6 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesData = { } export type GetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesErrors = { - 404: PipelineNotFoundError | ErrorResponse default: ErrorResponse } @@ -1898,7 +1895,6 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesData = { } export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesErrors = { - 404: PipelineNotFoundError | ErrorResponse default: ErrorResponse } @@ -1925,7 +1921,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesData = { } export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesErrors = { - 404: DraftWorkflowNotExistError | PipelineNotFoundError | ErrorResponse + 404: DraftWorkflowNotExistError | ErrorResponse default: ErrorResponse } @@ -1950,7 +1946,6 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdDat } export type DeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: PipelineNotFoundError | ErrorResponse default: ErrorResponse } @@ -1975,7 +1970,6 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdData = } export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: PipelineNotFoundError | ErrorResponse default: ErrorResponse } @@ -2000,7 +1994,6 @@ export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdData } export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdErrors = { - 404: PipelineNotFoundError | ErrorResponse default: ErrorResponse } @@ -2025,7 +2018,6 @@ export type PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetD } export type PutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetErrors = { - 404: PipelineNotFoundError | ErrorResponse default: ErrorResponse }