mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
1485 lines
64 KiB
Python
1485 lines
64 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import replace
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from typing import Any, override
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from dify_trace_sdk.emitter import TraceScope
|
|
from dify_trace_sdk.events import TraceCorrelation, TraceEvent, WorkflowRunCompleted
|
|
from flask import Flask
|
|
from sqlalchemy import event as sqlalchemy_event
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity
|
|
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
|
|
from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
|
|
from core.tools.workflow_as_tool.repository import WorkflowToolSource
|
|
from core.workflow.node_execution_process_data import (
|
|
WORKFLOW_TOOL_INVOCATION_ID_KEY,
|
|
WORKFLOW_TOOL_ROOT_APP_ID_KEY,
|
|
)
|
|
from core.workflow.system_variables import SystemVariableKey, build_system_variables
|
|
from domain.workflow.retry_history import RETRY_HISTORY_PROCESS_DATA_KEY
|
|
from enums import WorkflowKind
|
|
from extensions.tracing import bind_tracing
|
|
from graphon.engine.event.node_failure import NodeFailureHandler
|
|
from graphon.engine.event.processor import NodeEventProcessor
|
|
from graphon.engine.event.stream import EventStream
|
|
from graphon.engine.frame import ExecutionFrame, FrameRegistry
|
|
from graphon.engine.scheduler import Scheduler
|
|
from graphon.engine.worker import NodeEventTask
|
|
from graphon.engine_events import (
|
|
EngineEvent,
|
|
GraphRunAbortedEvent,
|
|
GraphRunFailedEvent,
|
|
GraphRunPartialSucceededEvent,
|
|
GraphRunPausedEvent,
|
|
GraphRunStartedEvent,
|
|
GraphRunSucceededEvent,
|
|
NodeEvent,
|
|
NodeRunExceptionEvent,
|
|
NodeRunFailedEvent,
|
|
NodeRunPauseRequestedEvent,
|
|
NodeRunRetryEvent,
|
|
NodeRunStartedEvent,
|
|
NodeRunSucceededEvent,
|
|
)
|
|
from graphon.entities import WorkflowNodeExecution, WorkflowStartReason
|
|
from graphon.entities.pause_reason import SchedulingPause
|
|
from graphon.enums import (
|
|
BuiltinNodeTypes,
|
|
WorkflowExecutionStatus,
|
|
WorkflowNodeExecutionMetadataKey,
|
|
WorkflowNodeExecutionStatus,
|
|
WorkflowType,
|
|
)
|
|
from graphon.graph import Graph
|
|
from graphon.model_runtime.entities.llm_entities import LLMUsage
|
|
from graphon.node_events import NodeRunResult
|
|
from graphon.nodes.start.entities import StartNodeData
|
|
from graphon.nodes.start.start_node import StartNode
|
|
from graphon.runtime import ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool
|
|
from graphon.runtime.execution import ROOT_FRAME_ID
|
|
from graphon.variables.variables import Variable
|
|
from models import Account, WorkflowRun
|
|
from models.enums import WorkflowRunTriggeredFrom
|
|
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
|
|
from repositories.entities.workflow_execution import WorkflowExecution
|
|
from repositories.sqlalchemy_api_workflow_node_execution_repository import (
|
|
DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
|
|
)
|
|
from services import workflow_persistence as persistence_mod
|
|
from services.workflow_persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
|
|
from tests.helpers.unit.services.tracing_ports import NoTracingPolicies, RecordingTracingHooks, kernel_trace_scopes
|
|
from tests.workflow_test_utils import build_test_graph_init_params
|
|
|
|
"""Verify the workflow persistence layer fans Inspector deltas to redis pub/sub.
|
|
|
|
The hook lives in ``services/workflow_persistence.py``:
|
|
every ``_handle_node_*`` and the terminal ``_handle_graph_run_*`` handlers
|
|
call into ``services.workflow.inspector_events.publish_node_changed`` /
|
|
``publish_workflow_completed`` after the DB write succeeds. Those calls are
|
|
the only thing the Inspector SSE stream listens to, so any future refactor of
|
|
the persistence layer must keep them in place.
|
|
|
|
We don't reconstruct a full workflow engine here — the handlers are tested
|
|
in isolation by patching just the moving parts they touch
|
|
(``_workflow_execution`` + ``_node_execution_cache``) and asserting against
|
|
the publisher module's call sites. This keeps the test compact and tied to
|
|
the contract, not the implementation.
|
|
"""
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Graph-level publish hooks
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _graph_event(**kwargs: Any) -> MagicMock:
|
|
return MagicMock(**kwargs)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Node-level publish hooks
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _node_started_event(node_id: str = "agent-1", exec_id: str = "exec-1") -> NodeRunStartedEvent:
|
|
return NodeRunStartedEvent(
|
|
id=exec_id,
|
|
node_id=node_id,
|
|
node_type="agent",
|
|
node_title="Greeter",
|
|
predecessor_node_id=None,
|
|
container_id="",
|
|
node_version="1",
|
|
start_at=datetime(2026, 5, 26, 0, 0, 0),
|
|
)
|
|
|
|
|
|
def _seed_node_execution(layer: WorkflowPersistenceLayer, exec_id: str, node_id: str) -> None:
|
|
"""Inject a domain execution into the cache so the success / fail / etc
|
|
handlers (which look it up by id) can run without going through started."""
|
|
layer._node_execution_cache[exec_id] = MagicMock(
|
|
id=exec_id, node_id=node_id, status=MagicMock(value="running"), outputs={}, error=None
|
|
)
|
|
|
|
|
|
class TestPersistenceInspectorPublishCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def layer() -> WorkflowPersistenceLayer:
|
|
"""Use real layer initialization with repository and runtime test doubles."""
|
|
instance = WorkflowPersistenceLayer(
|
|
application_generate_entity=MagicMock(),
|
|
workflow_info=MagicMock(workflow_id="wf-1"),
|
|
workflow_execution_repository=MagicMock(),
|
|
workflow_node_execution_repository=MagicMock(),
|
|
trace_scope=None,
|
|
)
|
|
# Use a SimpleNamespace-like spec so Pydantic-validated callsites (e.g.
|
|
# ``WorkflowNodeExecution.new`` requires real strings) get the right types.
|
|
workflow_execution = MagicMock()
|
|
workflow_execution.id_ = "run-1"
|
|
workflow_execution.workflow_id = "wf-1"
|
|
workflow_execution.status = MagicMock(value="succeeded")
|
|
workflow_execution.outputs = {}
|
|
workflow_execution.error_message = None
|
|
workflow_execution.exceptions_count = 0
|
|
workflow_execution.finished_at = None
|
|
instance.set_node_run_indices({"exec-1": 1})
|
|
instance._workflow_execution = workflow_execution
|
|
# `runtime_state` is a layer-base property; stub it.
|
|
instance._runtime_state = MagicMock(total_tokens=0, node_run_steps=0, outputs={}, exceptions_count=0)
|
|
return instance
|
|
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def capture_publishes(monkeypatch: pytest.MonkeyPatch) -> dict[str, list]:
|
|
"""Replace the two publishers with capture lists so each test can assert
|
|
on the exact arguments."""
|
|
calls: dict[str, list] = {"node": [], "workflow": []}
|
|
|
|
def fake_node(*, workflow_run_id: str, node_id: str, status: str) -> None:
|
|
calls["node"].append({"workflow_run_id": workflow_run_id, "node_id": node_id, "status": status})
|
|
|
|
def fake_workflow(*, workflow_run_id: str, status: str) -> None:
|
|
calls["workflow"].append({"workflow_run_id": workflow_run_id, "status": status})
|
|
|
|
monkeypatch.setattr(persistence_mod, "_inspector_publish_node_changed", fake_node)
|
|
monkeypatch.setattr(persistence_mod, "_inspector_publish_workflow_completed", fake_workflow)
|
|
return calls
|
|
|
|
@staticmethod
|
|
def test_graph_run_succeeded_publishes_workflow_completed(layer, capture_publishes):
|
|
layer._workflow_execution.status = MagicMock(value="succeeded")
|
|
layer._handle_graph_run_succeeded(_graph_event(outputs={"text": "hi"}))
|
|
assert capture_publishes["workflow"] == [{"workflow_run_id": "run-1", "status": "succeeded"}]
|
|
assert capture_publishes["node"] == []
|
|
|
|
@staticmethod
|
|
def test_graph_run_partial_succeeded_publishes_workflow_completed(layer, capture_publishes):
|
|
layer._workflow_execution.status = MagicMock(value="partial-succeeded")
|
|
layer._handle_graph_run_partial_succeeded(_graph_event(outputs={}, exceptions_count=1))
|
|
assert capture_publishes["workflow"] == [{"workflow_run_id": "run-1", "status": "partial-succeeded"}]
|
|
|
|
@staticmethod
|
|
def test_graph_run_failed_publishes_workflow_completed(layer, capture_publishes):
|
|
layer._workflow_execution.status = MagicMock(value="failed")
|
|
layer._handle_graph_run_failed(_graph_event(error="boom", exceptions_count=0))
|
|
assert capture_publishes["workflow"] == [{"workflow_run_id": "run-1", "status": "failed"}]
|
|
|
|
@staticmethod
|
|
def test_graph_run_aborted_publishes_workflow_completed(layer, capture_publishes):
|
|
layer._workflow_execution.status = MagicMock(value="stopped")
|
|
layer._handle_graph_run_aborted(_graph_event(reason="user stop"))
|
|
assert capture_publishes["workflow"] == [{"workflow_run_id": "run-1", "status": "stopped"}]
|
|
|
|
@staticmethod
|
|
def test_graph_run_paused_does_not_publish_completion(layer, capture_publishes):
|
|
"""Pause is not a terminal state — the Inspector keeps waiting for either
|
|
resume or a real terminal event."""
|
|
layer.on_event(GraphRunPausedEvent(outputs={}))
|
|
assert capture_publishes["workflow"] == []
|
|
assert capture_publishes["node"] == []
|
|
|
|
@staticmethod
|
|
def test_node_started_publishes_running(layer, capture_publishes):
|
|
layer._handle_node_started(_node_started_event())
|
|
assert capture_publishes["node"] == [{"workflow_run_id": "run-1", "node_id": "agent-1", "status": "running"}]
|
|
|
|
@staticmethod
|
|
def test_node_retry_publishes_retry(layer, capture_publishes, monkeypatch: pytest.MonkeyPatch):
|
|
_seed_node_execution(layer, exec_id="exec-1", node_id="agent-1")
|
|
monkeypatch.setattr(layer, "_append_retry_history", lambda *args: None)
|
|
event = MagicMock(id="exec-1", error="rate limit")
|
|
layer._handle_node_retry(event)
|
|
assert capture_publishes["node"] == [{"workflow_run_id": "run-1", "node_id": "agent-1", "status": "retry"}]
|
|
|
|
@staticmethod
|
|
def test_node_succeeded_publishes_succeeded(layer, capture_publishes, monkeypatch: pytest.MonkeyPatch):
|
|
_seed_node_execution(layer, exec_id="exec-1", node_id="agent-1")
|
|
# Stub the inner _update_node_execution so we don't have to construct a
|
|
# full NodeRunResult — we only want to confirm the publish happens after.
|
|
monkeypatch.setattr(layer, "_update_node_execution", lambda *a, **kw: None)
|
|
event = MagicMock(id="exec-1", node_run_result=MagicMock(), finished_at=datetime.now())
|
|
layer._handle_node_succeeded(event)
|
|
assert capture_publishes["node"] == [{"workflow_run_id": "run-1", "node_id": "agent-1", "status": "succeeded"}]
|
|
|
|
@staticmethod
|
|
def test_node_failed_publishes_failed(layer, capture_publishes, monkeypatch: pytest.MonkeyPatch):
|
|
_seed_node_execution(layer, exec_id="exec-1", node_id="agent-1")
|
|
monkeypatch.setattr(layer, "_update_node_execution", lambda *a, **kw: None)
|
|
event = MagicMock(id="exec-1", node_run_result=MagicMock(), error="bad", finished_at=datetime.now())
|
|
layer._handle_node_failed(event)
|
|
assert capture_publishes["node"] == [{"workflow_run_id": "run-1", "node_id": "agent-1", "status": "failed"}]
|
|
|
|
@staticmethod
|
|
def test_node_exception_publishes_exception(layer, capture_publishes, monkeypatch: pytest.MonkeyPatch):
|
|
_seed_node_execution(layer, exec_id="exec-1", node_id="agent-1")
|
|
monkeypatch.setattr(layer, "_update_node_execution", lambda *a, **kw: None)
|
|
event = MagicMock(id="exec-1", node_run_result=MagicMock(), error="oom", finished_at=datetime.now())
|
|
layer._handle_node_exception(event)
|
|
assert capture_publishes["node"] == [{"workflow_run_id": "run-1", "node_id": "agent-1", "status": "exception"}]
|
|
|
|
@staticmethod
|
|
def test_node_pause_requested_does_not_publish(layer, capture_publishes, monkeypatch: pytest.MonkeyPatch):
|
|
"""Node pause is not an Inspector-visible state — no publish."""
|
|
_seed_node_execution(layer, exec_id="exec-1", node_id="agent-1")
|
|
monkeypatch.setattr(layer, "_update_node_execution", lambda *a, **kw: None)
|
|
event = MagicMock(id="exec-1", node_run_result=MagicMock())
|
|
layer._handle_node_pause_requested(event)
|
|
assert capture_publishes["node"] == []
|
|
|
|
|
|
class _RepoRecorder:
|
|
def __init__(self) -> None:
|
|
self.saved: list[object] = []
|
|
self.synchronously_saved: list[object] = []
|
|
self.saved_exec_data: list[object] = []
|
|
|
|
def save(self, entity):
|
|
self.saved.append(entity)
|
|
|
|
def save_many(self, entities):
|
|
self.saved.extend(entities)
|
|
|
|
def save_synchronously(self, entity):
|
|
self.synchronously_saved.append(entity)
|
|
|
|
def save_execution_data(self, entity):
|
|
self.saved_exec_data.append(entity)
|
|
|
|
|
|
class _TraceRecorder:
|
|
"""Record trace events with the number of workflow saves that preceded each one."""
|
|
|
|
def __init__(self, workflow_repo: _RepoRecorder) -> None:
|
|
self._workflow_repo = workflow_repo
|
|
self.events: list[TraceEvent] = []
|
|
self.saves_before_emit: list[int] = []
|
|
|
|
def emit(self, event: TraceEvent) -> None:
|
|
self.events.append(event)
|
|
self.saves_before_emit.append(len(self._workflow_repo.synchronously_saved))
|
|
|
|
|
|
def _trace_scope(recorder: _TraceRecorder, correlation: TraceCorrelation) -> TraceScope:
|
|
return TraceScope(emitter=recorder, tenant_id="tenant", app_id="app", user_id="user", correlation=correlation)
|
|
|
|
|
|
def _empty_correlation() -> TraceCorrelation:
|
|
return TraceCorrelation(
|
|
external_trace_id=None, session_id=None, parent_workflow_run_id=None, parent_node_execution_id=None
|
|
)
|
|
|
|
|
|
def _naive_utc_now() -> datetime:
|
|
return datetime.now(UTC).replace(tzinfo=None)
|
|
|
|
|
|
def _make_layer(
|
|
system_variables: list | None = None,
|
|
*,
|
|
extras: dict | None = None,
|
|
graph_data: dict | None = None,
|
|
trace_scope: TraceScope | None = None,
|
|
workflow_execution_repo=None,
|
|
workflow_node_execution_repo=None,
|
|
node_run_indices=None,
|
|
):
|
|
system_variables = system_variables or build_system_variables(
|
|
workflow_execution_id="run-id",
|
|
conversation_id="conv-id",
|
|
)
|
|
runtime_state = RuntimeState(
|
|
workflow_id="test-workflow",
|
|
variable_pool=VariablePool.from_bootstrap(system_variables=system_variables),
|
|
start_at=0.0,
|
|
)
|
|
read_only_state = ReadOnlyRuntimeStateWrapper(runtime_state)
|
|
|
|
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
|
|
task_id="task",
|
|
app_config=SimpleNamespace(app_id="app", tenant_id="tenant"),
|
|
inputs={"foo": "bar"},
|
|
files=[],
|
|
user_id="user",
|
|
stream=False,
|
|
invoke_from=None,
|
|
trace_scope=trace_scope,
|
|
workflow_execution_id="run-id",
|
|
extras=extras or {},
|
|
call_depth=0,
|
|
)
|
|
|
|
workflow_info = PersistenceWorkflowInfo(
|
|
workflow_id="workflow-id",
|
|
workflow_type=WorkflowType.WORKFLOW,
|
|
version="1",
|
|
graph_data=graph_data if graph_data is not None else {"nodes": [], "edges": []},
|
|
)
|
|
|
|
workflow_execution_repo = workflow_execution_repo if workflow_execution_repo is not None else _RepoRecorder()
|
|
workflow_node_execution_repo = (
|
|
workflow_node_execution_repo if workflow_node_execution_repo is not None else _RepoRecorder()
|
|
)
|
|
|
|
layer = WorkflowPersistenceLayer(
|
|
application_generate_entity=application_generate_entity,
|
|
workflow_info=workflow_info,
|
|
workflow_execution_repository=workflow_execution_repo,
|
|
workflow_node_execution_repository=workflow_node_execution_repo,
|
|
trace_scope=trace_scope,
|
|
)
|
|
layer.initialize(read_only_state, command_channel=None)
|
|
layer.set_node_run_indices(node_run_indices or {})
|
|
|
|
return layer, workflow_execution_repo, workflow_node_execution_repo, runtime_state
|
|
|
|
|
|
def _make_sql_layer(session_factory, *, node_run_indices=None):
|
|
user = Account(name="Test", email="test@example.com")
|
|
user.id = "user"
|
|
layer, _, _, _ = _make_layer(
|
|
node_run_indices=node_run_indices,
|
|
workflow_execution_repo=SQLAlchemyWorkflowExecutionRepository(
|
|
session_factory=session_factory,
|
|
tenant_id="tenant",
|
|
user=user,
|
|
app_id="caller-app",
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
),
|
|
workflow_node_execution_repo=SQLAlchemyWorkflowNodeExecutionRepository(
|
|
session_factory=session_factory,
|
|
tenant_id="tenant",
|
|
user=user,
|
|
app_id="caller-app",
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
),
|
|
)
|
|
layer._application_generate_entity.app_config.app_id = "caller-app"
|
|
return layer
|
|
|
|
|
|
def _tool_source():
|
|
return WorkflowToolSource(
|
|
app_id="source-app",
|
|
workflow_id="source-workflow",
|
|
graph_config={"nodes": [{"id": "source-loop", "data": {"type": "loop"}}], "edges": []},
|
|
features_dict={},
|
|
environment_variables=[],
|
|
workflow_kind=WorkflowKind.STANDARD,
|
|
)
|
|
|
|
|
|
class TestPersistenceLayerCases:
|
|
@staticmethod
|
|
def test_tool_trace_identity_survives_start_pause_and_resume():
|
|
layer, _, node_repo, _ = _make_layer(node_run_indices={"tool-exec": 1})
|
|
layer.on_event(GraphRunStartedEvent())
|
|
started_at = _naive_utc_now()
|
|
layer.on_event(
|
|
NodeRunStartedEvent(
|
|
id="tool-exec",
|
|
node_id="tool",
|
|
node_type=BuiltinNodeTypes.TOOL,
|
|
node_title="Nested tool",
|
|
provider_type="workflow",
|
|
provider_id="provider",
|
|
start_at=started_at,
|
|
node_run_result=NodeRunResult(process_data={WORKFLOW_TOOL_INVOCATION_ID_KEY: "invocation"}),
|
|
)
|
|
)
|
|
for event in (
|
|
NodeRunPauseRequestedEvent(
|
|
id="tool-exec", node_id="tool", node_type=BuiltinNodeTypes.TOOL, reason=SchedulingPause(message="pause")
|
|
),
|
|
NodeRunSucceededEvent(id="tool-exec", node_id="tool", node_type=BuiltinNodeTypes.TOOL, start_at=started_at),
|
|
):
|
|
layer.on_event(event)
|
|
execution = node_repo.saved_exec_data[-1]
|
|
assert execution.process_data[WORKFLOW_TOOL_INVOCATION_ID_KEY] == "invocation"
|
|
assert execution.metadata[WorkflowNodeExecutionMetadataKey.TOOL_INFO] == {
|
|
"provider_type": "workflow",
|
|
"provider_id": "provider",
|
|
}
|
|
|
|
@staticmethod
|
|
def test_workflow_tool_agent_nodes_persist_in_source_app_under_only_root_run(sqlite_session_factory):
|
|
layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"caller-exec": 1, "source-agent-exec": 1})
|
|
listen = layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "caller-exec")
|
|
layer.on_graph_start()
|
|
layer.on_event(GraphRunStartedEvent())
|
|
started_at = _naive_utc_now()
|
|
layer.on_event(
|
|
NodeRunStartedEvent(
|
|
id="caller-exec",
|
|
node_id="caller",
|
|
node_type=BuiltinNodeTypes.TOOL,
|
|
node_title="Workflow Tool",
|
|
start_at=started_at,
|
|
)
|
|
)
|
|
listen(
|
|
NodeRunStartedEvent(
|
|
id="source-agent-exec",
|
|
node_id="agent",
|
|
node_type=BuiltinNodeTypes.AGENT,
|
|
node_version="2",
|
|
node_title="Agent",
|
|
start_at=started_at,
|
|
container_id="source-loop",
|
|
node_run_result=NodeRunResult(
|
|
process_data={WORKFLOW_TOOL_ROOT_APP_ID_KEY: "untrusted-app"}, metadata={"loop_id": "source-loop"}
|
|
),
|
|
)
|
|
)
|
|
|
|
with sqlite_session_factory() as session:
|
|
rows = {row.id: row for row in session.scalars(select(WorkflowNodeExecutionModel))}
|
|
assert rows["caller-exec"].app_id == "caller-app"
|
|
source_row = rows["source-agent-exec"]
|
|
assert (source_row.tenant_id, source_row.app_id, source_row.workflow_id, source_row.workflow_run_id) == (
|
|
"tenant",
|
|
"source-app",
|
|
"source-workflow",
|
|
"run-id",
|
|
)
|
|
assert source_row.status == "running"
|
|
assert source_row.process_data_dict[WORKFLOW_TOOL_ROOT_APP_ID_KEY] == "caller-app"
|
|
assert source_row.execution_metadata_dict["loop_id"] == "source-loop"
|
|
assert session.scalars(select(WorkflowRun)).one().workflow_id == "workflow-id"
|
|
|
|
listen(
|
|
NodeRunSucceededEvent(
|
|
id="source-agent-exec",
|
|
node_id="agent",
|
|
node_type=BuiltinNodeTypes.AGENT,
|
|
node_title="Agent",
|
|
start_at=started_at,
|
|
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"answer": "ok"}),
|
|
)
|
|
)
|
|
layer.on_event(GraphRunSucceededEvent(outputs={"result": "caller-result"}))
|
|
|
|
with sqlite_session_factory() as session:
|
|
run = session.scalars(select(WorkflowRun)).one()
|
|
assert (run.app_id, run.workflow_id, run.status, run.outputs_dict) == (
|
|
"caller-app",
|
|
"workflow-id",
|
|
"succeeded",
|
|
{"result": "caller-result"},
|
|
)
|
|
row = session.get(WorkflowNodeExecutionModel, "source-agent-exec")
|
|
assert row.status == "succeeded"
|
|
assert row.outputs_dict == {"answer": "ok"}
|
|
assert row.process_data_dict[WORKFLOW_TOOL_ROOT_APP_ID_KEY] == "caller-app"
|
|
|
|
@staticmethod
|
|
def test_nested_tool_callers_keep_distinct_invocations_in_shared_source_layers(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
ids = ["a-call-1", "a-call-2", "b-call-1", "b-call-2", "c-child"]
|
|
layer = _make_sql_layer(sqlite_session_factory, node_run_indices=dict.fromkeys(ids, 1))
|
|
source = _tool_source()
|
|
nested = replace(source, app_id="nested-app", workflow_id="nested-workflow")
|
|
first_call = layer.create_workflow_tool_event_listener(source, "workflow-id", "a-call-1")
|
|
second_call = layer.create_workflow_tool_event_listener(source, "workflow-id", "a-call-2")
|
|
nested_call = layer.create_workflow_tool_event_listener(nested, source.workflow_id, "b-call-1")
|
|
layer.on_event(GraphRunStartedEvent())
|
|
for execution_id, listener in zip(
|
|
ids, [layer.on_event, layer.on_event, first_call, second_call, nested_call], strict=True
|
|
):
|
|
listener(
|
|
NodeRunStartedEvent(
|
|
id=execution_id,
|
|
node_id="same-canvas-node",
|
|
node_type=BuiltinNodeTypes.TOOL,
|
|
node_title="Tool",
|
|
start_at=_naive_utc_now(),
|
|
)
|
|
)
|
|
repository = DifyAPISQLAlchemyWorkflowNodeExecutionRepository(sqlite_session_factory)
|
|
for caller, expected, source_app, source_workflow in (
|
|
("a-call-1", "b-call-1", "source-app", "source-workflow"),
|
|
("a-call-2", "b-call-2", "source-app", "source-workflow"),
|
|
("b-call-1", "c-child", "nested-app", "nested-workflow"),
|
|
):
|
|
children = repository.get_workflow_tool_executions("tenant", "run-id", caller)
|
|
assert [child.id for child in children] == [expected]
|
|
child = children[0]
|
|
assert (child.app_id, child.workflow_id, child.workflow_run_id) == (source_app, source_workflow, "run-id")
|
|
assert child.triggered_from_workflow_id == ("source-workflow" if caller == "b-call-1" else "workflow-id")
|
|
assert child.triggered_from_node_execution_id == caller
|
|
|
|
@staticmethod
|
|
def test_workflow_tool_resume_updates_existing_running_container(sqlite_session_factory):
|
|
layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"loop-exec": 1})
|
|
listen = layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "caller-exec")
|
|
layer.on_event(GraphRunStartedEvent())
|
|
started_at = _naive_utc_now()
|
|
listen(
|
|
NodeRunStartedEvent(
|
|
id="loop-exec",
|
|
node_id="source-loop",
|
|
node_type=BuiltinNodeTypes.LOOP,
|
|
node_title="Approval Loop",
|
|
start_at=started_at,
|
|
)
|
|
)
|
|
layer.on_event(GraphRunPausedEvent(outputs={}, reasons=[]))
|
|
|
|
resumed = _make_sql_layer(sqlite_session_factory, node_run_indices={"loop-exec": 1})
|
|
resume_listener = resumed.create_workflow_tool_event_listener(
|
|
_tool_source(),
|
|
"workflow-id",
|
|
"caller-exec",
|
|
node_executions=resumed._workflow_node_execution_repository.for_workflow_tool(
|
|
"source-app"
|
|
).get_by_workflow_execution("run-id"),
|
|
)
|
|
resumed.on_graph_start()
|
|
resumed.on_event(GraphRunStartedEvent(reason=WorkflowStartReason.RESUMPTION))
|
|
resume_listener(
|
|
NodeRunSucceededEvent(
|
|
id="loop-exec",
|
|
node_id="source-loop",
|
|
node_type=BuiltinNodeTypes.LOOP,
|
|
node_title="Approval Loop",
|
|
start_at=started_at,
|
|
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"approved": True}),
|
|
)
|
|
)
|
|
resumed.on_event(GraphRunSucceededEvent(outputs={"answer": "approved"}))
|
|
|
|
with sqlite_session_factory() as session:
|
|
node = session.scalars(select(WorkflowNodeExecutionModel)).one()
|
|
assert (node.app_id, node.workflow_id, node.workflow_run_id, node.status) == (
|
|
"source-app",
|
|
"source-workflow",
|
|
"run-id",
|
|
"succeeded",
|
|
)
|
|
assert node.outputs_dict == {"approved": True}
|
|
run = session.scalars(select(WorkflowRun)).one()
|
|
assert (run.app_id, run.workflow_id, run.status) == ("caller-app", "workflow-id", "succeeded")
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("source_workflow_id", ["older-workflow", "workflow-id"])
|
|
def test_root_resume_isolates_same_app_workflow_tool_origin(sqlite_session_factory, source_workflow_id):
|
|
source = replace(_tool_source(), app_id="caller-app", workflow_id=source_workflow_id)
|
|
layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"caller-exec": 1, "older-exec": 1})
|
|
listen = layer.create_workflow_tool_event_listener(source, "workflow-id", "caller-exec")
|
|
layer.on_event(GraphRunStartedEvent())
|
|
started_at = _naive_utc_now()
|
|
layer.on_event(
|
|
NodeRunStartedEvent(
|
|
id="caller-exec",
|
|
node_id="caller",
|
|
node_type=BuiltinNodeTypes.TOOL,
|
|
node_title="Caller",
|
|
start_at=started_at,
|
|
)
|
|
)
|
|
listen(
|
|
NodeRunStartedEvent(
|
|
id="older-exec",
|
|
node_id="older-node",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
node_title="Older version",
|
|
start_at=started_at,
|
|
)
|
|
)
|
|
layer.on_event(GraphRunPausedEvent())
|
|
|
|
resumed = _make_sql_layer(sqlite_session_factory, node_run_indices={"caller-exec": 1, "older-exec": 1})
|
|
resumed.set_node_execution_history(
|
|
resumed._workflow_node_execution_repository.get_by_workflow_execution("run-id")
|
|
)
|
|
resume_listener = resumed.create_workflow_tool_event_listener(
|
|
source,
|
|
"workflow-id",
|
|
"caller-exec",
|
|
node_executions=resumed._workflow_node_execution_repository.for_workflow_tool(
|
|
source.app_id
|
|
).get_by_workflow_execution("run-id"),
|
|
)
|
|
resumed.on_event(GraphRunStartedEvent(reason=WorkflowStartReason.RESUMPTION))
|
|
resume_listener(
|
|
NodeRunSucceededEvent(
|
|
id="older-exec",
|
|
node_id="older-node",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
start_at=started_at,
|
|
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED),
|
|
)
|
|
)
|
|
resumed.on_event(GraphRunFailedEvent(error="caller failed later"))
|
|
|
|
with sqlite_session_factory() as session:
|
|
rows = {row.id: row for row in session.scalars(select(WorkflowNodeExecutionModel))}
|
|
assert rows["caller-exec"].status == "failed"
|
|
assert rows["older-exec"].status == "succeeded"
|
|
assert rows["older-exec"].workflow_id == source_workflow_id
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("source", [False, True])
|
|
@pytest.mark.parametrize("outcome", ["succeeded", "failed", "aborted", "paused"])
|
|
def test_retry_starts_preserve_original_execution_and_attempt_history(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
source: bool,
|
|
outcome: str,
|
|
) -> None:
|
|
layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"retry-exec": 1})
|
|
listen = (
|
|
layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "caller-exec")
|
|
if source
|
|
else layer.on_event
|
|
)
|
|
layer.on_event(GraphRunStartedEvent())
|
|
workflow_id = "source-workflow" if source else "workflow-id"
|
|
state = RuntimeState(workflow_id=workflow_id, variable_pool=VariablePool(), start_at=0)
|
|
frames = FrameRegistry()
|
|
node = StartNode(
|
|
node_id="child",
|
|
data=StartNodeData(title="Child", variables=[]),
|
|
init_params=build_test_graph_init_params(workflow_id=workflow_id),
|
|
runtime_state=state,
|
|
)
|
|
graph = Graph.new().add_root(node).build()
|
|
frames.register(
|
|
ExecutionFrame(
|
|
frame_id=ROOT_FRAME_ID,
|
|
graph=graph,
|
|
state=state,
|
|
scheduler=Scheduler(graph, state, ROOT_FRAME_ID),
|
|
failure_handler=NodeFailureHandler(graph, state.graph_execution),
|
|
)
|
|
)
|
|
|
|
event_stream = EventStream(layers=[])
|
|
collect = event_stream.collect
|
|
|
|
def collect_source_event(event: EngineEvent) -> None:
|
|
assert isinstance(event, NodeEvent)
|
|
listen(event)
|
|
collect(event)
|
|
|
|
monkeypatch.setattr(event_stream, "collect", collect_source_event)
|
|
processor = NodeEventProcessor(state.graph_execution, event_stream, frames, {})
|
|
|
|
def dispatch(event: NodeEvent) -> None:
|
|
processor.dispatch(NodeEventTask(frame_id=ROOT_FRAME_ID, event=event))
|
|
|
|
started_at = _naive_utc_now()
|
|
started = NodeRunStartedEvent(
|
|
id="retry-exec",
|
|
node_id="child",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
node_title="Child",
|
|
start_at=started_at,
|
|
)
|
|
dispatch(started)
|
|
for attempt in (1, 2):
|
|
dispatch(
|
|
NodeRunRetryEvent(
|
|
id="retry-exec",
|
|
node_id="child",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
node_title="Child",
|
|
start_at=started_at,
|
|
retry_index=attempt,
|
|
error=f"attempt-{attempt}",
|
|
node_run_result=NodeRunResult(outputs={"attempt": attempt}),
|
|
)
|
|
)
|
|
dispatch(started)
|
|
if outcome == "succeeded":
|
|
dispatch(
|
|
NodeRunSucceededEvent(
|
|
id="retry-exec",
|
|
node_id="child",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
start_at=started_at,
|
|
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"ok": True}),
|
|
)
|
|
)
|
|
elif outcome == "failed":
|
|
layer.on_event(GraphRunFailedEvent(error="terminal failure"))
|
|
elif outcome == "aborted":
|
|
layer.on_event(GraphRunAbortedEvent(reason="terminal failure"))
|
|
else:
|
|
commits = []
|
|
|
|
def record_commit(_session: Session) -> None:
|
|
commits.append(True)
|
|
|
|
sqlalchemy_event.listen(Session, "after_commit", record_commit)
|
|
try:
|
|
layer.persist_unfinished_nodes_synchronously()
|
|
finally:
|
|
sqlalchemy_event.remove(Session, "after_commit", record_commit)
|
|
assert len(commits) == 1
|
|
|
|
with sqlite_session_factory() as session:
|
|
row = session.scalars(select(WorkflowNodeExecutionModel)).one()
|
|
assert row.index == 1
|
|
assert row.created_at == started_at
|
|
assert row.workflow_id == workflow_id
|
|
assert row.app_id == ("source-app" if source else "caller-app")
|
|
if outcome == "succeeded":
|
|
assert row.status == "succeeded"
|
|
assert row.outputs_dict == {"ok": True}
|
|
elif outcome == "paused":
|
|
assert row.status == "retry"
|
|
assert row.finished_at is None
|
|
else:
|
|
assert row.status == "failed"
|
|
assert row.error == "terminal failure"
|
|
assert row.finished_at is not None
|
|
history = row.process_data_dict[RETRY_HISTORY_PROCESS_DATA_KEY]
|
|
assert [(attempt["retry_index"], attempt["error"]) for attempt in history] == [
|
|
(1, "attempt-1"),
|
|
(2, "attempt-2"),
|
|
]
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
"event", [GraphRunFailedEvent(error="failed", exceptions_count=1), GraphRunAbortedEvent(reason="aborted")]
|
|
)
|
|
def test_root_terminal_failure_finishes_running_workflow_tool_nodes(sqlite_session_factory, event):
|
|
layer = _make_sql_layer(sqlite_session_factory, node_run_indices={"source-exec": 1})
|
|
listen = layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "caller-exec")
|
|
layer.on_event(GraphRunStartedEvent())
|
|
listen(
|
|
NodeRunStartedEvent(
|
|
id="source-exec",
|
|
node_id="child",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
node_title="Child",
|
|
start_at=_naive_utc_now(),
|
|
)
|
|
)
|
|
|
|
layer.on_event(event)
|
|
|
|
with sqlite_session_factory() as session:
|
|
node = session.scalars(select(WorkflowNodeExecutionModel)).one()
|
|
assert node.status == "failed"
|
|
assert node.error == (event.error if isinstance(event, GraphRunFailedEvent) else event.reason)
|
|
assert node.finished_at is not None
|
|
assert session.scalars(select(WorkflowRun)).one().workflow_id == "workflow-id"
|
|
|
|
@staticmethod
|
|
def test_terminal_failure_batches_only_unfinished_nodes_per_source(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
ids = (
|
|
"root-a",
|
|
"root-b",
|
|
"root-retry",
|
|
"root-paused",
|
|
"source-a",
|
|
"source-b",
|
|
"source-retry",
|
|
"source-paused",
|
|
"source-done",
|
|
)
|
|
layer = _make_sql_layer(sqlite_session_factory, node_run_indices=dict.fromkeys(ids, 1))
|
|
listen = layer.create_workflow_tool_event_listener(_tool_source(), "workflow-id", "root-a")
|
|
layer.on_event(GraphRunStartedEvent())
|
|
started_at = _naive_utc_now()
|
|
for execution_id in ids:
|
|
listener = listen if execution_id.startswith("source") else layer.on_event
|
|
listener(
|
|
NodeRunStartedEvent(
|
|
id=execution_id,
|
|
node_id=execution_id,
|
|
node_type=BuiltinNodeTypes.CODE,
|
|
node_title="Code",
|
|
start_at=started_at,
|
|
)
|
|
)
|
|
if execution_id.endswith("paused"):
|
|
listener(
|
|
NodeRunPauseRequestedEvent(
|
|
id=execution_id,
|
|
node_id=execution_id,
|
|
node_type=BuiltinNodeTypes.CODE,
|
|
reason=SchedulingPause(message="pause"),
|
|
)
|
|
)
|
|
elif execution_id.endswith("retry"):
|
|
listener(
|
|
NodeRunRetryEvent(
|
|
id=execution_id,
|
|
node_id=execution_id,
|
|
node_type=BuiltinNodeTypes.CODE,
|
|
node_title="Code",
|
|
start_at=started_at,
|
|
retry_index=1,
|
|
error="retry failed",
|
|
)
|
|
)
|
|
listen(
|
|
NodeRunSucceededEvent(
|
|
id="source-done",
|
|
node_id="source-done",
|
|
node_type=BuiltinNodeTypes.CODE,
|
|
start_at=started_at,
|
|
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"answer": "kept"}),
|
|
)
|
|
)
|
|
commits = []
|
|
|
|
def record_commit(_session: Session) -> None:
|
|
commits.append(True)
|
|
|
|
sqlalchemy_event.listen(Session, "after_commit", record_commit)
|
|
try:
|
|
layer.on_event(GraphRunFailedEvent(error="terminal failure", exceptions_count=0))
|
|
finally:
|
|
sqlalchemy_event.remove(Session, "after_commit", record_commit)
|
|
|
|
with sqlite_session_factory() as session:
|
|
rows = {row.id: row for row in session.scalars(select(WorkflowNodeExecutionModel))}
|
|
assert {key: row.status for key, row in rows.items()} == {
|
|
"root-a": "failed",
|
|
"root-b": "failed",
|
|
"root-retry": "failed",
|
|
"root-paused": "paused",
|
|
"source-a": "failed",
|
|
"source-b": "failed",
|
|
"source-retry": "failed",
|
|
"source-paused": "paused",
|
|
"source-done": "succeeded",
|
|
}
|
|
assert rows["source-done"].outputs_dict == {"answer": "kept"}
|
|
for execution_id in ("root-a", "root-b", "root-retry", "source-a", "source-b", "source-retry"):
|
|
assert rows[execution_id].error == "terminal failure"
|
|
assert rows[execution_id].finished_at is not None
|
|
for execution_id in ("root-retry", "source-retry"):
|
|
assert (
|
|
rows[execution_id].process_data_dict[RETRY_HISTORY_PROCESS_DATA_KEY][0]["error"] == "retry failed"
|
|
)
|
|
assert rows["source-a"].triggered_from_node_execution_id == "root-a"
|
|
assert rows["source-a"].app_id == "source-app"
|
|
# One commit per source owner, plus the workflow summary.
|
|
assert len(commits) == 3
|
|
|
|
class TestWorkflowPersistenceLayer:
|
|
def test_start_freezes_tracing_only_after_workflow_save(self) -> None:
|
|
layer, workflow_repo, _, _ = _make_layer(extras={"trace_session_id": "external-session"})
|
|
calls: list[tuple[str, str, str, str, object]] = []
|
|
|
|
class _FreezeAfterSave(RecordingTracingHooks):
|
|
@override
|
|
def freeze_workflow_run(
|
|
self,
|
|
entity: AdvancedChatAppGenerateEntity | WorkflowAppGenerateEntity,
|
|
*,
|
|
workflow_id: str,
|
|
workflow_run_id: str,
|
|
conversation_id: object,
|
|
graph_data: object,
|
|
) -> None:
|
|
assert workflow_repo.synchronously_saved
|
|
assert graph_data == {"nodes": [], "edges": []}
|
|
saved_execution = workflow_repo.synchronously_saved[-1]
|
|
assert isinstance(saved_execution, WorkflowExecution)
|
|
assert saved_execution.id_ == workflow_run_id
|
|
calls.append(
|
|
(
|
|
entity.app_config.app_id,
|
|
entity.app_config.tenant_id,
|
|
workflow_id,
|
|
workflow_run_id,
|
|
conversation_id,
|
|
)
|
|
)
|
|
|
|
app = Flask(__name__)
|
|
bind_tracing(
|
|
app,
|
|
scopes=kernel_trace_scopes(
|
|
_TraceRecorder(workflow_repo), policies=NoTracingPolicies(), enterprise_enabled=False
|
|
),
|
|
hooks=_FreezeAfterSave(None),
|
|
)
|
|
|
|
with app.app_context():
|
|
layer._handle_graph_run_started()
|
|
assert calls == [("app", "tenant", "workflow-id", "run-id", "conv-id")]
|
|
|
|
def test_get_execution_id_requires_system_variable(self):
|
|
layer, _, _, _ = _make_layer(build_system_variables())
|
|
|
|
with pytest.raises(ValueError, match="workflow_execution_id must be provided"):
|
|
layer._get_execution_id()
|
|
|
|
def test_prepare_workflow_inputs_excludes_conversation_id(self, monkeypatch: pytest.MonkeyPatch):
|
|
layer, _, _, _ = _make_layer()
|
|
|
|
monkeypatch.setattr(
|
|
"core.workflow.workflow_entry.WorkflowEntry.handle_special_values",
|
|
lambda inputs: inputs,
|
|
)
|
|
|
|
inputs = layer._prepare_workflow_inputs()
|
|
|
|
assert "sys.conversation_id" not in inputs
|
|
assert inputs[f"sys.{SystemVariableKey.WORKFLOW_EXECUTION_ID.value}"] == "run-id"
|
|
|
|
@pytest.mark.parametrize(
|
|
"status",
|
|
[
|
|
WorkflowNodeExecutionStatus.RUNNING,
|
|
WorkflowNodeExecutionStatus.RETRY,
|
|
WorkflowNodeExecutionStatus.PAUSED,
|
|
WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
],
|
|
)
|
|
def test_fail_running_node_executions_marks_only_live_attempts_failed(self, status):
|
|
layer, _, node_repo, _ = _make_layer()
|
|
|
|
execution = WorkflowNodeExecution(
|
|
id="exec-id",
|
|
workflow_id="workflow-id",
|
|
workflow_execution_id="run-id",
|
|
index=1,
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.START,
|
|
title="Start",
|
|
status=status,
|
|
created_at=_naive_utc_now(),
|
|
)
|
|
layer._node_execution_cache[execution.id] = execution
|
|
|
|
layer._fail_running_node_executions(error_message="boom")
|
|
|
|
if status in (WorkflowNodeExecutionStatus.RUNNING, WorkflowNodeExecutionStatus.RETRY):
|
|
assert execution.status == WorkflowNodeExecutionStatus.FAILED
|
|
assert execution.error == "boom"
|
|
assert execution.finished_at is not None
|
|
assert node_repo.saved == [execution]
|
|
else:
|
|
assert execution.status == status
|
|
assert execution.finished_at is None
|
|
assert node_repo.saved == []
|
|
|
|
def test_resumption_restores_container_execution_before_terminal_event(self):
|
|
layer, _, node_repo, _ = _make_layer()
|
|
started_at = _naive_utc_now()
|
|
execution = WorkflowNodeExecution(
|
|
id="loop-exec",
|
|
workflow_id="workflow-id",
|
|
workflow_execution_id="run-id",
|
|
index=4,
|
|
node_id="loop",
|
|
node_type=BuiltinNodeTypes.LOOP,
|
|
title="Loop",
|
|
status=WorkflowNodeExecutionStatus.RUNNING,
|
|
created_at=started_at,
|
|
)
|
|
layer.set_node_execution_history([execution])
|
|
|
|
layer.on_event(GraphRunStartedEvent(reason=WorkflowStartReason.RESUMPTION))
|
|
layer.on_event(
|
|
NodeRunSucceededEvent(
|
|
id=execution.id,
|
|
node_id=execution.node_id,
|
|
node_type=execution.node_type,
|
|
start_at=started_at,
|
|
finished_at=started_at + timedelta(seconds=2),
|
|
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED),
|
|
)
|
|
)
|
|
|
|
assert execution.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
|
assert execution.elapsed_time == 2.0
|
|
assert execution.index == 4
|
|
|
|
def test_handle_graph_run_succeeded_updates_execution(self):
|
|
layer, exec_repo, _, runtime_state = _make_layer()
|
|
layer.on_event(GraphRunStartedEvent())
|
|
usage = LLMUsage.empty_usage()
|
|
usage.total_tokens = 3
|
|
runtime_state.add_llm_usage(usage)
|
|
for _ in range(2):
|
|
runtime_state.increment_node_run_steps()
|
|
runtime_state.set_output("out", "v")
|
|
|
|
layer.on_event(GraphRunSucceededEvent(outputs={"ok": True}))
|
|
|
|
saved = exec_repo.synchronously_saved[-1]
|
|
assert saved.status == WorkflowExecutionStatus.SUCCEEDED
|
|
assert saved.total_tokens == 3
|
|
assert saved.total_steps == 2
|
|
|
|
def test_handle_graph_run_partial_succeeded_updates_execution(self):
|
|
layer, exec_repo, _, runtime_state = _make_layer()
|
|
layer.on_event(GraphRunStartedEvent())
|
|
usage = LLMUsage.empty_usage()
|
|
usage.total_tokens = 5
|
|
runtime_state.add_llm_usage(usage)
|
|
for _ in range(4):
|
|
runtime_state.increment_node_run_steps()
|
|
runtime_state._graph_execution = SimpleNamespace(exceptions_count=2)
|
|
|
|
layer.on_event(GraphRunPartialSucceededEvent(outputs={"ok": True}, exceptions_count=2))
|
|
|
|
saved = exec_repo.synchronously_saved[-1]
|
|
assert saved.status == WorkflowExecutionStatus.PARTIAL_SUCCEEDED
|
|
assert saved.exceptions_count == 2
|
|
assert saved.total_tokens == 5
|
|
|
|
def test_handle_graph_run_failed_marks_nodes_and_emits_trace_after_save(self) -> None:
|
|
workflow_repo = _RepoRecorder()
|
|
recorder = _TraceRecorder(workflow_repo)
|
|
layer, exec_repo, node_repo, _ = _make_layer(
|
|
trace_scope=_trace_scope(recorder, _empty_correlation()), workflow_execution_repo=workflow_repo
|
|
)
|
|
layer.on_event(GraphRunStartedEvent())
|
|
|
|
running = WorkflowNodeExecution(
|
|
id="node-exec",
|
|
workflow_id="workflow-id",
|
|
workflow_execution_id="run-id",
|
|
index=1,
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.START,
|
|
title="Start",
|
|
created_at=_naive_utc_now(),
|
|
)
|
|
layer._node_execution_cache[running.id] = running
|
|
|
|
layer.on_event(GraphRunFailedEvent(error="boom", exceptions_count=1))
|
|
|
|
assert node_repo.saved
|
|
assert exec_repo.synchronously_saved[-1].status == WorkflowExecutionStatus.FAILED
|
|
assert [event.payload for event in recorder.events] == [
|
|
WorkflowRunCompleted(workflow_run_id="run-id", conversation_id="conv-id", total_tokens=0)
|
|
]
|
|
# Started and finished saves both precede the event.
|
|
assert recorder.saves_before_emit == [2]
|
|
|
|
@pytest.mark.parametrize(
|
|
("system_variables", "conversation_id"),
|
|
[
|
|
(build_system_variables(workflow_execution_id="run-id", conversation_id="conv-id"), "conv-id"),
|
|
(build_system_variables(workflow_execution_id="run-id"), None),
|
|
],
|
|
)
|
|
def test_handle_graph_run_succeeded_emits_run_tokens_with_scope_correlation(
|
|
self, system_variables: list[Variable], conversation_id: str | None
|
|
) -> None:
|
|
workflow_repo = _RepoRecorder()
|
|
recorder = _TraceRecorder(workflow_repo)
|
|
correlation = TraceCorrelation(
|
|
external_trace_id="trace",
|
|
session_id="session-1",
|
|
parent_workflow_run_id="outer-workflow-run-1",
|
|
parent_node_execution_id="outer-node-execution-1",
|
|
)
|
|
layer, _, _, runtime_state = _make_layer(
|
|
system_variables,
|
|
trace_scope=_trace_scope(recorder, correlation),
|
|
workflow_execution_repo=workflow_repo,
|
|
)
|
|
layer.on_event(GraphRunStartedEvent())
|
|
usage = LLMUsage.empty_usage()
|
|
usage.total_tokens = 7
|
|
runtime_state.add_llm_usage(usage)
|
|
|
|
layer.on_event(GraphRunSucceededEvent(outputs={"ok": True}))
|
|
|
|
assert len(recorder.events) == 1
|
|
event = recorder.events[0]
|
|
assert event.payload == WorkflowRunCompleted(
|
|
workflow_run_id="run-id", conversation_id=conversation_id, total_tokens=7
|
|
)
|
|
assert event.correlation == correlation
|
|
assert (event.tenant_id, event.app_id, event.user_id) == ("tenant", "app", "user")
|
|
assert recorder.saves_before_emit == [2]
|
|
|
|
def test_paused_run_emits_no_trace(self) -> None:
|
|
workflow_repo = _RepoRecorder()
|
|
recorder = _TraceRecorder(workflow_repo)
|
|
layer, _, _, _ = _make_layer(
|
|
trace_scope=_trace_scope(recorder, _empty_correlation()), workflow_execution_repo=workflow_repo
|
|
)
|
|
layer.on_event(GraphRunStartedEvent())
|
|
|
|
layer.on_event(GraphRunPausedEvent(reasons=[SchedulingPause(message="pause")]))
|
|
|
|
assert recorder.events == []
|
|
|
|
def test_handle_graph_run_aborted_sets_status(self):
|
|
layer, exec_repo, _, _ = _make_layer()
|
|
layer.on_event(GraphRunStartedEvent())
|
|
|
|
layer.on_event(GraphRunAbortedEvent(reason=None, outputs={}))
|
|
|
|
saved = exec_repo.synchronously_saved[-1]
|
|
assert saved.status == WorkflowExecutionStatus.STOPPED
|
|
assert saved.error_message
|
|
|
|
def test_handle_node_started_and_retry(self):
|
|
layer, _, node_repo, _ = _make_layer(node_run_indices={"exec": 1})
|
|
layer.on_event(GraphRunStartedEvent())
|
|
|
|
start_event = NodeRunStartedEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.START,
|
|
node_title="Start",
|
|
start_at=_naive_utc_now(),
|
|
predecessor_node_id="prev",
|
|
)
|
|
layer.on_event(start_event)
|
|
|
|
assert node_repo.saved
|
|
assert "exec" in layer._node_execution_cache
|
|
assert node_repo.saved[-1].created_at == start_event.start_at
|
|
|
|
retry_event = NodeRunRetryEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.START,
|
|
node_title="Start",
|
|
start_at=_naive_utc_now(),
|
|
error="retry",
|
|
retry_index=1,
|
|
)
|
|
layer.on_event(retry_event)
|
|
assert node_repo.saved_exec_data
|
|
|
|
def test_agent_v2_caller_row_is_saved_synchronously_before_node_run(self):
|
|
layer, _, node_repo, _ = _make_layer(node_run_indices={"agent-exec": 1})
|
|
layer.on_event(GraphRunStartedEvent())
|
|
|
|
layer.on_event(
|
|
NodeRunStartedEvent(
|
|
id="agent-exec",
|
|
node_id="agent-node",
|
|
node_type=BuiltinNodeTypes.AGENT,
|
|
node_version="2",
|
|
node_title="Agent",
|
|
start_at=_naive_utc_now(),
|
|
)
|
|
)
|
|
|
|
assert [execution.id for execution in node_repo.synchronously_saved] == ["agent-exec"]
|
|
assert node_repo.saved == []
|
|
|
|
def test_retry_history_is_preserved_after_node_succeeds(self):
|
|
layer, _, node_repo, _ = _make_layer(node_run_indices={"exec": 1})
|
|
layer.on_event(GraphRunStartedEvent())
|
|
started_at = _naive_utc_now()
|
|
layer.on_event(
|
|
NodeRunStartedEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.HTTP_REQUEST,
|
|
node_title="HTTP",
|
|
start_at=started_at,
|
|
)
|
|
)
|
|
|
|
for retry_index in (1, 2):
|
|
layer.on_event(
|
|
NodeRunRetryEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.HTTP_REQUEST,
|
|
node_title="HTTP",
|
|
start_at=started_at,
|
|
error=f"attempt {retry_index} failed",
|
|
retry_index=retry_index,
|
|
node_run_result=NodeRunResult(
|
|
inputs={"attempt": retry_index},
|
|
process_data={"request": f"attempt-{retry_index}"},
|
|
outputs={"status_code": 500, "body": f"failure-{retry_index}"},
|
|
metadata={WorkflowNodeExecutionMetadataKey.ITERATION_ID: "iteration-1"},
|
|
),
|
|
)
|
|
)
|
|
|
|
layer.on_event(
|
|
NodeRunSucceededEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.HTTP_REQUEST,
|
|
start_at=started_at,
|
|
node_run_result=NodeRunResult(
|
|
inputs={"attempt": 3},
|
|
process_data={"request": "successful-attempt"},
|
|
outputs={"status_code": 200, "body": "ok"},
|
|
metadata={},
|
|
),
|
|
)
|
|
)
|
|
|
|
saved_execution = node_repo.saved_exec_data[-1]
|
|
assert saved_execution.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
|
assert saved_execution.process_data["request"] == "successful-attempt"
|
|
retry_history = saved_execution.process_data["__dify_retry_history"]
|
|
assert [attempt["retry_index"] for attempt in retry_history] == [1, 2]
|
|
assert retry_history[0]["inputs"] == {"attempt": 1}
|
|
assert retry_history[0]["process_data"] == {"request": "attempt-1"}
|
|
assert retry_history[0]["outputs"] == {"status_code": 500, "body": "failure-1"}
|
|
assert retry_history[0]["error"] == "attempt 1 failed"
|
|
assert retry_history[0]["execution_metadata"] == {"iteration_id": "iteration-1"}
|
|
|
|
def test_retry_history_records_unix_timestamps(self, monkeypatch: pytest.MonkeyPatch):
|
|
"""Retry attempts store epoch seconds, so the host timezone must not shift them."""
|
|
if not hasattr(time, "tzset"):
|
|
pytest.skip("tzset is only available on POSIX platforms")
|
|
recorded_at = datetime(2024, 5, 1)
|
|
expected = int(recorded_at.replace(tzinfo=UTC).timestamp())
|
|
monkeypatch.setattr(persistence_mod, "naive_utc_now", lambda: recorded_at)
|
|
monkeypatch.setenv("TZ", "Asia/Shanghai")
|
|
time.tzset()
|
|
try:
|
|
layer, _, node_repo, _ = _make_layer(node_run_indices={"exec": 1})
|
|
layer.on_event(GraphRunStartedEvent())
|
|
layer.on_event(
|
|
NodeRunStartedEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.HTTP_REQUEST,
|
|
node_title="HTTP",
|
|
start_at=recorded_at,
|
|
)
|
|
)
|
|
layer.on_event(
|
|
NodeRunRetryEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.HTTP_REQUEST,
|
|
node_title="HTTP",
|
|
start_at=recorded_at,
|
|
error="attempt 1 failed",
|
|
retry_index=1,
|
|
node_run_result=NodeRunResult(inputs={}, process_data={}, outputs={}, metadata={}),
|
|
)
|
|
)
|
|
layer.on_event(
|
|
NodeRunSucceededEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.HTTP_REQUEST,
|
|
node_title="HTTP",
|
|
start_at=recorded_at,
|
|
node_run_result=NodeRunResult(inputs={}, process_data={}, outputs={}, metadata={}),
|
|
)
|
|
)
|
|
attempt = node_repo.saved_exec_data[-1].process_data["__dify_retry_history"][0]
|
|
finally:
|
|
monkeypatch.undo()
|
|
time.tzset()
|
|
assert attempt["created_at"] == expected
|
|
assert attempt["finished_at"] == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
("event_type", "expected_status"),
|
|
[
|
|
(NodeRunFailedEvent, WorkflowNodeExecutionStatus.FAILED),
|
|
(NodeRunExceptionEvent, WorkflowNodeExecutionStatus.EXCEPTION),
|
|
(GraphRunFailedEvent, WorkflowNodeExecutionStatus.FAILED),
|
|
(GraphRunAbortedEvent, WorkflowNodeExecutionStatus.FAILED),
|
|
],
|
|
)
|
|
def test_retry_history_is_preserved_after_terminal_error(self, event_type, expected_status):
|
|
layer, _, node_repo, _ = _make_layer(node_run_indices={"exec": 1})
|
|
layer.on_event(GraphRunStartedEvent())
|
|
started_at = _naive_utc_now()
|
|
layer.on_event(
|
|
NodeRunStartedEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
node_title="LLM",
|
|
start_at=started_at,
|
|
)
|
|
)
|
|
layer.on_event(
|
|
NodeRunRetryEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
node_title="LLM",
|
|
start_at=started_at,
|
|
error="retry failed",
|
|
retry_index=1,
|
|
node_run_result=NodeRunResult(outputs={"attempt": 1}),
|
|
)
|
|
)
|
|
|
|
if event_type is GraphRunFailedEvent:
|
|
terminal_event = event_type(error="terminal failure")
|
|
elif event_type is GraphRunAbortedEvent:
|
|
terminal_event = event_type(reason="terminal failure")
|
|
else:
|
|
terminal_event = event_type(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
start_at=started_at,
|
|
error="terminal failure",
|
|
node_run_result=NodeRunResult(process_data={"terminal": True}),
|
|
)
|
|
node_repo.saved.clear()
|
|
layer.on_event(terminal_event)
|
|
|
|
saved_execution = node_repo.saved[-1]
|
|
assert saved_execution.status == expected_status
|
|
assert saved_execution.error == "terminal failure"
|
|
assert saved_execution.finished_at is not None
|
|
if event_type in (NodeRunFailedEvent, NodeRunExceptionEvent):
|
|
assert saved_execution.process_data["terminal"] is True
|
|
assert saved_execution.process_data["__dify_retry_history"][0]["retry_index"] == 1
|
|
|
|
def test_pause_flush_persists_retry_history_synchronously(self):
|
|
layer, _, node_repo, _ = _make_layer(node_run_indices={"exec": 1})
|
|
layer.on_event(GraphRunStartedEvent())
|
|
started_at = _naive_utc_now()
|
|
layer.on_event(
|
|
NodeRunStartedEvent(
|
|
id="exec", node_id="node", node_type=BuiltinNodeTypes.LLM, node_title="LLM", start_at=started_at
|
|
)
|
|
)
|
|
layer.on_event(
|
|
NodeRunRetryEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
node_title="LLM",
|
|
start_at=started_at,
|
|
error="retry failed",
|
|
retry_index=1,
|
|
)
|
|
)
|
|
assert node_repo.synchronously_saved == []
|
|
|
|
layer.persist_unfinished_nodes_synchronously()
|
|
|
|
saved_execution = node_repo.synchronously_saved[-1]
|
|
assert saved_execution.status == WorkflowNodeExecutionStatus.RETRY
|
|
assert saved_execution.finished_at is None
|
|
assert saved_execution.process_data["__dify_retry_history"][0]["error"] == "retry failed"
|
|
|
|
def test_handle_node_pause_requested_skips_outputs(self):
|
|
layer, _, _, _ = _make_layer(node_run_indices={"exec": 1})
|
|
layer.on_event(GraphRunStartedEvent())
|
|
start_event = NodeRunStartedEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
node_title="LLM",
|
|
start_at=_naive_utc_now(),
|
|
)
|
|
layer.on_event(start_event)
|
|
|
|
domain_execution = layer._node_execution_cache["exec"]
|
|
domain_execution.inputs = {"old": True}
|
|
|
|
result = NodeRunResult(
|
|
inputs={"new": True},
|
|
outputs={"out": 1},
|
|
process_data={
|
|
"p": 1,
|
|
"workflow_agent_binding_id": "workflow-binding-1",
|
|
"workflow_tool_invocation_id": "tool-call-1",
|
|
},
|
|
metadata={},
|
|
)
|
|
pause_event = NodeRunPauseRequestedEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
reason=SchedulingPause(message="pause"),
|
|
node_run_result=result,
|
|
)
|
|
layer.on_event(pause_event)
|
|
|
|
assert domain_execution.status == WorkflowNodeExecutionStatus.PAUSED
|
|
assert domain_execution.inputs == {"old": True}
|
|
assert domain_execution.process_data == {
|
|
"workflow_agent_binding_id": "workflow-binding-1",
|
|
"workflow_tool_invocation_id": "tool-call-1",
|
|
}
|
|
|
|
def test_handle_node_retry_preserves_workflow_agent_binding_identity(self):
|
|
layer, _, _, _ = _make_layer(node_run_indices={"exec": 1})
|
|
layer.on_event(GraphRunStartedEvent())
|
|
started_at = _naive_utc_now()
|
|
layer.on_event(
|
|
NodeRunStartedEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.AGENT,
|
|
node_title="Agent",
|
|
start_at=started_at,
|
|
)
|
|
)
|
|
|
|
layer.on_event(
|
|
NodeRunRetryEvent(
|
|
id="exec",
|
|
node_id="node",
|
|
node_type=BuiltinNodeTypes.AGENT,
|
|
node_title="Agent",
|
|
start_at=started_at,
|
|
error="retry",
|
|
retry_index=1,
|
|
node_run_result=NodeRunResult(
|
|
process_data={
|
|
"workflow_agent_binding_id": "workflow-binding-1",
|
|
"workflow_tool_invocation_id": "tool-call-1",
|
|
},
|
|
),
|
|
)
|
|
)
|
|
|
|
assert layer._node_execution_cache["exec"].process_data["workflow_agent_binding_id"] == "workflow-binding-1"
|
|
assert layer._node_execution_cache["exec"].process_data["workflow_tool_invocation_id"] == "tool-call-1"
|
|
|
|
def test_get_node_execution_raises_for_missing(self):
|
|
layer, _, _, _ = _make_layer()
|
|
with pytest.raises(ValueError, match="Node execution not found"):
|
|
layer._get_node_execution("missing")
|
|
|
|
def test_get_workflow_execution_raises_when_uninitialized(self):
|
|
layer, _, _, _ = _make_layer()
|
|
|
|
with pytest.raises(ValueError, match="workflow execution not initialized"):
|
|
layer._get_workflow_execution()
|