dify/api/services/workflow_run_service.py

240 lines
8.0 KiB
Python

import threading
from dataclasses import dataclass
from datetime import datetime
from typing import TypedDict
import contexts
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from machinery.context import RequestContext
from models import (
WorkflowRun,
WorkflowRunTriggeredFrom,
)
from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowNodeExecutionRepository
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
from services.workflow_node_execution_trace_service import (
WorkflowNodeExecutionTrace,
assemble_workflow_node_execution_traces,
)
class WorkflowRunListArgs(TypedDict, total=False):
"""Expected shape of the args dict passed to workflow run pagination methods."""
limit: int
last_id: str
status: str
@dataclass(frozen=True, slots=True)
class WorkflowRunPausedNode:
node_id: str
node_title: str
form_id: str
form_token: str | None
@dataclass(frozen=True, slots=True)
class WorkflowRunPauseDetails:
paused_at: datetime | None
paused_nodes: tuple[WorkflowRunPausedNode, ...]
class WorkflowRunService:
def __init__(
self,
*,
workflow_runs: DifyAPISQLAlchemyWorkflowRunRepository,
node_executions: DifyAPIWorkflowNodeExecutionRepository,
) -> None:
self._workflow_runs = workflow_runs
self._node_executions = node_executions
def get_paginate_advanced_chat_workflow_runs(
self,
context: RequestContext,
*,
app_id: str,
args: WorkflowRunListArgs,
triggered_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.DEBUGGING,
) -> InfiniteScrollPagination:
"""
Get advanced chat app workflow run list
:param context: admitted Console request context
:param app_id: app id
:param args: request args
:param triggered_from: workflow run triggered from (default: DEBUGGING for preview runs)
"""
class WorkflowWithMessage:
message_id: str
conversation_id: str
def __init__(self, workflow_run: WorkflowRun):
self._workflow_run = workflow_run
def __getattr__(self, item):
return getattr(self._workflow_run, item)
pagination = self.get_paginate_workflow_runs(
context,
app_id=app_id,
args=args,
triggered_from=triggered_from,
)
# Batch-load the associated Message for every run in a single query to avoid
# an N+1 pattern: the deprecated WorkflowRun.message property issues one query
# per run. The filter matches that property exactly (app_id + workflow_run_id).
workflow_runs = pagination.data
run_ids = [workflow_run.id for workflow_run in workflow_runs]
messages_by_run_id = self._workflow_runs.get_message_refs(
app_id=app_id,
workflow_run_ids=run_ids,
)
with_message_workflow_runs = []
for workflow_run in workflow_runs:
message = messages_by_run_id.get(workflow_run.id)
with_message_workflow_run = WorkflowWithMessage(workflow_run=workflow_run)
if message:
with_message_workflow_run.message_id = message.message_id
with_message_workflow_run.conversation_id = message.conversation_id
with_message_workflow_runs.append(with_message_workflow_run)
pagination.data = with_message_workflow_runs
return pagination
def get_paginate_workflow_runs(
self,
context: RequestContext,
*,
app_id: str,
args: WorkflowRunListArgs,
triggered_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.DEBUGGING,
) -> InfiniteScrollPagination:
"""
Get workflow run list
:param context: admitted Console request context
:param app_id: app id
:param args: request args
:param triggered_from: workflow run triggered from (default: DEBUGGING)
"""
limit = int(args.get("limit", 20))
last_id = args.get("last_id")
status = args.get("status")
return self._workflow_runs.get_paginated_workflow_runs(
tenant_id=context.active_workspace_id,
app_id=app_id,
triggered_from=triggered_from,
limit=limit,
last_id=last_id,
status=status,
)
def get_workflow_run(self, context: RequestContext, *, app_id: str, run_id: str) -> WorkflowRun | None:
"""
Get workflow run detail
:param context: admitted Console request context
:param app_id: app id
:param run_id: workflow run id
"""
return self._workflow_runs.get_workflow_run_by_id(
tenant_id=context.active_workspace_id,
app_id=app_id,
run_id=run_id,
)
def get_workflow_runs_count(
self,
context: RequestContext,
*,
app_id: str,
status: str | None = None,
time_range: str | None = None,
triggered_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.DEBUGGING,
) -> dict[str, int]:
"""
Get workflow runs count statistics
:param context: admitted Console request context
:param app_id: app id
:param status: optional status filter
:param time_range: optional time range filter (e.g., "7d", "4h", "30m", "30s")
:param triggered_from: workflow run triggered from (default: DEBUGGING)
:return: dict with total and status counts
"""
return self._workflow_runs.get_workflow_runs_count(
tenant_id=context.active_workspace_id,
app_id=app_id,
triggered_from=triggered_from,
status=status,
time_range=time_range,
)
def get_workflow_run_node_executions(
self,
context: RequestContext,
*,
app_id: str,
run_id: str,
) -> list[WorkflowNodeExecutionTrace]:
"""
Get workflow run node execution list
"""
workflow_run = self.get_workflow_run(context, app_id=app_id, run_id=run_id)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())
if not workflow_run:
return []
node_executions = self._node_executions.get_executions_by_workflow_run(
tenant_id=context.active_workspace_id,
app_id=app_id,
workflow_run_id=run_id,
)
return assemble_workflow_node_execution_traces(node_executions, self._node_executions)
def get_pause_details(
self,
context: RequestContext,
*,
workflow_run_id: str,
) -> WorkflowRunPauseDetails | None:
pause_record = self._workflow_runs.get_pause_record(
workspace_id=context.active_workspace_id,
workflow_run_id=workflow_run_id,
)
if pause_record is None:
return None
if pause_record.status != WorkflowExecutionStatus.PAUSED:
return WorkflowRunPauseDetails(paused_at=None, paused_nodes=())
human_input_reasons: list[HumanInputRequired] = []
for reason in pause_record.reasons:
if not isinstance(reason, HumanInputRequired):
raise NotImplementedError(f"Pause details do not support {type(reason).__name__}")
human_input_reasons.append(reason)
return WorkflowRunPauseDetails(
paused_at=pause_record.paused_at,
paused_nodes=tuple(
WorkflowRunPausedNode(
node_id=reason.node_id,
node_title=reason.node_title,
form_id=reason.form_id,
form_token=pause_record.form_tokens.get(reason.form_id),
)
for reason in human_input_reasons
),
)