diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 5ec416e465c..2f49d54353b 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -99,6 +99,7 @@ from services.app.access import resolve_app_access_filter from services.app_generate_service import AppGenerateService from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError +from services.errors.workflow_service import EmptyWorkflowConfirmationRequiredError from services.workflow_ref_service import WorkflowRefService from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService from services.workflow_variable_reference_validator import ( @@ -639,7 +640,8 @@ class DraftWorkflowApi(Resource): @rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) def post(self, current_user: Account, app_model: App): """ - Sync draft workflow + Sync draft workflow. Empty initialization or clearing requires force=true; + an already-empty draft may be saved with its current hash without force. """ content_type = request.headers.get("Content-Type", "") @@ -656,11 +658,6 @@ class DraftWorkflowApi(Resource): else: abort(415) - # Empty autosaves can otherwise replace a populated draft, even with a matching hash. - # Require explicit confirmation for both regular saves and text/plain unload beacons. - if args_model.graph.get("nodes") == [] and args_model.graph.get("edges") == [] and not args_model.force: - raise InvalidArgumentError(description="Saving an empty workflow requires force=true.") - workflow_service = WorkflowService() try: @@ -693,10 +690,11 @@ class DraftWorkflowApi(Resource): deleted_environment_variable_ids=deleted_environment_variable_ids, preserve_environment_variables=True, graph_only=args_model.is_collaborative, + require_empty_graph_confirmation=not args_model.force, ) except WorkflowHashNotEqualError: raise DraftWorkflowNotSync() - except VariableError as e: + except (VariableError, EmptyWorkflowConfirmationRequiredError) as e: raise InvalidArgumentError(description=str(e)) return dump_response( diff --git a/api/services/errors/workflow_service.py b/api/services/errors/workflow_service.py index ea9b4875688..04bc953dd6e 100644 --- a/api/services/errors/workflow_service.py +++ b/api/services/errors/workflow_service.py @@ -8,3 +8,7 @@ class DraftWorkflowDeletionError(ValueError): """Raised when attempting to delete a draft workflow""" pass + + +class EmptyWorkflowConfirmationRequiredError(ValueError): + """Raised when an unconfirmed empty save would initialize or clear a draft.""" diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 76f552171dc..8ae33a591da 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -152,7 +152,11 @@ from services.workflow.workflow_converter import WorkflowConverter from services.workflow_ref_service import WorkflowRef from services.workflow_version_number_service import allocate_version_number -from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError +from .errors.workflow_service import ( + DraftWorkflowDeletionError, + EmptyWorkflowConfirmationRequiredError, + WorkflowInUseError, +) from .human_input_delivery_test_service import ( DeliveryTestContext, DeliveryTestEmailRecipient, @@ -424,6 +428,7 @@ class WorkflowService: commit: bool = True, sync_agent_bindings: bool = True, graph_only: bool = False, + require_empty_graph_confirmation: bool = False, ) -> Workflow: """ Sync draft workflow. @@ -436,7 +441,13 @@ class WorkflowService: graph-only saves preserve independently persisted draft fields, while an explicit per-ID environment patch is merged with the graph. + Public unconfirmed saves require an empty graph to match an already-empty + draft. The False default preserves trusted DSL import initialization and + Builder repair/rollback clears; public force=true uses the same overload. + Neither overload bypasses the existing draft hash check. + :raises WorkflowHashNotEqualError + :raises EmptyWorkflowConfirmationRequiredError """ if environment_variable_upserts is None and deleted_environment_variable_ids: raise ValueError("Deleted environment variable ids require an environment variable patch.") @@ -447,6 +458,11 @@ class WorkflowService: if workflow and workflow.unique_hash != unique_hash: raise WorkflowHashNotEqualError() + if require_empty_graph_confirmation and graph.get("nodes") == [] and graph.get("edges") == []: + previous_graph = workflow.graph_dict if workflow is not None else None + if previous_graph is None or previous_graph.get("nodes") != [] or previous_graph.get("edges") != []: + raise EmptyWorkflowConfirmationRequiredError("Saving an empty workflow requires force=true.") + # Collaboration persists features and variables through dedicated endpoints. A graph save # must not overwrite those newer database values with another collaborator's stale cache. if not graph_only or not workflow: