Files
dify/api/services/workflow_run_service.py
T

310 lines
11 KiB
Python

import threading
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, TypedDict
from sqlalchemy.orm import Session, sessionmaker
import contexts
from core.workflow.node_execution_process_data import (
WORKFLOW_TOOL_INVOCATION_ID_KEY,
WORKFLOW_TOOL_ROOT_APP_ID_KEY,
)
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,
)
from services.workflow_pause_service import load_workflow_pause_snapshot
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_version: Literal["1", "2"]
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,
session_factory: sessionmaker[Session],
) -> None:
self._workflow_runs = workflow_runs
self._node_executions = node_executions
self._session_factory = session_factory
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
Returns an empty list when the run is not visible to the admitted workspace.
Creator accounts and end users are resolved in a short read-only session that
closes before returning, so the traces carry detached, already-loaded rows.
"""
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,
)
with self._session_factory() as session:
return assemble_workflow_node_execution_traces(node_executions, self._node_executions, session=session)
def get_workflow_tool_node_executions(
self,
context: RequestContext,
*,
app_id: str,
run_id: str,
node_execution_id: str,
) -> list[WorkflowNodeExecutionTrace] | None:
"""Read nested Tool traces through the admitted root app and workflow run.
Returns None when the root run is not visible to the admitted workspace.
Creator lookups use a short read-only session that closes before the internal
correlation keys are stripped from ``process_data``.
"""
if self.get_workflow_run(context, app_id=app_id, run_id=run_id) is None:
return None
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())
executions = self._node_executions.get_workflow_tool_executions(
tenant_id=context.active_workspace_id,
workflow_run_id=run_id,
parent_node_execution_id=node_execution_id,
)
with self._session_factory() as session:
traces = assemble_workflow_node_execution_traces(executions, self._node_executions, session=session)
for trace in traces:
if trace.process_data is not None:
trace.process_data = {
key: value
for key, value in trace.process_data.items()
if key
not in (
WORKFLOW_TOOL_INVOCATION_ID_KEY,
WORKFLOW_TOOL_ROOT_APP_ID_KEY,
)
}
return traces
def get_pause_details(
self,
context: RequestContext,
*,
workflow_run_id: str,
) -> WorkflowRunPauseDetails | None:
with self._session_factory() as session:
pause_record = self._workflow_runs.get_pause_record(
session,
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 or pause_record.pause is None:
return WorkflowRunPauseDetails(paused_at=None, paused_nodes=())
snapshot = load_workflow_pause_snapshot(pause_record.pause, session_factory=self._session_factory)
human_input_reasons: list[HumanInputRequired] = []
for reason in snapshot.reasons:
if not isinstance(reason, HumanInputRequired):
raise NotImplementedError(f"Pause details do not support {type(reason).__name__}")
human_input_reasons.append(reason)
legacy_form_ids = [reason.form_id for reason in human_input_reasons if reason.form_version == "1"]
form_tokens: dict[str, str] = {}
if legacy_form_ids:
with self._session_factory() as session:
form_tokens = self._workflow_runs.get_legacy_form_tokens(session, form_ids=legacy_form_ids)
form_tokens.update(
{
form_id: prepared.form_token.get_secret_value()
for form_id, prepared in snapshot.v2_forms.items()
if prepared.form_token is not None
}
)
return WorkflowRunPauseDetails(
paused_at=pause_record.pause.paused_at,
paused_nodes=tuple(
WorkflowRunPausedNode(
node_id=reason.node_id,
node_title=reason.node_title,
form_id=reason.form_id,
form_version=reason.form_version,
form_token=form_tokens.get(reason.form_id),
)
for reason in human_input_reasons
),
)