Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/workflow_manager/workflow_v2/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class WorkflowDoesNotExistError(APIException):
default_detail = "Workflow does not exist"


class WorkflowDeletionError(APIException):
status_code = 400
default_detail = "Workflow cannot be deleted as it is currently in use."


class ExecutionDoesNotExistError(APIException):
status_code = 404
default_detail = "Execution does not exist."
Expand Down
18 changes: 18 additions & 0 deletions backend/workflow_manager/workflow_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from workflow_manager.workflow_v2.enums import SchemaEntity, SchemaType
from workflow_manager.workflow_v2.exceptions import (
InternalException,
WorkflowDeletionError,
WorkflowDoesNotExistError,
WorkflowGenerationError,
WorkflowRegenerationError,
Expand Down Expand Up @@ -136,6 +137,23 @@ def perform_create(self, serializer: WorkflowSerializer) -> Workflow:
raise WorkflowGenerationError
return workflow

def perform_destroy(self, instance: Workflow) -> None:
"""Block deletion when the workflow is in use by any pipeline/API.

The frontend gates the delete button via `can_update`, but direct API
callers can still hit DELETE. Without this guard, the CASCADE FK on
Pipeline/APIDeployment would silently drop their rows along with
the workflow.
"""
usage = WorkflowHelper.can_update_workflow(str(instance.id))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant DB fetch inside perform_destroy

can_update_workflow opens with Workflow.objects.get(pk=workflow_id) to reload the object, but instance passed into perform_destroy is already the fully-loaded Workflow row (fetched by get_object() earlier in the DRF lifecycle). This adds an extra round-trip. If can_update_workflow is also called from the existing can_update endpoint with only an ID string, consider adding an overload or internal helper that accepts either a Workflow instance or an ID so perform_destroy can skip the redundant lookup.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/workflow_manager/workflow_v2/views.py
Line: 148

Comment:
**Redundant DB fetch inside `perform_destroy`**

`can_update_workflow` opens with `Workflow.objects.get(pk=workflow_id)` to reload the object, but `instance` passed into `perform_destroy` is already the fully-loaded `Workflow` row (fetched by `get_object()` earlier in the DRF lifecycle). This adds an extra round-trip. If `can_update_workflow` is also called from the existing `can_update` endpoint with only an ID string, consider adding an overload or internal helper that accepts either a `Workflow` instance or an ID so `perform_destroy` can skip the redundant lookup.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

if not usage.get("can_update", False):
raise WorkflowDeletionError(
detail=WorkflowHelper.build_workflow_in_use_message(
instance.workflow_name, usage
)
)
super().perform_destroy(instance)

def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
"""Override partial_update to handle sharing notifications."""
# Get the workflow instance before update
Expand Down
40 changes: 40 additions & 0 deletions backend/workflow_manager/workflow_v2/workflow_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,46 @@ def make_async_result(obj: AsyncResult) -> dict[str, Any]:
}

USAGE_DISPLAY_LIMIT = 5
USAGE_MESSAGE_DISPLAY_LIMIT = 3

@staticmethod
def build_workflow_in_use_message(workflow_name: str, usage: dict[str, Any]) -> str:
"""Builds a user-facing message listing pipelines/APIs blocking deletion.

Matches the format used by the frontend so direct API callers see the
same details about which pipelines/API deployments are using the WF.
"""
pipelines = usage.get("pipelines") or []
api_names = usage.get("api_names") or []
pipeline_count = usage.get("pipeline_count", 0)
api_count = usage.get("api_count", 0)

if (pipeline_count + api_count) == 0:
return f"Cannot delete `{workflow_name}` as it is currently in use."
Comment on lines +1008 to +1009
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unreachable fallback branch

build_workflow_in_use_message is only called from perform_destroy after can_update_workflow has already returned can_update=False, which only occurs when pipeline_count + api_count > 0. The zero-count branch (line 1008–1009) therefore cannot be reached via the current call site and silently returns a generic message if it ever were, which could mask the real cause. Consider either removing this branch (trusting the caller contract) or converting it to an AssertionError/log to make the invariant explicit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/workflow_manager/workflow_v2/workflow_helper.py
Line: 1008-1009

Comment:
**Unreachable fallback branch**

`build_workflow_in_use_message` is only called from `perform_destroy` after `can_update_workflow` has already returned `can_update=False`, which only occurs when `pipeline_count + api_count > 0`. The zero-count branch (line 1008–1009) therefore cannot be reached via the current call site and silently returns a generic message if it ever were, which could mask the real cause. Consider either removing this branch (trusting the caller contract) or converting it to an `AssertionError`/log to make the invariant explicit.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code


limit = WorkflowHelper.USAGE_MESSAGE_DISPLAY_LIMIT
lines: list[str] = []

if api_names:
shown = list(api_names)[:limit]
for name in shown:
lines.append(f"- `{name}` (API Deployment)")
remaining = api_count - len(shown)
if remaining > 0:
lines.append(f"- ...and {remaining} more API deployment(s)")

if pipelines:
shown = list(pipelines)[:limit]
for p in shown:
name = p.get("pipeline_name")
p_type = p.get("pipeline_type")
lines.append(f"- `{name}` ({p_type} Pipeline)")
Comment on lines +1025 to +1027
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Raw enum key used as pipeline type label

pipeline_type is stored as the PipelineType TextChoices key ("ETL", "TASK", "DEFAULT", "APP"), so the message would read DEFAULT Pipeline or APP Pipeline for those types — labels that may be unclear to API callers. The .values() queryset in can_update_workflow returns the raw DB value rather than the human-readable label ("Default", "App"). Consider mapping the raw key to the display label (e.g. via PipelineType(p_type).label where PipelineType is imported from pipeline_v2.models) before constructing the line.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/workflow_manager/workflow_v2/workflow_helper.py
Line: 1025-1027

Comment:
**Raw enum key used as pipeline type label**

`pipeline_type` is stored as the `PipelineType` TextChoices key (`"ETL"`, `"TASK"`, `"DEFAULT"`, `"APP"`), so the message would read `DEFAULT Pipeline` or `APP Pipeline` for those types — labels that may be unclear to API callers. The `.values()` queryset in `can_update_workflow` returns the raw DB value rather than the human-readable label (`"Default"`, `"App"`). Consider mapping the raw key to the display label (e.g. via `PipelineType(p_type).label` where `PipelineType` is imported from `pipeline_v2.models`) before constructing the line.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

remaining = pipeline_count - len(shown)
if remaining > 0:
lines.append(f"- ...and {remaining} more pipeline(s)")

details = "\n".join(lines)
return f"Cannot delete `{workflow_name}` as it is used in:\n{details}"

@staticmethod
def can_update_workflow(workflow_id: str) -> dict[str, Any]:
Expand Down