merge: integrate reviewed official Graphon 0.8 compatibility

This commit is contained in:
Charles Yao
2026-10-15 14:41:10 -07:00
210 changed files with 4259 additions and 2016 deletions
@@ -315,6 +315,10 @@ jobs:
JUNIT = "builder-confirmed-tool-units.xml"
FILES = [
"api/tests/unit_tests/core/workflow/test_graphon_v08_scope_regressions.py",
"api/tests/unit_tests/core/workflow/test_graphon_v08_contract.py",
"api/tests/unit_tests/core/workflow/context/test_execution_context.py",
"api/tests/unit_tests/core/workflow/context/test_flask_app_context.py",
"api/tests/unit_tests/services/dify_builder/test_reconcile.py",
"api/tests/unit_tests/core/app/apps/test_builder_execution_admission.py",
"api/tests/unit_tests/services/dify_builder/test_real_call_consent_service.py",
@@ -403,6 +407,93 @@ jobs:
print(f"COLLECTED {module}: {count} unit cases", flush=True)
if count == 0:
problems.append(f"missing unit module {module}")
graphon_v08_expected = {
"tests.unit_tests.core.workflow.test_graphon_v08_scope_regressions": {
"test_actual_dify_v07_envelope_resumes_exact_graph_through_entry": 4,
"test_actual_single_worker_keeps_engine_adapter_for_second_node": 2,
"test_background_summary_keeps_real_image_conversion_without_ambient_runtime": 1,
"test_capture_restores_worker_context_and_adapter": 8,
"test_captured_otel_context_is_reversible_on_exception": 2,
"test_dify_application_context_binds_rendering_for_request_and_background": 2,
"test_direct_host_signing_boundary_needs_no_ambient_runtime": 4,
"test_json_prevalidation_compares_original_binding_before_dto_adaptation": 2,
"test_lazy_file_response_keeps_owner_until_iteration_and_cleanup": 3,
"test_lazy_response_retains_ownership_grants_between_steps_without_leaking": 1,
"test_public_preview_checks_real_signature_without_ambient_adapter": 2,
"test_request_rendering_and_teardown_restore_none_even_on_failure": 1,
"test_valid_public_preview_retains_normal_delivery_without_ambient_adapter": 2
},
"tests.unit_tests.core.workflow.test_graphon_v08_contract": {
"test_explicit_file_scope_is_reentrant_and_restores_previous_adapter": 1,
"test_historical_v1_filter_binds_original_edge_position_to_public_id": 1,
"test_historical_v1_response_filter_bytes_are_accepted_without_rewriting": 1,
"test_historical_v2_loop_snapshot_resumes_with_dify_scoped_factory": 2,
"test_host_context_does_not_replace_engine_file_adapter_on_any_segment": 1,
"test_legacy_v1_child_tasks_fail_closed_without_fabricating_frames": 1,
"test_nested_ancestry_uses_actual_direct_container_and_retains_occurrence_id": 1,
"test_ordinary_workflow_uses_official_engine_without_external_io": 1,
"test_pause_barrier_flushes_before_persisting_and_exposing_pause": 1,
"test_pause_barrier_storage_failure_never_exposes_resumable_pause": 1,
"test_pause_event_defers_snapshot_until_outer_filter_flush": 1,
"test_real_engine_pause_snapshot_follows_quiescence_and_outer_filter_flush": 1,
"test_scoped_factory_validation_never_initializes_collaborators": 1,
"test_single_node_execution_binds_an_explicit_file_adapter": 1,
"test_stop_queue_preserves_frame_and_invocation_tasks": 1
},
"tests.unit_tests.core.workflow.context.test_execution_context": {
"test_builder_fluent_interface": 1,
"test_builder_with_all_params": 1,
"test_builder_with_partial_params": 1,
"test_capture_current_context_captures_contextvars": 1,
"test_capture_current_context_returns_context": 1,
"test_context_manager_protocol": 1,
"test_enter_with_app_context": 1,
"test_enter_with_context_vars": 1,
"test_enter_without_app_context": 1,
"test_execution_context_implements_protocol": 1,
"test_initialization_with_all_params": 1,
"test_initialization_with_minimal_params": 1,
"test_missing_provider_raises_keyerror": 1,
"test_null_app_context_enter_yield": 1,
"test_null_app_context_get_config": 1,
"test_null_app_context_get_config_default": 1,
"test_null_app_context_get_extension": 1,
"test_null_app_context_get_extension_not_found": 1,
"test_protocol_compatibility": 1,
"test_protocol_with_flask_execution_context": 1,
"test_tenant_provider_read_ok": 1,
"test_thread_safe_context_manager": 1,
"test_user_property": 1
},
"tests.unit_tests.core.workflow.context.test_flask_app_context": {
"test_app_context_property": 1,
"test_capture_flask_context_captures_app": 1,
"test_capture_flask_context_captures_contextvars": 1,
"test_capture_flask_context_captures_user_from_g": 1,
"test_capture_flask_context_with_explicit_user": 1,
"test_context_manager_protocol": 1,
"test_enter_enters_flask_app_context": 1,
"test_enter_method_as_context_manager": 1,
"test_enter_restores_context_vars": 1,
"test_enter_restores_user_in_g": 1,
"test_flask_app_context_enter": 1,
"test_flask_app_context_get_config": 1,
"test_flask_app_context_get_config_default": 1,
"test_flask_app_context_get_extension": 1,
"test_flask_app_context_get_extension_not_found": 1,
"test_flask_app_context_initialization": 1,
"test_initialization": 1
}
}
for module, expected in graphon_v08_expected.items():
actual = {}
for classname, name in identities:
normalized = classname.removeprefix("api.")
if normalized == module or normalized.startswith(module + "."):
function = name.split("[", 1)[0]
actual[function] = actual.get(function, 0) + 1
if actual != expected:
problems.append(f"Graphon 0.8 case inventory mismatch {module}: {actual}")
json_admission_module = "tests.unit_tests.services.dify_builder.test_json_tool_execution_admission"
expected_json_admission = {
f"test_owned_json_parse_prepare_persists_restricted_request[{variant}]"
+3 -3
View File
@@ -958,14 +958,14 @@ class WorkflowConfig(BaseSettings):
default=400_000,
)
# GraphEngine Worker Pool Configuration
# Engine Worker Pool Configuration
GRAPH_ENGINE_MIN_WORKERS: PositiveInt = Field(
description="Minimum number of workers per GraphEngine instance",
description="Minimum number of workers per Engine instance",
default=3,
)
GRAPH_ENGINE_MAX_WORKERS: PositiveInt = Field(
description="Maximum number of workers per GraphEngine instance",
description="Maximum number of workers per Engine instance",
default=10,
)
+18 -12
View File
@@ -96,28 +96,34 @@ class ExecutionContext:
@contextmanager
def enter(self) -> Generator[None]:
"""Enter this execution context."""
if self._context_vars:
for var, val in self._context_vars.items():
var.set(val)
if self._app_context is not None:
with self._app_context.enter():
tokens = []
try:
if self._context_vars:
for var, val in self._context_vars.items():
tokens.append((var, var.set(val)))
if self._app_context is not None:
with self._app_context.enter():
yield
else:
yield
else:
yield
finally:
for var, token in reversed(tokens):
var.reset(token)
def __enter__(self) -> "ExecutionContext":
"""Enter the execution context."""
cm = self.enter()
self._local.cm = cm
cm.__enter__()
if not hasattr(self._local, "stack"):
self._local.stack = []
self._local.stack.append(cm)
return self
def __exit__(self, *args: Any) -> None:
"""Exit the execution context."""
cm = getattr(self._local, "cm", None)
if cm is not None:
cm.__exit__(*args)
stack = getattr(self._local, "stack", [])
if stack:
stack.pop().__exit__(*args)
class NullAppContext(AppContext):
+11 -25
View File
@@ -10,7 +10,7 @@ from typing import Any, final, override
from flask import Flask, current_app, g
from context.execution_context import AppContext, IExecutionContext, register_context_capturer
from context.execution_context import AppContext, ExecutionContext, IExecutionContext, register_context_capturer
@final
@@ -137,37 +137,23 @@ class FlaskExecutionContext:
def __enter__(self) -> "FlaskExecutionContext":
"""Enter the Flask execution context."""
# Restore non-Flask context variables to avoid leaking Flask tokens across threads
for var, val in self._context_vars.items():
var.set(val)
# Enter Flask app context
cm = self._app_context.enter()
self._local.cm = cm
cm = self.enter()
cm.__enter__()
# Restore user in new app context
if self._user is not None:
g._login_user = self._user
if not hasattr(self._local, "stack"):
self._local.stack = []
self._local.stack.append(cm)
return self
def __exit__(self, *args: Any) -> None:
"""Exit the Flask execution context."""
cm = getattr(self._local, "cm", None)
if cm is not None:
cm.__exit__(*args)
"""Exit the Flask execution context and restore the caller's variables."""
stack = getattr(self._local, "stack", [])
if stack:
stack.pop().__exit__(*args)
@contextmanager
def enter(self) -> Generator[None]:
"""Enter Flask execution context as context manager."""
# Restore non-Flask context variables to avoid leaking Flask tokens across threads
for var, val in self._context_vars.items():
var.set(val)
# Enter Flask app context
with self._flask_app.app_context():
# Restore user in new app context
"""Activate captured state reversibly for one worker segment."""
with ExecutionContext(app_context=self._app_context, context_vars=self._context_vars).enter():
if self._user is not None:
g._login_user = self._user
yield
+2 -2
View File
@@ -83,7 +83,6 @@ from fields.workflow_run_fields import (
from graphon.enums import NodeType
from graphon.file import File
from graphon.file import helpers as file_helpers
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.variables import SecretVariable, SegmentType, VariableBase
from graphon.variables.exc import VariableError
@@ -102,6 +101,7 @@ 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.command_service import send_workflow_stop_command
from services.workflow_ref_service import WorkflowRefService
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
from services.workflow_variable_reference_validator import (
@@ -1234,7 +1234,7 @@ class WorkflowTaskStopApi(Resource):
AppQueueManager.set_stop_flag_no_user_check(task_id)
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
send_workflow_stop_command(task_id=task_id, redis=redis_client)
return {"result": "success"}
@@ -57,7 +57,6 @@ from fields.workflow_run_fields import (
workflow_run_pagination_response_source,
workflow_run_response_source,
)
from graphon.graph_engine.manager import GraphEngineManager
from libs import helper
from libs.external_api import error_responses
from libs.helper import TimestampField
@@ -69,6 +68,7 @@ from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError,
from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
from services.snippet_generate_service import SnippetGenerateService
from services.snippet_service import SnippetService
from services.workflow.command_service import send_workflow_stop_command
logger = logging.getLogger(__name__)
@@ -867,6 +867,6 @@ class SnippetWorkflowTaskStopApi(Resource):
AppQueueManager.set_stop_flag_no_user_check(task_id)
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
send_workflow_stop_command(task_id=task_id, redis=redis_client)
return {"result": "success"}
+2 -2
View File
@@ -68,7 +68,6 @@ from core.errors.error import (
QuotaExceededError,
)
from extensions.ext_redis import redis_client
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from models.model import App, AppMode
@@ -82,6 +81,7 @@ from services.errors.app import (
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
)
from services.errors.llm import InvokeRateLimitError
from services.workflow.command_service import send_workflow_stop_command
logger = logging.getLogger(__name__)
@@ -375,5 +375,5 @@ class AppRunTaskStopApi(Resource):
if owner != f"end-user-{ctx.end_user.id}".encode():
raise NotFound("Task not found")
AppQueueManager.set_stop_flag_no_user_check(task_id)
GraphEngineManager(redis_client).send_stop_command(task_id)
send_workflow_stop_command(task_id=task_id, redis=redis_client)
return TaskStopResponse(result="success")
+2 -2
View File
@@ -55,7 +55,6 @@ from fields.base import ResponseModel
from fields.end_user_fields import SimpleEndUser
from fields.member_fields import SimpleAccountResponse
from graphon.enums import WorkflowExecutionStatus
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.external_api import error_responses
@@ -73,6 +72,7 @@ from services.errors.app import (
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
)
from services.errors.llm import InvokeRateLimitError
from services.workflow.command_service import send_workflow_stop_command
logger = logging.getLogger(__name__)
@@ -593,7 +593,7 @@ class WorkflowTaskStopApi(Resource):
AppQueueManager.set_stop_flag_no_user_check(task_id)
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
send_workflow_stop_command(task_id=task_id, redis=redis_client)
return SimpleResultResponse(result="success").model_dump()
+2 -2
View File
@@ -26,7 +26,6 @@ from core.errors.error import (
QuotaExceededError,
)
from extensions.ext_redis import redis_client
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.external_api import error_responses
@@ -36,6 +35,7 @@ from services.errors.app import (
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
)
from services.errors.llm import InvokeRateLimitError
from services.workflow.command_service import send_workflow_stop_command
logger = logging.getLogger(__name__)
@@ -144,6 +144,6 @@ class WorkflowTaskStopApi(WebApiResource):
AppQueueManager.set_stop_flag_no_user_check(task_id)
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
send_workflow_stop_command(task_id=task_id, redis=redis_client)
return SimpleResultResponse(result="success").model_dump(mode="json")
@@ -48,10 +48,10 @@ from core.repositories import DifyCoreRepositoryFactory
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from extensions.ext_database import db
from factories import file_factory
from graphon.filters import ResponseStreamFilter
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.engine.filter import ResponseStreamFilter
from graphon.engine.layer import Layer
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader
from libs.flask_utils import preserve_flask_contexts
from models import Account, App, Conversation, EndUser, Message, Workflow, WorkflowNodeExecutionTriggeredFrom
@@ -281,7 +281,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
application_generate_entity: AdvancedChatAppGenerateEntity,
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
pause_state_config: PauseStateLayerConfig | None = None,
response_stream_filter: ResponseStreamFilter | None = None,
):
@@ -312,7 +312,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
message=message,
stream=application_generate_entity.stream,
pause_state_config=pause_state_config,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
response_stream_filter=response_stream_filter,
session=session,
)
@@ -520,8 +520,8 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
stream: bool = True,
variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
pause_state_config: PauseStateLayerConfig | None = None,
graph_runtime_state: GraphRuntimeState | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
runtime_state: RuntimeState | None = None,
graph_engine_layers: Sequence[Layer] = (),
response_stream_filter: ResponseStreamFilter | None = None,
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]:
"""
@@ -574,7 +574,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
message_id=message.id,
)
graph_layers: list[GraphEngineLayer] = list(graph_engine_layers)
graph_layers: list[Layer] = list(graph_engine_layers)
resolved_response_stream_filter = response_stream_filter or ResponseStreamFilter()
if pause_state_config is not None:
graph_layers.append(
@@ -602,7 +602,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
"workflow_execution_repository": workflow_execution_repository,
"workflow_node_execution_repository": workflow_node_execution_repository,
"graph_engine_layers": tuple(graph_layers),
"graph_runtime_state": graph_runtime_state,
"runtime_state": runtime_state,
"response_stream_filter": resolved_response_stream_filter,
},
)
@@ -656,8 +656,8 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
variable_loader: VariableLoader,
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_runtime_state: GraphRuntimeState | None = None,
graph_engine_layers: Sequence[Layer] = (),
runtime_state: RuntimeState | None = None,
response_stream_filter: ResponseStreamFilter | None = None,
):
"""
@@ -685,7 +685,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
)
if workflow is None:
raise ValueError("Workflow not found")
if graph_runtime_state is not None:
if runtime_state is not None:
self._restore_workflow_run_graph(
session=session,
workflow=workflow,
@@ -723,7 +723,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
graph_engine_layers=graph_engine_layers,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
response_stream_filter=response_stream_filter,
)
+22 -20
View File
@@ -46,11 +46,11 @@ from core.workflow.workflow_entry import WorkflowEntry
from extensions.ext_redis import redis_client
from extensions.otel import WorkflowAppRunnerHandler, trace_span
from extensions.workflow_warm_shutdown import WORKFLOW_WARM_SHUTDOWN_ABORT_REASON, celery_warm_shutdown_started
from graphon.engine.command import RedisChannel
from graphon.engine.filter import ResponseStreamFilter
from graphon.engine.layer import Layer
from graphon.enums import WorkflowType
from graphon.filters import ResponseStreamFilter
from graphon.graph_engine.command_channels import RedisChannel
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from graphon.variable_loader import VariableLoader
from graphon.variables.variables import Variable
from models import Workflow
@@ -80,8 +80,8 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
app: App,
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_runtime_state: GraphRuntimeState | None = None,
graph_engine_layers: Sequence[Layer] = (),
runtime_state: RuntimeState | None = None,
response_stream_filter: ResponseStreamFilter | None = None,
):
super().__init__(
@@ -99,7 +99,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
self._app = app
self._workflow_execution_repository = workflow_execution_repository
self._workflow_node_execution_repository = workflow_node_execution_repository
self._resume_graph_runtime_state = graph_runtime_state
self._resume_graph_runtime_state = runtime_state
self._response_stream_filter = response_stream_filter
@trace_span(WorkflowAppRunnerHandler)
@@ -132,11 +132,11 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
resume_state = self._resume_graph_runtime_state
if resume_state is not None:
graph_runtime_state = resume_state
variable_pool = graph_runtime_state.variable_pool
runtime_state = resume_state
variable_pool = runtime_state.variable_pool
graph = self._init_graph(
graph_config=self._workflow.graph_dict,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
workflow_id=self._workflow.id,
tenant_id=self._workflow.tenant_id,
user_id=self.application_generate_entity.user_id,
@@ -147,7 +147,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
)
elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
# Handle single iteration or single loop run
graph, variable_pool, graph_runtime_state = self._prepare_single_node_execution(
graph, variable_pool, runtime_state = self._prepare_single_node_execution(
workflow=self._workflow,
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
@@ -213,10 +213,12 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=new_inputs)
# init graph
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.time())
runtime_state = RuntimeState(
workflow_id=self._workflow.id, variable_pool=variable_pool, start_at=time.time()
)
graph = self._init_graph(
graph_config=self._workflow.graph_dict,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
workflow_id=self._workflow.id,
tenant_id=self._workflow.tenant_id,
user_id=self.application_generate_entity.user_id,
@@ -235,7 +237,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
shutdown_state_getter=celery_warm_shutdown_started,
abort_reason=WORKFLOW_WARM_SHUTDOWN_ABORT_REASON,
)
attach_stop_aware_ready_queue(graph_runtime_state, task_id=task_id)
attach_stop_aware_ready_queue(runtime_state, task_id=task_id)
command_channel = CombinedCommandChannel(
(
RedisChannel(redis_client, channel_key),
@@ -255,12 +257,12 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
invoke_from=invoke_from,
call_depth=self.application_generate_entity.call_depth,
variable_pool=variable_pool,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
command_channel=command_channel,
response_stream_filter=self._response_stream_filter,
)
self._queue_manager.graph_runtime_state = graph_runtime_state
self._queue_manager.runtime_state = runtime_state
persistence_layer = WorkflowPersistenceLayer(
application_generate_entity=self.application_generate_entity,
@@ -275,8 +277,8 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
trace_manager=self.application_generate_entity.trace_manager,
)
workflow_entry.graph_engine.layer(persistence_layer)
workflow_entry.graph_engine.layer(
workflow_entry.add_layer(persistence_layer)
workflow_entry.add_layer(
build_workflow_agent_workspace_retirement_layer(
dify_run_context=DifyRunContext(
tenant_id=self._workflow.tenant_id,
@@ -292,9 +294,9 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
conversation_variable_layer = ConversationVariablePersistenceLayer(
ConversationVariableUpdater(session_factory.get_session_maker())
)
workflow_entry.graph_engine.layer(conversation_variable_layer)
workflow_entry.add_layer(conversation_variable_layer)
for layer in self._graph_engine_layers:
workflow_entry.graph_engine.layer(layer)
workflow_entry.add_layer(layer)
generator = workflow_entry.run()
for event in generator:
@@ -82,7 +82,7 @@ from graphon.enums import WorkflowExecutionStatus
from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.nodes import BuiltinNodeTypes
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
from libs.datetime_utils import naive_utc_now
from models import Account, Conversation, EndUser, Message, MessageFile
from models.enums import CreatorUserRole, MessageFileBelongsTo, MessageStatus
@@ -209,7 +209,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
self._recorded_files: list[Mapping[str, Any]] = []
self._workflow_run_id: str = ""
self._draft_var_saver_factory = draft_var_saver_factory
self._graph_runtime_state: GraphRuntimeState | None = None
self._graph_runtime_state: RuntimeState | None = None
self._message_saved_on_pause = False
self._seed_graph_runtime_state_from_queue_manager()
@@ -667,11 +667,11 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
task_id=self._application_generate_entity.task_id,
workflow_id=self._workflow_id,
status=WorkflowExecutionStatus.SUCCEEDED,
graph_runtime_state=validated_state,
runtime_state=validated_state,
)
yield from self._handle_advanced_chat_message_end_event(
QueueAdvancedChatMessageEndEvent(), graph_runtime_state=validated_state
QueueAdvancedChatMessageEndEvent(), runtime_state=validated_state
)
yield workflow_finish_resp
@@ -690,12 +690,12 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
task_id=self._application_generate_entity.task_id,
workflow_id=self._workflow_id,
status=WorkflowExecutionStatus.PARTIAL_SUCCEEDED,
graph_runtime_state=validated_state,
runtime_state=validated_state,
exceptions_count=event.exceptions_count,
)
yield from self._handle_advanced_chat_message_end_event(
QueueAdvancedChatMessageEndEvent(), graph_runtime_state=validated_state
QueueAdvancedChatMessageEndEvent(), runtime_state=validated_state
)
yield workflow_finish_resp
@@ -709,20 +709,20 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
responses = self._workflow_response_converter.workflow_pause_to_stream_response(
event=event,
task_id=self._application_generate_entity.task_id,
graph_runtime_state=validated_state,
runtime_state=validated_state,
)
for reason in event.reasons:
if isinstance(reason, HumanInputRequired):
self._persist_human_input_extra_content(form_id=reason.form_id, node_id=reason.node_id)
yield from responses
resolved_state: GraphRuntimeState | None = None
resolved_state: RuntimeState | None = None
try:
resolved_state = self._ensure_graph_runtime_initialized()
except ValueError:
resolved_state = None
with self._database_session() as session:
self._save_message(session=session, graph_runtime_state=resolved_state)
self._save_message(session=session, runtime_state=resolved_state)
message = self._get_message(session=session)
if message is not None:
message.status = MessageStatus.PAUSED
@@ -745,7 +745,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
task_id=self._application_generate_entity.task_id,
workflow_id=self._workflow_id,
status=WorkflowExecutionStatus.FAILED,
graph_runtime_state=validated_state,
runtime_state=validated_state,
error=event.error,
exceptions_count=event.exceptions_count,
)
@@ -761,7 +761,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
self,
event: QueueStopEvent,
*,
graph_runtime_state: GraphRuntimeState | None = None,
runtime_state: RuntimeState | None = None,
trace_manager: TraceQueueManager | None = None,
**kwargs,
) -> Generator[StreamResponse]:
@@ -769,20 +769,20 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
_ = trace_manager
resolved_state = None
if self._workflow_run_id:
resolved_state = self._resolve_graph_runtime_state(graph_runtime_state)
resolved_state = self._resolve_graph_runtime_state(runtime_state)
if self._workflow_run_id and resolved_state:
workflow_finish_resp = self._workflow_response_converter.workflow_finish_to_stream_response(
task_id=self._application_generate_entity.task_id,
workflow_id=self._workflow_id,
status=WorkflowExecutionStatus.STOPPED,
graph_runtime_state=resolved_state,
runtime_state=resolved_state,
error=event.get_stop_reason(),
)
with self._database_session() as session:
# Save message
self._save_message(session=session, graph_runtime_state=resolved_state)
self._save_message(session=session, runtime_state=resolved_state)
self._emit_message_trace()
yield workflow_finish_resp
@@ -802,11 +802,11 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
self,
event: QueueAdvancedChatMessageEndEvent,
*,
graph_runtime_state: GraphRuntimeState | None = None,
runtime_state: RuntimeState | None = None,
**kwargs,
) -> Generator[StreamResponse]:
"""Handle advanced chat message end events."""
resolved_state = self._ensure_graph_runtime_initialized(graph_runtime_state)
resolved_state = self._ensure_graph_runtime_initialized(runtime_state)
output_moderation_answer = self._base_task_pipeline.handle_output_moderation_when_task_finished(
self._task_state.answer
@@ -821,7 +821,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
# Save message unless it has already been persisted on pause.
if not self._message_saved_on_pause:
with self._database_session() as session:
self._save_message(session=session, graph_runtime_state=resolved_state)
self._save_message(session=session, runtime_state=resolved_state)
self._emit_message_trace()
yield self._message_end_to_stream_response()
@@ -1024,7 +1024,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
break
case QueueStopEvent():
yield from self._handle_stop_event(event, graph_runtime_state=None, trace_manager=trace_manager)
yield from self._handle_stop_event(event, runtime_state=None, trace_manager=trace_manager)
break
# Handle all other events through elegant dispatch
@@ -1042,7 +1042,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if self._conversation_name_generate_thread:
logger.debug("Conversation name generation running as daemon thread")
def _save_message(self, *, session: Session, graph_runtime_state: GraphRuntimeState | None = None):
def _save_message(self, *, session: Session, runtime_state: RuntimeState | None = None):
message = self._get_message(session=session)
if message is None:
return
@@ -1057,8 +1057,8 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
message.provider_response_latency = time.perf_counter() - self._base_task_pipeline.start_at
# Set usage first before dumping metadata
if graph_runtime_state and graph_runtime_state.llm_usage:
usage = graph_runtime_state.llm_usage
if runtime_state and runtime_state.llm_usage:
usage = runtime_state.llm_usage
message.message_tokens = usage.prompt_tokens
message.message_unit_price = usage.prompt_unit_price
message.message_price_unit = usage.prompt_price_unit
@@ -1115,7 +1115,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
def _seed_graph_runtime_state_from_queue_manager(self) -> None:
"""Bootstrap the cached runtime state from the queue manager when present."""
candidate = self._base_task_pipeline.queue_manager.graph_runtime_state
candidate = self._base_task_pipeline.queue_manager.runtime_state
if candidate is not None:
self._graph_runtime_state = candidate
+51 -22
View File
@@ -1,7 +1,7 @@
import logging
import threading
from collections.abc import Generator, Mapping, Sequence
from contextlib import AbstractContextManager, nullcontext
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Union, final
from sqlalchemy.orm import Session
@@ -13,7 +13,12 @@ from core.app.apps.draft_variable_saver import (
NoopDraftVariableSaver,
)
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access import DatabaseFileAccessController, FileAccessScope, bind_file_access_scope
from core.app.file_access import (
DatabaseFileAccessController,
FileAccessScope,
bind_file_access_scope,
get_current_file_access_scope,
)
from extensions.ext_database import db
from factories import file_factory
from graphon.enums import NodeType
@@ -97,34 +102,58 @@ class BaseAppGenerator:
response_stream: Generator[ResponseT],
worker_thread: threading.Thread,
) -> Generator[ResponseT]:
"""Keep the producer owned by the response stream until both finish."""
try:
yield from response_stream
finally:
BaseAppGenerator._join_worker_thread(worker_thread)
"""Capture rendering ownership now; restore it for every lazy step and close."""
from graphon.file.runtime import peek_workflow_file_runtime, use_workflow_file_runtime
file_runtime = peek_workflow_file_runtime()
file_scope = get_current_file_access_scope()
def consume() -> Generator[ResponseT]:
nonlocal file_scope
try:
while True:
with use_workflow_file_runtime(file_runtime), bind_file_access_scope(file_scope):
try:
item = next(response_stream)
file_scope = get_current_file_access_scope()
except StopIteration:
return
yield item
finally:
try:
with use_workflow_file_runtime(file_runtime), bind_file_access_scope(file_scope):
response_stream.close()
finally:
BaseAppGenerator._join_worker_thread(worker_thread)
return consume()
@staticmethod
@contextmanager
def _bind_file_access_scope(
*,
tenant_id: str,
user: Account | EndUser,
invoke_from: InvokeFrom,
) -> AbstractContextManager[None]:
"""Bind request-scoped file ownership markers for downstream file lookups."""
) -> Generator[None]:
"""Bind the file adapter and request ownership for downstream file lookups."""
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
user_id = getattr(user, "id", None)
if not isinstance(user_id, str) or not user_id:
return nullcontext()
user_from = UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER
return bind_file_access_scope(
FileAccessScope(
tenant_id=tenant_id,
user_id=user_id,
user_from=user_from,
invoke_from=invoke_from,
)
)
with bind_dify_workflow_file_runtime():
user_id = getattr(user, "id", None)
if not isinstance(user_id, str) or not user_id:
yield
return
user_from = UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER
with bind_file_access_scope(
FileAccessScope(
tenant_id=tenant_id,
user_id=user_id,
user_from=user_from,
invoke_from=invoke_from,
)
):
yield
def _prepare_user_inputs(
self,
+6 -6
View File
@@ -26,7 +26,7 @@ from core.app.entities.queue_entities import (
WorkflowQueueMessage,
)
from extensions.ext_redis import RedisClientWrapper, redis_client
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
logger = logging.getLogger(__name__)
@@ -64,7 +64,7 @@ class AppQueueManager(ABC):
q: queue.Queue[WorkflowQueueMessage | MessageQueueMessage | None] = queue.Queue()
self._q = q
self._graph_runtime_state: GraphRuntimeState | None = None
self._graph_runtime_state: RuntimeState | None = None
self._stopped_cache: TTLCache[tuple, bool] = TTLCache(maxsize=1, ttl=1)
self._cache_lock = threading.Lock()
self._listener_segment_completed = threading.Event()
@@ -152,14 +152,14 @@ class AppQueueManager(ABC):
self.publish(QueueErrorEvent(error=e), pub_from)
@property
def graph_runtime_state(self) -> GraphRuntimeState | None:
def runtime_state(self) -> RuntimeState | None:
"""Retrieve the attached graph runtime state, if available."""
return self._graph_runtime_state
@graph_runtime_state.setter
def graph_runtime_state(self, graph_runtime_state: GraphRuntimeState | None) -> None:
@runtime_state.setter
def runtime_state(self, runtime_state: RuntimeState | None) -> None:
"""Attach the live graph runtime state reference for downstream consumers."""
self._graph_runtime_state = graph_runtime_state
self._graph_runtime_state = runtime_state
def publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None:
"""
@@ -1,11 +1,11 @@
"""Shared helpers for managing GraphRuntimeState across task pipelines."""
"""Shared helpers for managing RuntimeState across task pipelines."""
from __future__ import annotations
from typing import TYPE_CHECKING
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
if TYPE_CHECKING:
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
class GraphRuntimeStateSupport:
"""
Mixin that centralises common GraphRuntimeState access patterns used by task pipelines.
Mixin that centralises common RuntimeState access patterns used by task pipelines.
Subclasses are expected to provide:
* `_base_task_pipeline` – exposing the queue manager with an optional cached runtime state.
@@ -21,32 +21,32 @@ class GraphRuntimeStateSupport:
"""
_base_task_pipeline: BasedGenerateTaskPipeline
_graph_runtime_state: GraphRuntimeState | None = None
_graph_runtime_state: RuntimeState | None = None
def _ensure_graph_runtime_initialized(
self,
graph_runtime_state: GraphRuntimeState | None = None,
) -> GraphRuntimeState:
runtime_state: RuntimeState | None = None,
) -> RuntimeState:
"""Validate and return the active graph runtime state."""
return self._resolve_graph_runtime_state(graph_runtime_state)
return self._resolve_graph_runtime_state(runtime_state)
def _extract_workflow_run_id(self, graph_runtime_state: GraphRuntimeState) -> str:
workflow_run_id = get_system_text(graph_runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID)
def _extract_workflow_run_id(self, runtime_state: RuntimeState) -> str:
workflow_run_id = get_system_text(runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID)
if not workflow_run_id:
raise ValueError("workflow_execution_id missing from runtime state")
return workflow_run_id
def _resolve_graph_runtime_state(
self,
graph_runtime_state: GraphRuntimeState | None = None,
) -> GraphRuntimeState:
runtime_state: RuntimeState | None = None,
) -> RuntimeState:
"""Return the cached runtime state or bootstrap it from the queue manager."""
if graph_runtime_state is not None:
self._graph_runtime_state = graph_runtime_state
return graph_runtime_state
if runtime_state is not None:
self._graph_runtime_state = runtime_state
return runtime_state
if self._graph_runtime_state is None:
candidate = self._base_task_pipeline.queue_manager.graph_runtime_state
candidate = self._base_task_pipeline.queue_manager.runtime_state
if candidate is not None:
self._graph_runtime_state = candidate
@@ -72,7 +72,7 @@ from graphon.enums import (
WorkflowNodeExecutionStatus,
)
from graphon.file import FILE_MODEL_IDENTITY, File
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment
from graphon.variables.variables import Variable
from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter
@@ -264,7 +264,7 @@ class WorkflowResponseConverter:
task_id: str,
workflow_id: str,
status: WorkflowExecutionStatus,
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
error: str | None = None,
exceptions_count: int = 0,
) -> WorkflowFinishStreamResponse:
@@ -278,7 +278,7 @@ class WorkflowResponseConverter:
finished_at = naive_utc_now()
elapsed_time = (finished_at - started_at).total_seconds()
outputs_mapping = graph_runtime_state.outputs or {}
outputs_mapping = runtime_state.outputs or {}
encoded_outputs = WorkflowRuntimeTypeConverter().to_json_encodable(outputs_mapping)
created_by: CreatedByDict | dict[str, object] = {}
@@ -306,8 +306,8 @@ class WorkflowResponseConverter:
outputs=encoded_outputs,
error=error,
elapsed_time=elapsed_time,
total_tokens=graph_runtime_state.total_tokens,
total_steps=graph_runtime_state.node_run_steps,
total_tokens=runtime_state.total_tokens,
total_steps=runtime_state.node_run_steps,
created_by=created_by,
created_at=int(started_at.timestamp()),
finished_at=int(finished_at.timestamp()),
@@ -321,7 +321,7 @@ class WorkflowResponseConverter:
*,
event: QueueWorkflowPausedEvent,
task_id: str,
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
) -> list[StreamResponse]:
run_id = self._ensure_workflow_run_id()
started_at = self._workflow_started_at
@@ -334,7 +334,7 @@ class WorkflowResponseConverter:
encoded_outputs = self._encode_outputs(event.outputs) or {}
if self._application_generate_entity.invoke_from == InvokeFrom.SERVICE_API:
encoded_outputs = {}
variable_pool = graph_runtime_state.variable_pool
variable_pool = runtime_state.variable_pool
resolved_reasons = resolve_human_input_pause_reason_inputs(
event.reasons,
variable_pool=variable_pool,
@@ -416,8 +416,8 @@ class WorkflowResponseConverter:
status=WorkflowExecutionStatus.PAUSED,
created_at=int(started_at.timestamp()),
elapsed_time=elapsed_time,
total_tokens=graph_runtime_state.total_tokens,
total_steps=graph_runtime_state.node_run_steps,
total_tokens=runtime_state.total_tokens,
total_steps=runtime_state.node_run_steps,
),
)
)
+5 -6
View File
@@ -8,8 +8,7 @@ from enum import Enum, auto
from configs import dify_config
from extensions.ext_redis import RedisClientWrapper, redis_client
from graphon.graph_engine.command_channels import RedisChannel
from graphon.graph_engine.manager import GraphEngineManager
from graphon.engine.command import AbortCommand, RedisChannel
logger = logging.getLogger(__name__)
@@ -22,7 +21,7 @@ class AppExecutionState(Enum):
def app_task_command_channel_key(task_id: str) -> str:
"""Redis key of the GraphEngine command channel for one app task."""
"""Redis key of the Engine command channel for one app task."""
return f"workflow:{task_id}:commands"
@@ -73,12 +72,12 @@ def clear_app_task_cancellation_signals(task_id: str) -> None:
redis_client.delete(channel_key)
if discarded:
logger.info(
"Discarded %s stale GraphEngine command(s) for app task %s",
"Discarded %s stale Engine command(s) for app task %s",
len(discarded),
task_id,
)
except Exception:
logger.exception("Failed to clear pending GraphEngine commands for app task %s", task_id)
logger.exception("Failed to clear pending Engine commands for app task %s", task_id)
class AppExecutionCoordinator:
@@ -213,7 +212,7 @@ class AppExecutionCoordinator:
)
try:
GraphEngineManager(redis_client).send_stop_command(self._task_id, reason=reason)
RedisChannel(redis_client, f"workflow:{self._task_id}:commands").send_command(AbortCommand(reason=reason))
except Exception:
logger.exception(
"Failed to send stop command for app execution task=%s attempt=%s",
+13 -11
View File
@@ -22,10 +22,10 @@ from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory, ge
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
from core.workflow.workflow_entry import WorkflowEntry
from graphon.engine_events import EngineEvent, GraphRunFailedEvent
from graphon.enums import WorkflowType
from graphon.graph import Graph
from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from graphon.variable_loader import VariableLoader
from graphon.variables.variables import RAGPipelineVariable, RAGPipelineVariableInput
from models.dataset import Dataset, Pipeline
@@ -145,7 +145,7 @@ class PipelineRunner(WorkflowBasedAppRunner):
# if only single iteration run is requested
if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
# Handle single iteration or single loop run
graph, variable_pool, graph_runtime_state = self._prepare_single_node_execution(
graph, variable_pool, runtime_state = self._prepare_single_node_execution(
workflow=workflow,
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
@@ -199,11 +199,13 @@ class PipelineRunner(WorkflowBasedAppRunner):
workflow.graph_dict
)
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=inputs)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
runtime_state = RuntimeState(
workflow_id=workflow.id, variable_pool=variable_pool, start_at=time.perf_counter()
)
# init graph
graph = self._init_rag_pipeline_graph(
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
start_node_id=root_node_id,
workflow=workflow,
user_from=user_from,
@@ -221,11 +223,11 @@ class PipelineRunner(WorkflowBasedAppRunner):
user_from=user_from,
invoke_from=invoke_from,
call_depth=self.application_generate_entity.call_depth,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
variable_pool=variable_pool,
)
self._queue_manager.graph_runtime_state = graph_runtime_state
self._queue_manager.runtime_state = runtime_state
persistence_layer = WorkflowPersistenceLayer(
application_generate_entity=self.application_generate_entity,
@@ -240,7 +242,7 @@ class PipelineRunner(WorkflowBasedAppRunner):
trace_manager=self.application_generate_entity.trace_manager,
)
workflow_entry.graph_engine.layer(persistence_layer)
workflow_entry.add_layer(persistence_layer)
generator = workflow_entry.run()
@@ -270,7 +272,7 @@ class PipelineRunner(WorkflowBasedAppRunner):
def _init_rag_pipeline_graph(
self,
workflow: Workflow,
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
start_node_id: str | None = None,
user_from: UserFrom = UserFrom.ACCOUNT,
invoke_from: InvokeFrom = InvokeFrom.SERVICE_API,
@@ -327,7 +329,7 @@ class PipelineRunner(WorkflowBasedAppRunner):
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
)
if start_node_id is None:
start_node_id = get_default_root_node_id(graph_config)
@@ -340,7 +342,7 @@ class PipelineRunner(WorkflowBasedAppRunner):
def _update_document_status(
self,
event: GraphEngineEvent,
event: EngineEvent,
*,
workspace_id: str,
dataset_id: str,
+19 -19
View File
@@ -53,11 +53,11 @@ from core.trigger.constants import is_trigger_node_type
from core.workflow.node_factory import get_default_root_node_id
from extensions.ext_database import db
from factories import file_factory
from graphon.engine.filter import ResponseStreamFilter
from graphon.engine.layer import Layer
from graphon.enums import NodeType
from graphon.filters import ResponseStreamFilter
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader
from libs.flask_utils import preserve_flask_contexts
from models.account import Account
@@ -160,7 +160,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_run_id: str | uuid.UUID | None = None,
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_engine_layers: Sequence[Layer] = (),
pause_state_config: PauseStateLayerConfig | None = None,
builder_execution: BuilderExecutionContext
| ConfirmedToolExecutionContext
@@ -186,7 +186,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_run_id: str | uuid.UUID | None = None,
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_engine_layers: Sequence[Layer] = (),
pause_state_config: PauseStateLayerConfig | None = None,
builder_execution: BuilderExecutionContext
| ConfirmedToolExecutionContext
@@ -212,7 +212,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_run_id: str | uuid.UUID | None = None,
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_engine_layers: Sequence[Layer] = (),
pause_state_config: PauseStateLayerConfig | None = None,
builder_execution: BuilderExecutionContext
| ConfirmedToolExecutionContext
@@ -237,7 +237,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_run_id: str | uuid.UUID | None = None,
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_engine_layers: Sequence[Layer] = (),
pause_state_config: PauseStateLayerConfig | None = None,
builder_execution: BuilderExecutionContext
| ConfirmedToolExecutionContext
@@ -434,10 +434,10 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow: Workflow,
user: Account | EndUser,
application_generate_entity: WorkflowAppGenerateEntity,
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_engine_layers: Sequence[Layer] = (),
pause_state_config: PauseStateLayerConfig | None = None,
variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
response_stream_filter: ResponseStreamFilter | None = None,
@@ -475,7 +475,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
streaming=application_generate_entity.stream,
variable_loader=variable_loader,
graph_engine_layers=graph_engine_layers,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
pause_state_config=pause_state_config,
response_stream_filter=response_stream_filter,
)
@@ -493,8 +493,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
streaming: bool = True,
variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_runtime_state: GraphRuntimeState | None = None,
graph_engine_layers: Sequence[Layer] = (),
runtime_state: RuntimeState | None = None,
pause_state_config: PauseStateLayerConfig | None = None,
response_stream_filter: ResponseStreamFilter | None = None,
builder_execution_admit: BuilderExecutionAdmission | None = None,
@@ -517,7 +517,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
user=user,
invoke_from=invoke_from,
):
graph_layers: list[GraphEngineLayer] = list(graph_engine_layers)
graph_layers: list[Layer] = list(graph_engine_layers)
# init queue manager
queue_manager = WorkflowAppQueueManager(
@@ -556,7 +556,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
"workflow_execution_repository": workflow_execution_repository,
"workflow_node_execution_repository": workflow_node_execution_repository,
"graph_engine_layers": tuple(graph_layers),
"graph_runtime_state": graph_runtime_state,
"runtime_state": runtime_state,
"response_stream_filter": resolved_response_stream_filter,
"builder_execution_admit": builder_execution_admit,
"native_runtime": native_runtime,
@@ -795,8 +795,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_runtime_state: GraphRuntimeState | None = None,
graph_engine_layers: Sequence[Layer] = (),
runtime_state: RuntimeState | None = None,
response_stream_filter: ResponseStreamFilter | None = None,
builder_execution_admit: BuilderExecutionAdmission | None = None,
native_runtime: NativeRuntimeBridge | None = None,
@@ -883,7 +883,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
app,
application_generate_entity,
root_node_id or get_default_root_node_id(workflow.graph_dict),
graph_runtime_state is not None,
runtime_state is not None,
)
if execution_recorder is None or not execution_recorder.healthy:
raise BuilderExecutionPolicyError("missing_execution_recorder")
@@ -894,7 +894,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
else:
workflow = self._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
if graph_runtime_state is not None and native_context is None:
if runtime_state is not None and native_context is None:
self._restore_workflow_run_graph(
session=session,
workflow=workflow,
@@ -928,7 +928,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_node_execution_repository=workflow_node_execution_repository,
root_node_id=root_node_id,
graph_engine_layers=graph_engine_layers,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
response_stream_filter=response_stream_filter,
execution_recorder=execution_recorder,
)
+21 -19
View File
@@ -38,11 +38,11 @@ from core.workflow.workflow_entry import WorkflowEntry
from extensions.ext_redis import redis_client
from extensions.otel import WorkflowAppRunnerHandler, trace_span
from extensions.workflow_warm_shutdown import WORKFLOW_WARM_SHUTDOWN_ABORT_REASON, celery_warm_shutdown_started
from graphon.engine.command import RedisChannel
from graphon.engine.filter import ResponseStreamFilter
from graphon.engine.layer import Layer
from graphon.enums import WorkflowType
from graphon.filters import ResponseStreamFilter
from graphon.graph_engine.command_channels import RedisChannel
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from graphon.variable_loader import VariableLoader
from libs.datetime_utils import naive_utc_now
from models.workflow import Workflow
@@ -68,8 +68,8 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
root_node_id: str | None = None,
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_runtime_state: GraphRuntimeState | None = None,
graph_engine_layers: Sequence[Layer] = (),
runtime_state: RuntimeState | None = None,
response_stream_filter: ResponseStreamFilter | None = None,
execution_recorder: BuilderExecutionRecorder | None = None,
native_runtime: NativeRuntimeBridge | None = None,
@@ -88,7 +88,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
self._root_node_id = root_node_id
self._workflow_execution_repository = workflow_execution_repository
self._workflow_node_execution_repository = workflow_node_execution_repository
self._resume_graph_runtime_state = graph_runtime_state
self._resume_graph_runtime_state = runtime_state
self._response_stream_filter = response_stream_filter
@trace_span(WorkflowAppRunnerHandler)
@@ -185,8 +185,8 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
resume_state = self._resume_graph_runtime_state
if resume_state is not None:
graph_runtime_state = resume_state
variable_pool = graph_runtime_state.variable_pool
runtime_state = resume_state
variable_pool = runtime_state.variable_pool
graph = self._init_graph(
builder_execution=entity.builder_execution,
native_test_required=entity.native_test_required,
@@ -195,7 +195,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
native_call_depth=entity.call_depth if entity.native_test_ref is not None else 0,
execution_recorder=self._execution_recorder,
graph_config=self._workflow.graph_dict,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
workflow_id=self._workflow.id,
tenant_id=self._workflow.tenant_id,
user_id=self.application_generate_entity.user_id,
@@ -206,7 +206,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
graph, variable_pool, graph_runtime_state = self._prepare_single_node_execution(
graph, variable_pool, runtime_state = self._prepare_single_node_execution(
workflow=self._workflow,
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
@@ -249,7 +249,9 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
),
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
runtime_state = RuntimeState(
workflow_id=self._workflow.id, variable_pool=variable_pool, start_at=time.perf_counter()
)
graph = self._init_graph(
builder_execution=entity.builder_execution,
native_test_required=entity.native_test_required,
@@ -258,7 +260,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
native_call_depth=entity.call_depth if entity.native_test_ref is not None else 0,
execution_recorder=self._execution_recorder,
graph_config=self._workflow.graph_dict,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
workflow_id=self._workflow.id,
tenant_id=self._workflow.tenant_id,
user_id=self.application_generate_entity.user_id,
@@ -277,7 +279,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
shutdown_state_getter=celery_warm_shutdown_started,
abort_reason=WORKFLOW_WARM_SHUTDOWN_ABORT_REASON,
)
attach_stop_aware_ready_queue(graph_runtime_state, task_id=task_id)
attach_stop_aware_ready_queue(runtime_state, task_id=task_id)
command_channel = CombinedCommandChannel(
(
RedisChannel(redis_client, channel_key),
@@ -286,7 +288,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
)
)
self._queue_manager.graph_runtime_state = graph_runtime_state
self._queue_manager.runtime_state = runtime_state
workflow_entry = WorkflowEntry(
tenant_id=self._workflow.tenant_id,
@@ -299,7 +301,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
invoke_from=invoke_from,
call_depth=self.application_generate_entity.call_depth,
variable_pool=variable_pool,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
command_channel=command_channel,
response_stream_filter=self._response_stream_filter,
)
@@ -317,8 +319,8 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
trace_manager=self.application_generate_entity.trace_manager,
)
workflow_entry.graph_engine.layer(persistence_layer)
workflow_entry.graph_engine.layer(
workflow_entry.add_layer(persistence_layer)
workflow_entry.add_layer(
build_workflow_agent_workspace_retirement_layer(
dify_run_context=DifyRunContext(
native_test_required=entity.native_test_required,
@@ -334,7 +336,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
)
)
for layer in self._graph_engine_layers:
workflow_entry.graph_engine.layer(layer)
workflow_entry.add_layer(layer)
generator = workflow_entry.run()
+11 -12
View File
@@ -5,8 +5,7 @@ from collections.abc import Callable, Sequence
from typing import final, override
from core.app.apps.execution_coordinator import is_app_task_stop_flag_set
from graphon.graph_engine.command_channels import CommandChannel
from graphon.graph_engine.entities.commands import AbortCommand, GraphEngineCommand
from graphon.engine.command import AbortCommand, Command, CommandChannel
logger = logging.getLogger(__name__)
ShutdownStateGetter = Callable[[], bool]
@@ -23,23 +22,23 @@ class CombinedCommandChannel:
raise ValueError("command_channels must not be empty")
self._command_channels = tuple(command_channels)
def fetch_commands(self) -> list[GraphEngineCommand]:
commands: list[GraphEngineCommand] = []
def fetch_commands(self) -> list[Command]:
commands: list[Command] = []
for channel in self._command_channels:
try:
commands.extend(channel.fetch_commands())
except Exception:
logger.exception("Failed to fetch GraphEngine commands from %s", channel.__class__.__name__)
logger.exception("Failed to fetch Engine commands from %s", channel.__class__.__name__)
return commands
def send_command(self, command: GraphEngineCommand) -> None:
def send_command(self, command: Command) -> None:
"""Send commands through the first channel, which is the runner's primary command sink."""
self._command_channels[0].send_command(command)
@final
class CelerySignalCommandChannel(CommandChannel):
"""Translate process-local Celery shutdown state into one GraphEngine abort command."""
"""Translate process-local Celery shutdown state into one Engine abort command."""
_shutdown_state_getter: ShutdownStateGetter
_abort_reason: str
@@ -56,7 +55,7 @@ class CelerySignalCommandChannel(CommandChannel):
self._abort_emitted = False
@override
def fetch_commands(self) -> list[GraphEngineCommand]:
def fetch_commands(self) -> list[Command]:
if self._abort_emitted or not self._shutdown_state_getter():
return []
@@ -64,13 +63,13 @@ class CelerySignalCommandChannel(CommandChannel):
return [AbortCommand(reason=self._abort_reason)]
@override
def send_command(self, command: GraphEngineCommand) -> None:
def send_command(self, command: Command) -> None:
_ = command
@final
class StopFlagCommandChannel(CommandChannel):
"""Translate the legacy Redis stop flag into one GraphEngine abort command."""
"""Translate the legacy Redis stop flag into one Engine abort command."""
_task_id: str
_abort_reason: str
@@ -87,7 +86,7 @@ class StopFlagCommandChannel(CommandChannel):
self._abort_emitted = False
@override
def fetch_commands(self) -> list[GraphEngineCommand]:
def fetch_commands(self) -> list[Command]:
if self._abort_emitted or not is_app_task_stop_flag_set(self._task_id):
return []
@@ -95,5 +94,5 @@ class StopFlagCommandChannel(CommandChannel):
return [AbortCommand(reason=self._abort_reason)]
@override
def send_command(self, command: GraphEngineCommand) -> None:
def send_command(self, command: Command) -> None:
_ = command
@@ -64,7 +64,7 @@ from core.workflow.system_variables import build_system_variables
from extensions.ext_database import db
from graphon.entities import WorkflowStartReason
from graphon.enums import WorkflowExecutionStatus
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
from models import Account
from models.enums import CreatorUserRole
from models.model import EndUser
@@ -120,7 +120,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
user=user,
system_variables=self._workflow_system_variables,
)
self._graph_runtime_state: GraphRuntimeState | None = self._base_task_pipeline.queue_manager.graph_runtime_state
self._graph_runtime_state: RuntimeState | None = self._base_task_pipeline.queue_manager.runtime_state
def process(
self,
@@ -481,7 +481,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
task_id=self._application_generate_entity.task_id,
workflow_id=self._workflow.id,
status=WorkflowExecutionStatus.SUCCEEDED,
graph_runtime_state=validated_state,
runtime_state=validated_state,
)
yield workflow_finish_resp
@@ -501,7 +501,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
task_id=self._application_generate_entity.task_id,
workflow_id=self._workflow.id,
status=WorkflowExecutionStatus.PARTIAL_SUCCEEDED,
graph_runtime_state=validated_state,
runtime_state=validated_state,
exceptions_count=event.exceptions_count,
)
yield workflow_finish_resp
@@ -517,7 +517,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
responses = self._workflow_response_converter.workflow_pause_to_stream_response(
event=event,
task_id=self._application_generate_entity.task_id,
graph_runtime_state=validated_state,
runtime_state=validated_state,
)
yield from responses
@@ -545,7 +545,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
task_id=self._application_generate_entity.task_id,
workflow_id=self._workflow.id,
status=status,
graph_runtime_state=validated_state,
runtime_state=validated_state,
error=error,
exceptions_count=exceptions_count,
)
@@ -5,15 +5,16 @@ from __future__ import annotations
from typing import final
from core.app.apps.execution_coordinator import is_app_task_stop_flag_set
from graphon.graph_engine.ready_queue import ReadyQueue, ReadyTask
from graphon.runtime.graph_runtime_state import GraphExecutionProtocol, GraphRuntimeState
from graphon.runtime import RuntimeState
from graphon.runtime.execution import GraphExecution
from graphon.runtime.ready_queue import ReadyQueue, ReadyTask
@final
class StopAwareReadyQueue:
"""Reject newly ready nodes once the run has been stopped.
GraphEngine drain still enqueues successors after abort. Drop those puts so
Engine drain still enqueues successors after abort. Drop those puts so
later nodes do not start; the in-flight node can finish.
"""
@@ -22,7 +23,7 @@ class StopAwareReadyQueue:
inner: ReadyQueue,
*,
task_id: str,
graph_execution: GraphExecutionProtocol,
graph_execution: GraphExecution,
) -> None:
self._inner = inner
self._task_id = task_id
@@ -45,8 +46,8 @@ class StopAwareReadyQueue:
def qsize(self) -> int:
return self._inner.qsize()
def drain(self) -> list[ReadyTask]:
return self._inner.drain()
def take_all(self) -> list[ReadyTask]:
return self._inner.take_all()
def dumps(self) -> str:
return self._inner.dumps()
@@ -55,13 +56,13 @@ class StopAwareReadyQueue:
self._inner.loads(data)
def attach_stop_aware_ready_queue(graph_runtime_state: GraphRuntimeState, *, task_id: str) -> None:
def attach_stop_aware_ready_queue(runtime_state: RuntimeState, *, task_id: str) -> None:
"""Install stop-aware enqueue policy on an existing runtime state."""
current = graph_runtime_state.ready_queue
current = runtime_state.ready_queue
if isinstance(current, StopAwareReadyQueue):
return
graph_runtime_state._ready_queue = StopAwareReadyQueue(
runtime_state._ready_queue = StopAwareReadyQueue(
current,
task_id=task_id,
graph_execution=graph_runtime_state.graph_execution,
graph_execution=runtime_state.graph_execution,
)
+55 -53
View File
@@ -63,14 +63,9 @@ from core.workflow.system_variables import (
from core.workflow.variable_pool_initializer import add_variables_to_pool
from core.workflow.workflow_entry import WorkflowEntry
from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run
from graphon.entities.base_node_data import BaseNodeData
from graphon.entities.graph_config import NodeConfigDict
from graphon.entities.pause_reason import HitlRequired
from graphon.enums import BuiltinNodeTypes
from graphon.graph import Graph
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import (
GraphEngineEvent,
from graphon.engine.layer import Layer
from graphon.engine_events import (
EngineEvent,
GraphRunAbortedEvent,
GraphRunFailedEvent,
GraphRunPartialSucceededEvent,
@@ -96,7 +91,12 @@ from graphon.graph_events import (
NodeRunStreamChunkEvent,
NodeRunSucceededEvent,
)
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.entities.base_node_data import BaseNodeData
from graphon.entities.graph_config import NodeConfigDict
from graphon.entities.pause_reason import HitlRequired
from graphon.enums import BuiltinNodeTypes
from graphon.graph import Graph
from graphon.runtime import RuntimeState, VariablePool
from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool
from models.workflow import Workflow
from services.dify_builder.native_testing.contracts import NativeTestContextRef
@@ -174,7 +174,7 @@ class WorkflowBasedAppRunner:
queue_manager: AppQueueManager,
variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
app_id: str,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
graph_engine_layers: Sequence[Layer] = (),
):
self._queue_manager = queue_manager
self._variable_loader = variable_loader
@@ -190,7 +190,7 @@ class WorkflowBasedAppRunner:
def _init_graph(
self,
graph_config: Mapping[str, Any],
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
user_from: UserFrom,
invoke_from: InvokeFrom,
workflow_id: str = "",
@@ -239,13 +239,13 @@ class WorkflowBasedAppRunner:
call_depth=native_call_depth,
)
# Use the provided graph_runtime_state for consistent state management
# Use the provided runtime_state for consistent state management
node_factory = DifyNodeFactory.from_graph_init_context(
execution_recorder=execution_recorder,
native_runtime=native_runtime,
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
)
if root_node_id is None:
@@ -268,7 +268,7 @@ class WorkflowBasedAppRunner:
user_id: str,
app_type: CreditUsageAppType | None = None,
trace_session_id: str | None = None,
) -> tuple[Graph, VariablePool, GraphRuntimeState]:
) -> tuple[Graph, VariablePool, RuntimeState]:
"""
Prepare graph, variable pool, and runtime state for single node execution
(either single iteration or single loop).
@@ -279,7 +279,7 @@ class WorkflowBasedAppRunner:
single_loop_run: SingleLoopRunEntity if running single loop, None otherwise
Returns:
A tuple containing (graph, variable_pool, graph_runtime_state)
A tuple containing (graph, variable_pool, runtime_state)
Raises:
ValueError: If neither single_iteration_run nor single_loop_run is specified
@@ -293,7 +293,7 @@ class WorkflowBasedAppRunner:
environment_variables=workflow.environment_variables,
),
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.time())
runtime_state = RuntimeState(workflow_id=workflow.id, variable_pool=variable_pool, start_at=time.time())
# Determine which type of single node execution and get graph/variable_pool
if single_iteration_run:
@@ -301,7 +301,7 @@ class WorkflowBasedAppRunner:
workflow=workflow,
node_id=single_iteration_run.node_id,
user_inputs=dict(single_iteration_run.inputs),
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
node_type_filter_key="iteration_id",
node_type_label="iteration",
user_id=user_id,
@@ -313,7 +313,7 @@ class WorkflowBasedAppRunner:
workflow=workflow,
node_id=single_loop_run.node_id,
user_inputs=dict(single_loop_run.inputs),
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
node_type_filter_key="loop_id",
node_type_label="loop",
user_id=user_id,
@@ -323,16 +323,16 @@ class WorkflowBasedAppRunner:
else:
raise ValueError("Neither single_iteration_run nor single_loop_run is specified")
# Return the graph, variable_pool, and the same graph_runtime_state used during graph creation
# This ensures all nodes in the graph reference the same GraphRuntimeState instance
return graph, variable_pool, graph_runtime_state
# Return the graph, variable_pool, and the same runtime_state used during graph creation
# This ensures all nodes in the graph reference the same RuntimeState instance
return graph, variable_pool, runtime_state
def _get_graph_and_variable_pool_for_single_node_run(
self,
workflow: Workflow,
node_id: str,
user_inputs: dict[str, Any],
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
node_type_filter_key: str, # 'iteration_id' or 'loop_id'
node_type_label: str = "node", # 'iteration' or 'loop' for error messages
*,
@@ -349,7 +349,7 @@ class WorkflowBasedAppRunner:
workflow: The workflow instance
node_id: The node ID to execute
user_inputs: User inputs for the node
graph_runtime_state: The graph runtime state
runtime_state: The graph runtime state
node_type_filter_key: The key to filter nodes ('iteration_id' or 'loop_id')
node_type_label: Label for error messages ('iteration' or 'loop')
@@ -410,7 +410,7 @@ class WorkflowBasedAppRunner:
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
)
target_node_config = None
@@ -427,8 +427,8 @@ class WorkflowBasedAppRunner:
node_version = str(target_node_config.data.version)
node_cls = resolve_workflow_node_class(node_type=node_type, node_version=node_version)
# Use the variable pool from graph_runtime_state instead of creating a new one
variable_pool = graph_runtime_state.variable_pool
# Use the variable pool from runtime_state instead of creating a new one
variable_pool = runtime_state.variable_pool
preload_node_creation_variables(
variable_loader=self._variable_loader,
@@ -492,7 +492,7 @@ class WorkflowBasedAppRunner:
logger.warning("Invalid agent strategy payload for node %s", event.node_id, exc_info=True)
return None
def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent):
def _handle_event(self, workflow_entry: WorkflowEntry, event: EngineEvent):
"""
Handle event
:param workflow_entry: workflow entry
@@ -519,7 +519,7 @@ class WorkflowBasedAppRunner:
)
)
case GraphRunPausedEvent():
runtime_state = workflow_entry.graph_engine.graph_runtime_state
runtime_state = workflow_entry.graph_engine.runtime_state
paused_nodes = list(
dict.fromkeys(reason.node_id for reason in event.reasons if isinstance(reason, HitlRequired))
)
@@ -575,8 +575,8 @@ class WorkflowBasedAppRunner:
node_title=event.node_title,
node_type=event.node_type,
start_at=event.start_at,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
in_iteration_id=workflow_entry.event_ancestry(event).iteration_id,
in_loop_id=workflow_entry.event_ancestry(event).loop_id,
inputs=inputs,
process_data=process_data,
outputs=outputs,
@@ -595,8 +595,8 @@ class WorkflowBasedAppRunner:
node_title=event.node_title,
node_type=event.node_type,
start_at=event.start_at,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
in_iteration_id=workflow_entry.event_ancestry(event).iteration_id,
in_loop_id=workflow_entry.event_ancestry(event).loop_id,
agent_strategy=self._build_agent_strategy_info(event),
provider_type=event.provider_type,
provider_id=event.provider_id,
@@ -612,15 +612,17 @@ class WorkflowBasedAppRunner:
outputs=node_run_result.outputs,
)
execution_metadata = node_run_result.metadata
if event.node_type == BuiltinNodeTypes.ANSWER and (event.in_iteration_id or event.in_loop_id):
if event.node_type == BuiltinNodeTypes.ANSWER and (
workflow_entry.event_ancestry(event).iteration_id or workflow_entry.event_ancestry(event).loop_id
):
answer = outputs.get("answer")
if isinstance(answer, str) and answer:
self._publish_event(
QueueTextChunkEvent(
text=answer,
from_variable_selector=[event.node_id, "answer"],
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
in_iteration_id=workflow_entry.event_ancestry(event).iteration_id,
in_loop_id=workflow_entry.event_ancestry(event).loop_id,
)
)
self._publish_event(
@@ -634,8 +636,8 @@ class WorkflowBasedAppRunner:
process_data=process_data,
outputs=outputs,
execution_metadata=execution_metadata,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
in_iteration_id=workflow_entry.event_ancestry(event).iteration_id,
in_loop_id=workflow_entry.event_ancestry(event).loop_id,
)
)
case NodeRunFailedEvent():
@@ -656,8 +658,8 @@ class WorkflowBasedAppRunner:
outputs=outputs,
error=event.node_run_result.error or "Unknown error",
execution_metadata=event.node_run_result.metadata,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
in_iteration_id=workflow_entry.event_ancestry(event).iteration_id,
in_loop_id=workflow_entry.event_ancestry(event).loop_id,
)
)
case NodeRunExceptionEvent():
@@ -678,8 +680,8 @@ class WorkflowBasedAppRunner:
outputs=outputs,
error=event.node_run_result.error or "Unknown error",
execution_metadata=event.node_run_result.metadata,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
in_iteration_id=workflow_entry.event_ancestry(event).iteration_id,
in_loop_id=workflow_entry.event_ancestry(event).loop_id,
)
)
case NodeRunStreamChunkEvent():
@@ -687,8 +689,8 @@ class WorkflowBasedAppRunner:
QueueTextChunkEvent(
text=event.chunk,
from_variable_selector=list(event.selector),
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
in_iteration_id=workflow_entry.event_ancestry(event).iteration_id,
in_loop_id=workflow_entry.event_ancestry(event).loop_id,
)
)
case NodeRunReasoningChunkEvent():
@@ -697,8 +699,8 @@ class WorkflowBasedAppRunner:
reasoning=event.chunk,
from_node_id=event.node_id,
is_final=event.is_final,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
in_iteration_id=workflow_entry.event_ancestry(event).iteration_id,
in_loop_id=workflow_entry.event_ancestry(event).loop_id,
)
)
case NodeRunRetrieverResourceEvent():
@@ -707,8 +709,8 @@ class WorkflowBasedAppRunner:
retriever_resources=[
RetrievalSourceMetadata.model_validate(resource) for resource in event.retriever_resources
],
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
in_iteration_id=workflow_entry.event_ancestry(event).iteration_id,
in_loop_id=workflow_entry.event_ancestry(event).loop_id,
)
)
case NodeRunAgentLogEvent():
@@ -733,7 +735,7 @@ class WorkflowBasedAppRunner:
node_type=event.node_type,
node_title=event.node_title,
start_at=event.start_at,
node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
node_run_index=workflow_entry.graph_engine.runtime_state.node_run_steps,
inputs=event.inputs,
metadata=event.metadata,
)
@@ -746,7 +748,7 @@ class WorkflowBasedAppRunner:
node_type=event.node_type,
node_title=event.node_title,
index=event.index,
node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
node_run_index=workflow_entry.graph_engine.runtime_state.node_run_steps,
output=event.pre_iteration_output,
)
)
@@ -758,7 +760,7 @@ class WorkflowBasedAppRunner:
node_type=event.node_type,
node_title=event.node_title,
start_at=event.start_at,
node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
node_run_index=workflow_entry.graph_engine.runtime_state.node_run_steps,
inputs=event.inputs,
outputs=event.outputs,
metadata=event.metadata,
@@ -774,7 +776,7 @@ class WorkflowBasedAppRunner:
node_type=event.node_type,
node_title=event.node_title,
start_at=event.start_at,
node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
node_run_index=workflow_entry.graph_engine.runtime_state.node_run_steps,
inputs=event.inputs,
metadata=event.metadata,
)
@@ -787,7 +789,7 @@ class WorkflowBasedAppRunner:
node_type=event.node_type,
node_title=event.node_title,
index=event.index,
node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
node_run_index=workflow_entry.graph_engine.runtime_state.node_run_steps,
output=event.pre_loop_output,
)
)
@@ -799,7 +801,7 @@ class WorkflowBasedAppRunner:
node_type=event.node_type,
node_title=event.node_title,
start_at=event.start_at,
node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps,
node_run_index=workflow_entry.graph_engine.runtime_state.node_run_steps,
inputs=event.inputs,
outputs=event.outputs,
metadata=event.metadata,
+1 -1
View File
@@ -108,7 +108,7 @@ def is_retriever_segment_access_granted(segment_id: str) -> bool:
@contextmanager
def bind_file_access_scope(scope: FileAccessScope) -> Generator[None]:
def bind_file_access_scope(scope: FileAccessScope | None) -> Generator[None]:
token = _current_file_access_scope.set(scope)
try:
yield
@@ -12,14 +12,14 @@ from typing import override
from core.workflow.system_variables import SystemVariableKey, get_system_text
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import GraphEngineEvent, NodeRunVariableUpdatedEvent
from graphon.engine.layer import Layer
from graphon.engine_events import EngineEvent, NodeRunVariableUpdatedEvent
from services.conversation_variable_updater import ConversationVariableUpdater
logger = logging.getLogger(__name__)
class ConversationVariablePersistenceLayer(GraphEngineLayer):
class ConversationVariablePersistenceLayer(Layer):
def __init__(self, conversation_variable_updater: ConversationVariableUpdater) -> None:
super().__init__()
self._conversation_variable_updater = conversation_variable_updater
@@ -29,7 +29,7 @@ class ConversationVariablePersistenceLayer(GraphEngineLayer):
pass
@override
def on_event(self, event: GraphEngineEvent) -> None:
def on_event(self, event: EngineEvent) -> None:
if not isinstance(event, NodeRunVariableUpdatedEvent):
return
@@ -38,7 +38,7 @@ class ConversationVariablePersistenceLayer(GraphEngineLayer):
logger.warning("Conversation variable selector invalid. selector=%s", selector)
return
conversation_id = get_system_text(self.graph_runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
conversation_id = get_system_text(self.runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
if conversation_id is None:
return
@@ -9,9 +9,9 @@ from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity,
from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
from core.workflow.nodes.human_input.boundary import enrich_graph_pause_reasons
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.filters import ResponseStreamFilter
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import GraphEngineEvent, GraphRunPausedEvent
from graphon.engine.filter import ResponseStreamFilter
from graphon.engine.layer import Layer
from graphon.engine_events import EngineEvent, GraphRunPausedEvent
from models.model import AppMode
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
from repositories.factory import DifyAPIRepositoryFactory
@@ -74,7 +74,7 @@ class PauseStateLayerConfig:
state_owner_user_id: str
class PauseStatePersistenceLayer(GraphEngineLayer):
class PauseStatePersistenceLayer(Layer):
def __init__(
self,
session_factory: Engine | sessionmaker[Session],
@@ -99,6 +99,7 @@ class PauseStatePersistenceLayer(GraphEngineLayer):
self._state_owner_user_id = state_owner_user_id
self._generate_entity = generate_entity
self._response_stream_filter = response_stream_filter
self._paused_event: GraphRunPausedEvent | None = None
def _get_repo(self) -> APIWorkflowRunRepository:
return DifyAPIRepositoryFactory.create_api_workflow_run_repository(self._session_maker)
@@ -114,7 +115,7 @@ class PauseStatePersistenceLayer(GraphEngineLayer):
pass
@override
def on_event(self, event: GraphEngineEvent) -> None:
def on_event(self, event: EngineEvent) -> None:
"""
Called for every event emitted by the engine.
@@ -127,7 +128,13 @@ class PauseStatePersistenceLayer(GraphEngineLayer):
Args:
event: The event emitted by the engine
"""
if not isinstance(event, GraphRunPausedEvent):
if isinstance(event, GraphRunPausedEvent):
self._paused_event = event
def persist_paused_state(self) -> None:
"""Commit the runtime/filter pair after the outer stream has fully flushed."""
event = self._paused_event
if event is None:
return
entity_wrapper: _GenerateEntityUnion
@@ -137,13 +144,13 @@ class PauseStatePersistenceLayer(GraphEngineLayer):
entity_wrapper = _AdvancedChatAppGenerateEntityWrapper(entity=self._generate_entity)
state = WorkflowResumptionContext(
serialized_graph_runtime_state=self.graph_runtime_state.dumps(),
serialized_graph_runtime_state=self.runtime_state.dumps(),
generate_entity=entity_wrapper,
serialized_response_stream_filter_state=self._response_stream_filter.dumps(),
)
workflow_run_id = get_system_text(
self.graph_runtime_state.variable_pool,
self.runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
)
assert workflow_run_id is not None
@@ -153,7 +160,7 @@ class PauseStatePersistenceLayer(GraphEngineLayer):
pause_reasons = enrich_graph_pause_reasons(
reasons=event.reasons,
form_repository=HumanInputFormSubmissionRepository(),
variable_pool=self.graph_runtime_state.variable_pool,
variable_pool=self.runtime_state.variable_pool,
)
repo = self._get_repo()
repo.create_workflow_pause(
@@ -162,6 +169,7 @@ class PauseStatePersistenceLayer(GraphEngineLayer):
state=state.dumps(),
pause_reasons=pause_reasons,
)
self._paused_event = None
@override
def on_graph_end(self, error: Exception | None) -> None:
+4 -4
View File
@@ -1,10 +1,10 @@
from typing import override
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import GraphEngineEvent, GraphRunPausedEvent
from graphon.engine.layer import Layer
from graphon.engine_events import EngineEvent, GraphRunPausedEvent
class SuspendLayer(GraphEngineLayer):
class SuspendLayer(Layer):
""" """
def __init__(self) -> None:
@@ -16,7 +16,7 @@ class SuspendLayer(GraphEngineLayer):
self._paused = False
@override
def on_event(self, event: GraphEngineEvent):
def on_event(self, event: EngineEvent):
"""
Handle the paused event, stash runtime state into storage and wait for resume.
"""
+6 -13
View File
@@ -4,16 +4,16 @@ from typing import ClassVar, override
from apscheduler.schedulers.background import BackgroundScheduler # type: ignore
from graphon.graph_engine.entities.commands import CommandType, GraphEngineCommand
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import GraphEngineEvent
from graphon.engine.command import PauseCommand
from graphon.engine.layer import Layer
from graphon.engine_events import EngineEvent
from services.workflow.entities import WorkflowScheduleCFSPlanEntity
from services.workflow.scheduler import CFSPlanScheduler, SchedulerCommand
logger = logging.getLogger(__name__)
class TimeSliceLayer(GraphEngineLayer):
class TimeSliceLayer(Layer):
"""
CFS plan scheduler to control the timeslice of the workflow.
"""
@@ -51,14 +51,7 @@ class TimeSliceLayer(GraphEngineLayer):
return
# send command to pause the workflow
self.command_channel.send_command(
GraphEngineCommand(
command_type=CommandType.PAUSE,
payload={
"reason": SchedulerCommand.RESOURCE_LIMIT_REACHED,
},
)
)
self.command_channel.send_command(PauseCommand(reason=SchedulerCommand.RESOURCE_LIMIT_REACHED))
except Exception:
logger.exception("scheduler error during check if the workflow need to be suspended")
@@ -80,7 +73,7 @@ class TimeSliceLayer(GraphEngineLayer):
)
@override
def on_event(self, event: GraphEngineEvent):
def on_event(self, event: EngineEvent):
pass
@override
+9 -9
View File
@@ -6,9 +6,9 @@ from pydantic import TypeAdapter
from core.db.session_factory import session_factory
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import (
GraphEngineEvent,
from graphon.engine.layer import Layer
from graphon.engine_events import (
EngineEvent,
GraphRunAbortedEvent,
GraphRunFailedEvent,
GraphRunPausedEvent,
@@ -21,12 +21,12 @@ from tasks.workflow_cfs_scheduler.cfs_scheduler import AsyncWorkflowCFSPlanEntit
logger = logging.getLogger(__name__)
class TriggerPostLayer(GraphEngineLayer):
class TriggerPostLayer(Layer):
"""
Trigger post layer.
"""
_STATUS_MAP: ClassVar[dict[type[GraphEngineEvent], WorkflowTriggerStatus]] = {
_STATUS_MAP: ClassVar[dict[type[EngineEvent], WorkflowTriggerStatus]] = {
GraphRunSucceededEvent: WorkflowTriggerStatus.SUCCEEDED,
GraphRunFailedEvent: WorkflowTriggerStatus.FAILED,
GraphRunAbortedEvent: WorkflowTriggerStatus.FAILED,
@@ -49,7 +49,7 @@ class TriggerPostLayer(GraphEngineLayer):
pass
@override
def on_event(self, event: GraphEngineEvent):
def on_event(self, event: EngineEvent):
"""
Update trigger log with success or failure.
"""
@@ -65,16 +65,16 @@ class TriggerPostLayer(GraphEngineLayer):
elapsed_time = (datetime.now(UTC) - self.start_time).total_seconds()
# Extract relevant data from result
outputs = self.graph_runtime_state.outputs
outputs = self.runtime_state.outputs
# BASICLY, workflow_execution_id is the same as workflow_run_id
workflow_run_id = get_system_text(
self.graph_runtime_state.variable_pool,
self.runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
)
assert workflow_run_id, "Workflow run id is not set"
total_tokens = self.graph_runtime_state.total_tokens
total_tokens = self.runtime_state.total_tokens
# Update trigger log with success
trigger_log.status = self._STATUS_MAP[type(event)]
+5 -3
View File
@@ -7,6 +7,7 @@ import os
import time
import urllib.parse
from collections.abc import Generator
from contextlib import AbstractContextManager
from typing import TYPE_CHECKING, Literal, override
from configs import dify_config
@@ -18,7 +19,7 @@ from core.workflow.file_reference import parse_file_reference
from extensions.ext_storage import storage
from graphon.file import FileTransferMethod
from graphon.file.protocols import WorkflowFileRuntimeProtocol
from graphon.file.runtime import set_workflow_file_runtime
from graphon.file.runtime import use_workflow_file_runtime
from graphon.http.protocols import HttpResponseProtocol
if TYPE_CHECKING:
@@ -215,5 +216,6 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol):
raise ValueError(f"Tool file {tool_file_id} not found")
def bind_dify_workflow_file_runtime() -> None:
set_workflow_file_runtime(DifyWorkflowFileRuntime(file_access_controller=DatabaseFileAccessController()))
def bind_dify_workflow_file_runtime() -> AbstractContextManager[None]:
"""Bind the host adapter for one explicit execution or rendering scope."""
return use_workflow_file_runtime(DifyWorkflowFileRuntime(file_access_controller=DatabaseFileAccessController()))
+1 -1
View File
@@ -1,4 +1,4 @@
"""Workflow-level GraphEngine layers that depend on outer infrastructure."""
"""Workflow-level Engine layers that depend on outer infrastructure."""
from .observability import ObservabilityLayer
from .persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
+29 -22
View File
@@ -1,5 +1,5 @@
"""
Observability layer for GraphEngine.
Observability layer for Engine.
This layer creates OpenTelemetry spans for node execution, enabling distributed
tracing of workflow execution. It establishes OTel context during node execution
@@ -8,7 +8,8 @@ associates with the node span.
"""
import logging
from contextvars import Token
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import cast, final, override
@@ -25,9 +26,9 @@ from extensions.otel.parser import (
)
from extensions.otel.runtime import is_instrument_flag_enabled
from extensions.otel.semconv import DifySpanAttributes
from graphon.engine.layer import Layer
from graphon.engine_events import EngineEvent, GraphRunAbortedEvent, NodeEvent
from graphon.enums import BuiltinNodeTypes, NodeType
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import GraphEngineEvent, GraphNodeEventBase, GraphRunAbortedEvent
from graphon.nodes.base.node import Node
logger = logging.getLogger(__name__)
@@ -36,11 +37,10 @@ logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _NodeSpanContext:
span: "Span"
token: Token[context_api.Context]
@final
class ObservabilityLayer(GraphEngineLayer):
class ObservabilityLayer(Layer):
"""
Layer that creates OpenTelemetry spans for node execution.
@@ -88,8 +88,25 @@ class ObservabilityLayer(GraphEngineLayer):
"""Called when graph execution starts."""
self._node_contexts.clear()
@contextmanager
@override
def node_run_context(self, node: Node, *, parent_execution_id: str | None = None) -> Generator[None]:
self._ensure_node_span(node, parent_execution_id=parent_execution_id)
node_context = self._node_contexts.get(node.execution_id)
if node_context is None:
yield
return
token = context_api.attach(set_span_in_context(node_context.span))
try:
yield
finally:
context_api.detach(token)
@override
def on_node_run_start(self, node: Node) -> None:
self._ensure_node_span(node)
def _ensure_node_span(self, node: Node, *, parent_execution_id: str | None = None) -> None:
"""
Called when a node starts execution.
@@ -103,28 +120,24 @@ class ObservabilityLayer(GraphEngineLayer):
return
execution_id = node.execution_id
if not execution_id:
if not execution_id or execution_id in self._node_contexts:
return
parent_context = context_api.get_current()
parent = self._node_contexts.get(parent_execution_id) if parent_execution_id else None
parent_context = set_span_in_context(parent.span) if parent else context_api.get_current()
span = self._tracer.start_span(
f"{node.title}",
kind=SpanKind.INTERNAL,
context=parent_context,
)
new_context = set_span_in_context(span)
token = context_api.attach(new_context)
self._node_contexts[execution_id] = _NodeSpanContext(span=span, token=token)
self._node_contexts[execution_id] = _NodeSpanContext(span=span)
except Exception as e:
logger.warning("Failed to create OpenTelemetry span for node %s: %s", node.id, e)
@override
def on_node_run_end(
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
) -> None:
def on_node_run_end(self, node: Node, error: Exception | None, result_event: NodeEvent | None = None) -> None:
"""
Called when a node finishes execution.
@@ -147,19 +160,13 @@ class ObservabilityLayer(GraphEngineLayer):
parser.parse(node=node, span=span, error=error, result_event=result_event)
span.end()
finally:
token = node_context.token
if token is not None:
try:
context_api.detach(token)
except Exception:
logger.warning("Failed to detach OpenTelemetry token: %s", token)
self._node_contexts.pop(execution_id, None)
except Exception as e:
logger.warning("Failed to end OpenTelemetry span for node %s: %s", node.id, e)
@override
def on_event(self, event: GraphEngineEvent) -> None:
def on_event(self, event: EngineEvent) -> None:
"""Record graph-level observability events."""
if self._is_disabled:
return
+41 -24
View File
@@ -1,7 +1,7 @@
"""Workflow persistence layer for GraphEngine.
"""Workflow persistence layer for Engine.
This layer mirrors the former ``WorkflowCycleManager`` responsibilities by
listening to ``GraphEngineEvent`` instances directly and persisting workflow
listening to ``EngineEvent`` instances directly and persisting workflow
and node execution state via the injected repositories.
The design keeps domain persistence concerns inside the engine thread, while
@@ -24,17 +24,9 @@ from core.workflow.node_execution_process_data import preserve_workflow_agent_bi
from core.workflow.system_variables import SystemVariableKey
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run
from graphon.entities import WorkflowExecution, WorkflowNodeExecution, WorkflowStartReason
from graphon.enums import (
BuiltinNodeTypes,
WorkflowExecutionStatus,
WorkflowNodeExecutionMetadataKey,
WorkflowNodeExecutionStatus,
WorkflowType,
)
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import (
GraphEngineEvent,
from graphon.engine.layer import Layer
from graphon.engine_events import (
EngineEvent,
GraphRunAbortedEvent,
GraphRunFailedEvent,
GraphRunPartialSucceededEvent,
@@ -48,8 +40,17 @@ from graphon.graph_events import (
NodeRunStartedEvent,
NodeRunSucceededEvent,
)
from graphon.entities import WorkflowExecution, WorkflowNodeExecution, WorkflowStartReason
from graphon.enums import (
BuiltinNodeTypes,
WorkflowExecutionStatus,
WorkflowNodeExecutionMetadataKey,
WorkflowNodeExecutionStatus,
WorkflowType,
)
from graphon.node_events import NodeRunResult
from libs.datetime_utils import naive_utc_now
from services.workflow.event_ancestry import WorkflowEventAncestry
from services.workflow.inspector_events import (
publish_node_changed as _inspector_publish_node_changed,
)
@@ -80,8 +81,8 @@ class _NodeRuntimeSnapshot:
created_at: datetime
class WorkflowPersistenceLayer(GraphEngineLayer):
"""GraphEngine layer that persists workflow and node execution state."""
class WorkflowPersistenceLayer(Layer):
"""Engine layer that persists workflow and node execution state."""
def __init__(
self,
@@ -95,6 +96,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
super().__init__()
self._application_generate_entity = application_generate_entity
self._workflow_info = workflow_info
self._event_ancestry = WorkflowEventAncestry(workflow_info.graph_data)
self._workflow_execution_repository = workflow_execution_repository
self._workflow_node_execution_repository = workflow_node_execution_repository
self._trace_manager = trace_manager
@@ -103,9 +105,10 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
self._node_execution_cache: dict[str, WorkflowNodeExecution] = {}
self._node_snapshots: dict[str, _NodeRuntimeSnapshot] = {}
self._node_sequence: int = 0
self._pending_pause: GraphRunPausedEvent | None = None
# ------------------------------------------------------------------
# GraphEngineLayer lifecycle
# Layer lifecycle
# ------------------------------------------------------------------
@override
def on_graph_start(self) -> None:
@@ -113,9 +116,10 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
self._node_execution_cache.clear()
self._node_snapshots.clear()
self._node_sequence = 0
self._pending_pause = None
@override
def on_event(self, event: GraphEngineEvent) -> None:
def on_event(self, event: EngineEvent) -> None:
match event:
case GraphRunStartedEvent():
self._handle_graph_run_started(event)
@@ -128,7 +132,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
case GraphRunAbortedEvent():
self._handle_graph_run_aborted(event)
case GraphRunPausedEvent():
self._handle_graph_run_paused(event)
self._pending_pause = event
case NodeRunRetryEvent():
self._handle_node_retry(event)
case NodeRunStartedEvent():
@@ -142,6 +146,17 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
case NodeRunPauseRequestedEvent():
self._handle_node_pause_requested(event)
def persist_paused_state(self) -> None:
"""Commit PAUSED only after the recoverable runtime/filter snapshot exists."""
if self._pending_pause is not None:
self._handle_graph_run_paused(self._pending_pause)
self._pending_pause = None
def fail_pause_persistence(self, error: Exception) -> None:
if self._pending_pause is not None:
self._pending_pause = None
self._handle_graph_run_failed(GraphRunFailedEvent(error=str(error)))
@override
def on_graph_end(self, error: Exception | None) -> None:
return
@@ -227,8 +242,10 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
execution = self._get_workflow_execution()
metadata = {
WorkflowNodeExecutionMetadataKey.ITERATION_ID: event.in_iteration_id,
WorkflowNodeExecutionMetadataKey.LOOP_ID: event.in_loop_id,
WorkflowNodeExecutionMetadataKey.ITERATION_ID: self._event_ancestry.resolve(
event.container_id
).iteration_id,
WorkflowNodeExecutionMetadataKey.LOOP_ID: self._event_ancestry.resolve(event.container_id).loop_id,
}
domain_execution = WorkflowNodeExecution(
@@ -256,8 +273,8 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
node_id=event.node_id,
title=event.node_title,
predecessor_node_id=event.predecessor_node_id,
iteration_id=event.in_iteration_id,
loop_id=event.in_loop_id,
iteration_id=self._event_ancestry.resolve(event.container_id).iteration_id,
loop_id=self._event_ancestry.resolve(event.container_id).loop_id,
created_at=event.start_at,
)
self._node_snapshots[event.id] = snapshot
@@ -415,7 +432,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
def _populate_completion_statistics(self, execution: WorkflowExecution, *, update_finished: bool = True) -> None:
if update_finished:
execution.finished_at = naive_utc_now()
runtime_state = self.graph_runtime_state
runtime_state = self.runtime_state
execution.total_tokens = runtime_state.total_tokens
execution.total_steps = runtime_state.node_run_steps
execution.outputs = execution.outputs or runtime_state.outputs
@@ -501,5 +518,5 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
self._trace_manager.add_trace_task(trace_task)
def _system_variables(self) -> Mapping[str, Any]:
runtime_state = self.graph_runtime_state
runtime_state = self.runtime_state
return runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)
+6 -3
View File
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, override
from core.plugin.entities.plugin_daemon import TTSAudioChunk
from graphon.model_runtime.entities.model_entities import ModelPropertyKey
from graphon.model_runtime.errors.invoke import InvokeBadRequestError
from graphon.model_runtime.protocols.tts_runtime import TTSChunk
from libs.stream import close_stream
if TYPE_CHECKING:
@@ -93,8 +94,10 @@ def _normalize_reported_mime_type(mime_type: object | None, source: str) -> str
return normalized_mime_type
def _extract_audio_chunk(chunk: bytes | bytearray | memoryview | TTSAudioChunk) -> tuple[bytes, str | None]:
"""Accept old byte chunks and the compatibility carrier for current Graphon."""
def _extract_audio_chunk(chunk: bytes | bytearray | memoryview | TTSAudioChunk | TTSChunk) -> tuple[bytes, str | None]:
"""Unpack native runtime chunks and plugin byte carriers without losing MIME."""
if isinstance(chunk, TTSChunk):
return chunk.data, chunk.mime_type
if isinstance(chunk, TTSAudioChunk):
return bytes(chunk), chunk.mime_type
@@ -141,7 +144,7 @@ def resolve_audio_mime_type(
return normalized_declared_mime_type or DEFAULT_TTS_AUDIO_MIME_TYPE
type _AudioChunk = bytes | bytearray | memoryview | TTSAudioChunk
type _AudioChunk = bytes | bytearray | memoryview | TTSAudioChunk | TTSChunk
class AudioStream(Iterator[bytes]):
+11 -4
View File
@@ -35,6 +35,8 @@ from graphon.model_runtime.model_providers.base.rerank_model import RerankModel
from graphon.model_runtime.model_providers.base.speech2text_model import Speech2TextModel
from graphon.model_runtime.model_providers.base.text_embedding_model import TextEmbeddingModel
from graphon.model_runtime.model_providers.base.tts_model import TTSModel
from graphon.model_runtime.protocols.tts_runtime import TTSChunk
from libs.stream import close_stream
from models.provider import ProviderType
logger = logging.getLogger(__name__)
@@ -407,7 +409,7 @@ class ModelInstance:
voice: str = "",
*,
request_metadata: Mapping[str, object] | None = None,
) -> Iterable[bytes]:
) -> Iterable[TTSChunk]:
"""
Invoke large language tts model
@@ -794,7 +796,7 @@ class QuotaManagedModelInstance(ModelInstance):
voice: str = "",
*,
request_metadata: Mapping[str, object] | None = None,
) -> Iterable[bytes]:
) -> Iterable[TTSChunk]:
return self._invoke_tts_stream(content_text=content_text, voice=voice, request_metadata=request_metadata)
def _invoke_tts_stream(
@@ -803,9 +805,10 @@ class QuotaManagedModelInstance(ModelInstance):
content_text: str,
voice: str,
request_metadata: Mapping[str, object] | None = None,
) -> Generator[bytes]:
) -> Generator[TTSChunk]:
effective_request_metadata = self._resolve_request_metadata(request_metadata)
reservation = self._reserve_quota_for_request(effective_request_metadata)
response: Iterable[TTSChunk] | None = None
try:
response = super().invoke_tts(
content_text=content_text,
@@ -816,7 +819,11 @@ class QuotaManagedModelInstance(ModelInstance):
reservation.commit()
yield chunk
finally:
self.release_quota_safely(reservation)
try:
if response is not None:
close_stream(response)
finally:
self.release_quota_safely(reservation)
class ModelManager:
+10 -3
View File
@@ -32,7 +32,8 @@ from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput
from graphon.model_runtime.entities.text_embedding_entities import EmbeddingInputType, EmbeddingResult
from graphon.model_runtime.model_providers.base.large_language_model import normalize_non_stream_runtime_result
from graphon.model_runtime.protocols.runtime import ModelRuntime
from graphon.model_runtime.protocols.tts_runtime import TTSModelVoice
from graphon.model_runtime.protocols.tts_runtime import TTSChunk, TTSModelVoice
from libs.stream import close_stream
from models.provider_ids import ModelProviderID
logger = logging.getLogger(__name__)
@@ -638,10 +639,10 @@ class PluginModelRuntime(ModelRuntime):
content_text: str,
voice: str,
request_metadata: Mapping[str, object] | None = None,
) -> Iterable[bytes]:
) -> Iterable[TTSChunk]:
del request_metadata
plugin_id, provider_name = self._split_provider(provider)
return self.client.invoke_tts(
response = self.client.invoke_tts(
tenant_id=self.tenant_id,
user_id=self.user_id,
plugin_id=plugin_id,
@@ -652,6 +653,12 @@ class PluginModelRuntime(ModelRuntime):
voice=voice,
)
try:
for chunk in response:
yield TTSChunk(data=bytes(chunk), mime_type=chunk.mime_type)
finally:
close_stream(response)
@override
def get_tts_model_voices(
self,
@@ -492,9 +492,12 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
# Add images first
for file in image_files:
try:
file_content = file_manager.to_prompt_message_content(
file, image_detail_config=ImagePromptMessageContent.DETAIL.LOW
)
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
with bind_dify_workflow_file_runtime():
file_content = file_manager.to_prompt_message_content(
file, image_detail_config=ImagePromptMessageContent.DETAIL.LOW
)
prompt_message_contents.append(file_content)
except Exception:
logger.warning("Failed to convert image file to prompt message content", exc_info=True)
+1 -1
View File
@@ -11,7 +11,7 @@ from core.workflow.nodes.human_input.pause_reason import (
HumanInputRequired,
PauseReason,
)
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
from graphon.variables import ArrayStringSegment
from models.human_input import ApprovalChannel, RecipientType
+5 -2
View File
@@ -1,9 +1,10 @@
from collections.abc import Callable, Generator, Sequence
from typing import Any, override
from graphon.file import File
from graphon.model_runtime.entities.llm_entities import LLMStructuredOutput
from graphon.model_runtime.entities.message_entities import PromptMessage
from graphon.node_events.base import NodeEventBase
from graphon.node_events.base import NodeEventPayload
from graphon.nodes.llm.node import LLMNode
from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol
@@ -31,12 +32,14 @@ class DifyLLMNode(LLMNode):
self,
*,
polling_model: LLMPollingCapableProtocol,
file_outputs: list[File],
prompt_messages: Sequence[PromptMessage],
stop: Sequence[str] | None,
) -> Generator[NodeEventBase | LLMStructuredOutput]:
) -> Generator[NodeEventPayload | LLMStructuredOutput]:
try:
yield from super()._invoke_llm_with_polling(
polling_model=polling_model,
file_outputs=file_outputs,
prompt_messages=prompt_messages,
stop=stop,
)
+126 -56
View File
@@ -2,6 +2,7 @@ import importlib
import pkgutil
import secrets
from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
from copy import copy, deepcopy
from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Protocol, cast, final, override, runtime_checkable
@@ -80,9 +81,10 @@ from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer
from extensions.application_services.data_sources import build_data_source_credentials
from graphon.entities.base_node_data import BaseNodeData
from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter
from graphon.enums import BuiltinNodeTypes, NodeType
from graphon.enums import BuiltinNodeTypes, NodeExecutionType, NodeType
from graphon.file.file_manager import file_manager
from graphon.graph.graph import NodeFactory
from graphon.graph.scoping import resolve_container_id
from graphon.model_runtime.memory import PromptMessageMemory
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
from graphon.nodes.base.node import Node
@@ -106,8 +108,7 @@ from services.dify_builder.native_testing.runtime_bridge import NativeRuntimeBri
from services.dify_builder.template_execution import MAX_OUTPUT_BYTES, BoundedJinja2TemplateRenderer
if TYPE_CHECKING:
from graphon.entities import GraphInitParams
from graphon.runtime import GraphRuntimeState
from graphon.runtime import InitParams, RuntimeState
LATEST_VERSION = "latest"
_START_NODE_TYPES: frozenset[NodeType] = frozenset(
@@ -119,7 +120,7 @@ _START_NODE_TYPES: frozenset[NodeType] = frozenset(
class DifyGraphInitContext:
"""Explicit graph-init values owned by the workflow layer.
Dify is gradually removing direct `GraphInitParams` construction from its
Dify is gradually removing direct `InitParams` construction from its
production call sites. Keep the translation here until `graphon` exposes an
equivalent explicit API.
"""
@@ -129,10 +130,10 @@ class DifyGraphInitContext:
run_context: Mapping[str, Any]
call_depth: int
def to_graph_init_params(self) -> "GraphInitParams":
from graphon.entities import GraphInitParams
def to_graph_init_params(self) -> "InitParams":
from graphon.runtime import InitParams
return GraphInitParams(
return InitParams(
workflow_id=self.workflow_id,
graph_config=self.graph_config,
run_context=self.run_context,
@@ -369,7 +370,7 @@ class DifyNodeFactory(NodeFactory):
cls,
*,
graph_init_context: DifyGraphInitContext,
graph_runtime_state: "GraphRuntimeState",
runtime_state: "RuntimeState",
execution_recorder: BuilderExecutionRecorder | None = None,
native_runtime: NativeRuntimeBridge | None = None,
) -> "DifyNodeFactory":
@@ -377,22 +378,23 @@ class DifyNodeFactory(NodeFactory):
return cls(
execution_recorder=execution_recorder,
native_runtime=native_runtime,
graph_init_params=graph_init_context.to_graph_init_params(),
graph_runtime_state=graph_runtime_state,
init_params=graph_init_context.to_graph_init_params(),
runtime_state=runtime_state,
)
def __init__(
self,
graph_init_params: "GraphInitParams",
graph_runtime_state: "GraphRuntimeState",
init_params: "InitParams",
runtime_state: "RuntimeState",
*,
execution_recorder: BuilderExecutionRecorder | None = None,
native_runtime: NativeRuntimeBridge | None = None,
_request_hmac_key: bytes | None = None,
) -> None:
self.graph_init_params = graph_init_params
self.graph_runtime_state = graph_runtime_state
self._dify_context = self._resolve_dify_context(graph_init_params.run_context)
self.init_params = init_params
self._admitted_graph_config = deepcopy(init_params.graph_config)
self.runtime_state = runtime_state
self._dify_context = self._resolve_dify_context(init_params.run_context)
self.execution_recorder = execution_recorder
self._native_runtime = native_runtime
if native_runtime is not None and self._dify_context.native_test_ref is None:
@@ -408,9 +410,9 @@ class DifyNodeFactory(NodeFactory):
self._dify_context.tenant_id,
self._dify_context.app_id,
self._dify_context.user_id,
graph_init_params.workflow_id,
graph_init_params.call_depth,
dict(graph_init_params.graph_config),
init_params.workflow_id,
init_params.call_depth,
dict(init_params.graph_config),
) != (
context.tenant_id,
context.app_id,
@@ -471,12 +473,12 @@ class DifyNodeFactory(NodeFactory):
self._human_input_runtime = DifyHumanInputNodeRuntime(
self._dify_context,
workflow_execution_id_getter=lambda: get_system_text(
self.graph_runtime_state.variable_pool,
self.runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
),
conversation_id_getter=self._conversation_id,
)
self._tool_runtime = DifyToolNodeRuntime(self._dify_context, workflow_id=self.graph_init_params.workflow_id)
self._tool_runtime = DifyToolNodeRuntime(self._dify_context, workflow_id=self.init_params.workflow_id)
self._http_request_file_manager = file_manager
self._document_extractor_unstructured_api_config = UnstructuredApiConfig(
api_url=dify_config.UNSTRUCTURED_API_URL,
@@ -508,13 +510,13 @@ class DifyNodeFactory(NodeFactory):
self._dify_context.tenant_id,
self._dify_context.app_id,
self._dify_context.user_id,
self.graph_init_params.workflow_id,
self.graph_init_params.call_depth,
self.init_params.workflow_id,
self.init_params.call_depth,
) != (context.tenant_id, context.app_id, context.actor_id, context.workflow_id, 0):
raise BuilderExecutionPolicyError("execution_owner_mismatch")
def _admission_snapshot(self) -> RestrictedAdmissionSnapshot:
graph = dict(self.graph_init_params.graph_config)
graph = dict(self.init_params.graph_config)
return RestrictedAdmissionSnapshot(
app_mode="workflow",
workflow_kind="standard",
@@ -559,9 +561,7 @@ class DifyNodeFactory(NodeFactory):
raise BuilderExecutionPolicyError("node_binding_mismatch")
def issue_guard(node_id: str, node_execution_id: str) -> ConfirmedToolGuard:
native_run_id = get_system_text(
self.graph_runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID
)
native_run_id = get_system_text(self.runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID)
if not native_run_id:
raise BuilderExecutionPolicyError("execution_launch_mismatch")
return recorder.confirmed_guard(
@@ -586,7 +586,7 @@ class DifyNodeFactory(NodeFactory):
self._validate_json_raw_config(node_config)
if admit_json_workflow(snapshot).nodes != context.admitted_nodes:
raise BuilderExecutionPolicyError("node_binding_mismatch")
native_run_id = get_system_text(self.graph_runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID)
native_run_id = get_system_text(self.runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID)
if not native_run_id:
raise BuilderExecutionPolicyError("execution_launch_mismatch")
self._json_tool_runtime = QualifiedJsonToolNodeRuntime(
@@ -596,14 +596,88 @@ class DifyNodeFactory(NodeFactory):
issuer=recorder,
)
def with_runtime_state(self, graph_runtime_state: "GraphRuntimeState") -> "DifyNodeFactory":
return DifyNodeFactory(
execution_recorder=self.execution_recorder,
_request_hmac_key=getattr(self, "_request_hmac_key", None),
native_runtime=self._native_runtime,
graph_init_params=self.graph_init_params,
graph_runtime_state=graph_runtime_state,
def with_runtime_state(self, runtime_state: "RuntimeState") -> "DifyNodeFactory":
"""Copy frame-local state while retaining the launch's guards and HMAC identity."""
context = self._dify_context.builder_execution
if context is not None:
self._require_builder_owner(context)
scoped = copy(self)
scoped.runtime_state = runtime_state
scoped.init_params = self.init_params.model_copy(deep=True)
if hasattr(scoped, "_human_input_runtime"):
scoped._human_input_runtime = DifyHumanInputNodeRuntime(
scoped._dify_context,
workflow_execution_id_getter=lambda: get_system_text(
scoped.runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID
),
conversation_id_getter=scoped._conversation_id,
)
scoped._bound_tool_file_manager_factory = lambda: DifyToolFileManager(
scoped._dify_context, conversation_id_getter=scoped._conversation_id
)
scoped._llm_file_saver = build_dify_llm_file_saver(
run_context=scoped._dify_context,
http_client=scoped._remote_file_http_client,
conversation_id_getter=scoped._conversation_id,
)
return scoped
def with_graph_config(self, graph_config: Mapping[str, Any]) -> "DifyNodeFactory":
"""Scope node initialization without re-running admission or initializing services."""
scoped = copy(self)
scoped.init_params = self.init_params.model_copy(update={"graph_config": deepcopy(graph_config)})
return scoped
def _binding_node_config(self, node_config: Mapping[str, Any]) -> dict[str, Any]:
"""Recover only Graphon's verified canonical ownership addition for raw admission.
Digests bind the persisted graph, not Graphon's materialized DTO. Never
discard a caller-supplied field or other changed extras from that boundary.
"""
config = dict(node_config)
data = config.get("data")
if isinstance(data, BaseNodeData):
data = data.model_dump(mode="json", by_alias=True, exclude_unset=True)
if not isinstance(data, Mapping):
return config
data = dict(data)
originals = {node["id"]: node for node in self._admitted_graph_config.get("nodes", [])}
original = originals.get(config.get("id"))
if original is not None and "container_id" not in original["data"] and "container_id" in data:
if data["container_id"] != resolve_container_id(original, nodes_by_id=originals):
raise BuilderExecutionPolicyError("execution_binding_mismatch")
del data["container_id"]
config["data"] = data
return config
def validate_node(self, node_config: Mapping[str, Any]) -> NodeExecutionType:
"""Resolve and validate the concrete node and host binding before any construction."""
context = self._dify_context.builder_execution
if context is not None:
node_config = self._binding_node_config(node_config)
validator = None
if isinstance(context, ConfirmedToolExecutionContext):
validator = self._validate_confirmed_raw_config
elif isinstance(context, JsonToolExecutionContext):
validator = self._validate_json_raw_config
elif isinstance(context, BuilderExecutionContext):
validator = self._validate_restricted_raw_config
elif context is not None:
raise BuilderExecutionPolicyError("unsupported_execution_policy")
def validate_raw_binding(_adapted: Mapping[str, Any], node_class: type[Node] | None) -> None:
# Admission binds persisted input, before legacy Tool/HITL DTO adaptation.
# Still check the actual resolved class at both validation checkpoints.
if validator is not None:
validator(node_config, node_class)
_, _, node_class, _ = _validated_node_config(
adapt_node_config_for_graph(node_config),
resolve_node_class=self._resolve_node_class,
validate_resolved_node_data=self._validate_resolved_node_data,
pre_validate_node_data=validate_raw_binding if validator is not None else None,
)
return node_class.execution_type
@staticmethod
def _resolve_dify_context(run_context: Mapping[str, Any]) -> DifyRunContext:
@@ -615,7 +689,7 @@ class DifyNodeFactory(NodeFactory):
return DifyRunContext.model_validate(raw_ctx)
def _conversation_id(self) -> str | None:
return get_system_text(self.graph_runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
return get_system_text(self.runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
@override
def create_node(self, node_config: dict[str, Any] | NodeConfigDict) -> Node:
@@ -630,13 +704,7 @@ class DifyNodeFactory(NodeFactory):
"""
context = self._dify_context.builder_execution
if context is not None:
# Graph.init supplies its shared DTO. JSON restores native enum spellings
# while exclude_unset preserves the original raw config digest boundary.
if isinstance(node_config.get("data"), BaseNodeData):
node_config = {
**node_config,
"data": node_config["data"].model_dump(mode="json", by_alias=True, exclude_unset=True),
}
node_config = self._binding_node_config(node_config)
if isinstance(context, ConfirmedToolExecutionContext):
return self._create_confirmed_node(node_config)
if isinstance(context, JsonToolExecutionContext):
@@ -659,13 +727,13 @@ class DifyNodeFactory(NodeFactory):
node_id=node_id,
node_data=resolved_node_data,
node_class=node_class,
runtime_state=self.graph_runtime_state,
runtime_state=self.runtime_state,
)
return node_class(
node_id=node_id,
data=resolved_node_data.model_dump(mode="python", by_alias=True),
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
init_params=self.init_params,
runtime_state=self.runtime_state,
**native_kwargs,
)
if isinstance(context, BuilderExecutionContext):
@@ -697,8 +765,8 @@ class DifyNodeFactory(NodeFactory):
return node_class(
node_id=node_id,
data=resolved_node_data.model_dump(mode="python", by_alias=True),
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
init_params=self.init_params,
runtime_state=self.runtime_state,
**kwargs,
)
node_type = node_data.type
@@ -777,8 +845,8 @@ class DifyNodeFactory(NodeFactory):
node = node_class(
node_id=node_id,
data=constructor_node_data,
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
init_params=self.init_params,
runtime_state=self.runtime_state,
**node_init_kwargs,
)
return node
@@ -801,8 +869,8 @@ class DifyNodeFactory(NodeFactory):
return node_class(
node_id=node_id,
data=resolved_node_data.model_dump(mode="python", by_alias=True),
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
init_params=self.init_params,
runtime_state=self.runtime_state,
**kwargs,
)
@@ -823,8 +891,8 @@ class DifyNodeFactory(NodeFactory):
return node_class(
node_id=node_id,
data=resolved.model_dump(mode="python", by_alias=True),
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
init_params=self.init_params,
runtime_state=self.runtime_state,
**kwargs,
)
@@ -928,7 +996,7 @@ class DifyNodeFactory(NodeFactory):
return node_data
selector = parse_llm_model_selector(model_selector)
variable = self.graph_runtime_state.variable_pool.get(selector)
variable = self.runtime_state.variable_pool.get(selector)
if not isinstance(variable, ObjectSegment):
raise ValueError(f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable")
@@ -1051,6 +1119,8 @@ class DifyNodeFactory(NodeFactory):
if include_jinja2_template_renderer:
node_init_kwargs["jinja2_template_renderer"] = self._jinja2_template_renderer
if validated_node_data.type == BuiltinNodeTypes.LLM:
for legacy_key in ("credentials_provider", "model_factory", "http_client"):
node_init_kwargs.pop(legacy_key, None)
node_init_kwargs["default_query_selector"] = system_variable_selector(SystemVariableKey.QUERY)
node_init_kwargs["polling_finalizer"] = cast(DifyPreparedLLM, node_model_instance).finalize_llm_polling
return node_init_kwargs
@@ -1089,7 +1159,7 @@ class DifyNodeFactory(NodeFactory):
if not context_variable_selector:
return False
context_value = self.graph_runtime_state.variable_pool.get(context_variable_selector)
context_value = self.runtime_state.variable_pool.get(context_variable_selector)
if not isinstance(context_value, ArrayObjectSegment):
return False
@@ -1124,7 +1194,7 @@ class DifyNodeFactory(NodeFactory):
if node_data.memory is None:
return None
conversation_id = get_system_text(self.graph_runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
conversation_id = get_system_text(self.runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
return fetch_memory(
conversation_id=conversation_id,
app_id=self._dify_context.app_id,
+13 -14
View File
@@ -6,9 +6,9 @@ from typing import TYPE_CHECKING, Any, override
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.engine_events import NodeEvent
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus
from graphon.graph_events import GraphNodeEventBase
from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent
from graphon.node_events import NodeEventPayload, NodeRunResult, StreamCompletedEvent
from graphon.nodes.base.node import Node
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
@@ -23,8 +23,7 @@ from .runtime_support import AgentRuntimeSupport
from .strategy_protocols import AgentStrategyPresentationProvider, AgentStrategyResolver
if TYPE_CHECKING:
from graphon.entities import GraphInitParams
from graphon.runtime import GraphRuntimeState
from graphon.runtime import InitParams, RuntimeState
class AgentNode(Node[AgentNodeData]):
@@ -40,8 +39,8 @@ class AgentNode(Node[AgentNodeData]):
node_id: str,
data: AgentNodeData,
*,
graph_init_params: GraphInitParams,
graph_runtime_state: GraphRuntimeState,
init_params: InitParams,
runtime_state: RuntimeState,
strategy_resolver: AgentStrategyResolver,
presentation_provider: AgentStrategyPresentationProvider,
runtime_support: AgentRuntimeSupport,
@@ -50,8 +49,8 @@ class AgentNode(Node[AgentNodeData]):
super().__init__(
node_id=node_id,
data=data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
)
self._strategy_resolver = strategy_resolver
self._presentation_provider = presentation_provider
@@ -77,8 +76,8 @@ class AgentNode(Node[AgentNodeData]):
@override
@singledispatchmethod
def _dispatch( # pyrefly: ignore[missing-override-decorator]
self, event: NodeEventBase
) -> GraphNodeEventBase:
self, event: NodeEventPayload
) -> NodeEvent:
return super()._dispatch(event)
@_dispatch.register
@@ -98,7 +97,7 @@ class AgentNode(Node[AgentNodeData]):
)
@override
def _run(self) -> Generator[NodeEventBase]:
def _run(self) -> Generator[NodeEventPayload]:
from core.plugin.impl.exc import PluginDaemonClientSideError
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
@@ -123,7 +122,7 @@ class AgentNode(Node[AgentNodeData]):
parameters = self._runtime_support.build_parameters(
agent_parameters=agent_parameters,
variable_pool=self.graph_runtime_state.variable_pool,
variable_pool=self.runtime_state.variable_pool,
node_data=self.node_data,
strategy=strategy,
tenant_id=dify_ctx.tenant_id,
@@ -133,7 +132,7 @@ class AgentNode(Node[AgentNodeData]):
)
parameters_for_log = self._runtime_support.build_parameters(
agent_parameters=agent_parameters,
variable_pool=self.graph_runtime_state.variable_pool,
variable_pool=self.runtime_state.variable_pool,
node_data=self.node_data,
strategy=strategy,
tenant_id=dify_ctx.tenant_id,
@@ -144,7 +143,7 @@ class AgentNode(Node[AgentNodeData]):
)
credentials = self._runtime_support.build_credentials(parameters=parameters)
conversation_id = get_system_text(self.graph_runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
conversation_id = get_system_text(self.runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
try:
message_stream = strategy.invoke(
+4 -4
View File
@@ -3,11 +3,11 @@ from typing import Any
from pydantic import Field
from graphon.graph_events import GraphNodeEventBase
from graphon.node_events import NodeEventBase
from graphon.engine_events import NodeEvent
from graphon.node_events import NodeEventPayload
class AgentLogEvent(NodeEventBase):
class AgentLogEvent(NodeEventPayload):
message_id: str = Field(..., description="id")
label: str = Field(..., description="label")
node_execution_id: str = Field(..., description="node execution id")
@@ -19,7 +19,7 @@ class AgentLogEvent(NodeEventBase):
node_id: str = Field(..., description="node id")
class GraphAgentNodeEventBase(GraphNodeEventBase):
class GraphAgentNodeEventBase(NodeEvent):
pass
@@ -16,7 +16,7 @@ from graphon.file import File, FileTransferMethod, get_file_type_by_mime_type
from graphon.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.node_events import (
NodeEventBase,
NodeEventPayload,
NodeRunResult,
StreamChunkEvent,
StreamCompletedEvent,
@@ -45,7 +45,7 @@ class AgentMessageTransformer:
node_type: NodeType,
node_id: str,
node_execution_id: str,
) -> Generator[NodeEventBase]:
) -> Generator[NodeEventPayload]:
from core.plugin.impl.plugin import PluginInstaller
message_stream = ToolFileMessageTransformer.transform_tool_invoke_messages(
+15 -16
View File
@@ -24,6 +24,7 @@ from clients.agent_backend import (
)
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.engine_events import NodeRunPauseRequestedEvent
from graphon.enums import (
BuiltinNodeTypes,
ErrorStrategy,
@@ -31,8 +32,7 @@ from graphon.enums import (
WorkflowNodeExecutionMetadataKey,
WorkflowNodeExecutionStatus,
)
from graphon.graph_events import NodeRunPauseRequestedEvent
from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent
from graphon.node_events import NodeEventPayload, NodeRunResult, StreamCompletedEvent
from graphon.nodes.base.node import Node
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
from models.agent_config_entities import WorkflowNodeJobConfig, WorkflowOutputRoutes
@@ -57,8 +57,7 @@ from .runtime_request_builder import (
from .session_store import WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore
if TYPE_CHECKING:
from graphon.entities import GraphInitParams
from graphon.runtime import GraphRuntimeState
from graphon.runtime import InitParams, RuntimeState
logger = logging.getLogger(__name__)
@@ -87,8 +86,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
node_id: str,
data: DifyAgentNodeData,
*,
graph_init_params: GraphInitParams,
graph_runtime_state: GraphRuntimeState,
init_params: InitParams,
runtime_state: RuntimeState,
binding_resolver: WorkflowAgentBindingResolver,
runtime_request_builder: WorkflowAgentRuntimeRequestBuilder,
agent_backend_client: AgentBackendRunClient,
@@ -101,8 +100,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
super().__init__(
node_id=node_id,
data=data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
)
# The base node parses the factory's serialized payload into node_data.
if self.node_data.agent_output_routes.enabled and self.error_strategy == ErrorStrategy.DEFAULT_VALUE:
@@ -128,7 +127,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
event.extras["agent_node"] = {"version": "2", "agent_node_kind": self.node_data.agent_node_kind}
@override
def _run(self) -> Generator[NodeEventBase | NodeRunPauseRequestedEvent]:
def _run(self) -> Generator[NodeEventPayload | NodeRunPauseRequestedEvent]:
inputs: dict[str, Any] = {}
process_data: dict[str, Any] = {}
metadata: dict[str, Any] = {
@@ -155,16 +154,16 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
inputs: dict[str, Any],
process_data: dict[str, Any],
metadata: dict[str, Any],
) -> Generator[NodeEventBase | NodeRunPauseRequestedEvent]:
) -> Generator[NodeEventPayload | NodeRunPauseRequestedEvent]:
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
workflow_id = self.graph_init_params.workflow_id
workflow_id = self.init_params.workflow_id
workflow_run_id = get_system_text(
self.graph_runtime_state.variable_pool,
self.runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
)
# Chatflow sessions also carry their owning conversation.
conversation_id = get_system_text(
self.graph_runtime_state.variable_pool,
self.runtime_state.variable_pool,
SystemVariableKey.CONVERSATION_ID,
)
@@ -247,7 +246,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
workflow_run_id=workflow_run_id,
node_id=self._node_id,
node_execution_id=self.execution_id,
variable_pool=self.graph_runtime_state.variable_pool,
variable_pool=self.runtime_state.variable_pool,
binding=bundle.binding,
agent=bundle.agent,
snapshot=bundle.snapshot,
@@ -530,9 +529,9 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
return cancellation, None
def _is_graph_aborted(self) -> bool:
"""Let Agent SSE consumption observe GraphEngine's cooperative abort state."""
"""Let Agent SSE consumption observe Engine's cooperative abort state."""
try:
return self.graph_runtime_state.graph_execution.aborted
return self.runtime_state.graph_execution.aborted
except (AttributeError, RuntimeError):
return False
@@ -8,9 +8,9 @@ from typing import override
from core.app.entities.app_invoke_entities import DifyRunContext
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentWorkspaceStore
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import (
GraphEngineEvent,
from graphon.engine.layer import Layer
from graphon.engine_events import (
EngineEvent,
GraphRunAbortedEvent,
GraphRunFailedEvent,
GraphRunPartialSucceededEvent,
@@ -21,7 +21,7 @@ from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__)
class WorkflowAgentWorkspaceRetirementLayer(GraphEngineLayer):
class WorkflowAgentWorkspaceRetirementLayer(Layer):
"""Synchronously retire run Workspaces, then enqueue physical collection."""
_TERMINAL_EVENTS = (
@@ -44,11 +44,11 @@ class WorkflowAgentWorkspaceRetirementLayer(GraphEngineLayer):
return
@override
def on_event(self, event: GraphEngineEvent) -> None:
def on_event(self, event: EngineEvent) -> None:
if not isinstance(event, self._TERMINAL_EVENTS):
return
workflow_run_id = get_system_text(
self.graph_runtime_state.variable_pool,
self.runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
)
if not workflow_run_id:
@@ -21,8 +21,7 @@ from .entities import DatasourceNodeData, DatasourceParameter, OnlineDriveDownlo
from .exc import DatasourceNodeError
if TYPE_CHECKING:
from graphon.entities import GraphInitParams
from graphon.runtime import GraphRuntimeState
from graphon.runtime import InitParams, RuntimeState
class DatasourceNode(Node[DatasourceNodeData]):
@@ -38,15 +37,15 @@ class DatasourceNode(Node[DatasourceNodeData]):
node_id: str,
data: DatasourceNodeData,
*,
graph_init_params: "GraphInitParams",
graph_runtime_state: "GraphRuntimeState",
init_params: "InitParams",
runtime_state: "RuntimeState",
datasource_credentials: Callable[[str, str, str, str | None], dict[str, Any]],
) -> None:
super().__init__(
node_id=node_id,
data=data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
)
self.datasource_manager = DatasourceManager
self._datasource_credentials = datasource_credentials
@@ -63,7 +62,7 @@ class DatasourceNode(Node[DatasourceNodeData]):
"""
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
node_data = self.node_data
variable_pool = self.graph_runtime_state.variable_pool
variable_pool = self.runtime_state.variable_pool
datasource_type_segment = get_system_segment(variable_pool, SystemVariableKey.DATASOURCE_TYPE)
if not datasource_type_segment:
raise DatasourceNodeError("Datasource type is not set")
+11 -13
View File
@@ -5,17 +5,17 @@ from collections.abc import Iterable, Sequence
from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
from core.workflow.human_input_policy import resolve_variable_select_input_options
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.entities.pause_reason import HitlRequired, SchedulingPause
from graphon.enums import BuiltinNodeTypes
from graphon.filters import GraphEventFilterContext
from graphon.graph_events import (
GraphEngineEvent,
from graphon.engine.filter import EngineEventFilterContext
from graphon.engine_events import (
EngineEvent,
NodeRunHumanInputFormFilledEvent,
NodeRunHumanInputFormTimeoutEvent,
NodeRunStartedEvent,
NodeRunSucceededEvent,
)
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from graphon.entities.pause_reason import HitlRequired, SchedulingPause
from graphon.enums import BuiltinNodeTypes
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
from .constants import OUTPUT_FIELD_ACTION_ID, OUTPUT_FIELD_ACTION_VALUE, OUTPUT_FIELD_RENDERED_CONTENT, TIMEOUT_HANDLE
from .pause_reason import HumanInputRequired, PauseReason
@@ -38,11 +38,11 @@ class HumanInputFormEventFilter:
def filter_id(self) -> str:
return "dify-human-input-form-events"
def initialize(self, context: GraphEventFilterContext) -> None:
def initialize(self, context: EngineEventFilterContext) -> None:
self._node_titles.clear()
self._app_id = get_system_text(context.runtime_state.variable_pool, SystemVariableKey.APP_ID)
def on_event(self, event: GraphEngineEvent) -> Iterable[GraphEngineEvent]:
def on_event(self, event: EngineEvent) -> Iterable[EngineEvent]:
if isinstance(event, NodeRunStartedEvent) and event.node_type == BuiltinNodeTypes.HUMAN_INPUT:
self._node_titles[event.id] = event.node_title
elif isinstance(event, NodeRunSucceededEvent) and event.node_type == BuiltinNodeTypes.HUMAN_INPUT:
@@ -50,7 +50,7 @@ class HumanInputFormEventFilter:
yield event
def flush(self) -> Iterable[GraphEngineEvent]:
def flush(self) -> Iterable[EngineEvent]:
return ()
def _completion_event(
@@ -72,8 +72,7 @@ class HumanInputFormEventFilter:
node_type=event.node_type,
node_title=node_title,
node_version=event.node_version,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
container_id=event.container_id,
expiration_time=form.expiration_time,
)
@@ -83,8 +82,7 @@ class HumanInputFormEventFilter:
node_type=event.node_type,
node_title=node_title,
node_version=event.node_version,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
container_id=event.container_id,
rendered_content=result.outputs[OUTPUT_FIELD_RENDERED_CONTENT].text,
action_id=result.outputs[OUTPUT_FIELD_ACTION_ID].text,
action_text=result.outputs[OUTPUT_FIELD_ACTION_VALUE].text,
@@ -11,7 +11,7 @@ from core.repositories.human_input_repository import FormCreateParams, HumanInpu
from core.workflow.human_input_adapter import DeliveryChannelConfig
from core.workflow.node_runtime import DifyFileReferenceFactory
from graphon.nodes.human_input.entities import Completed, Expired, HITLContext, HITLDecision, PauseRequested
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
from graphon.variables.factory import build_segment
from graphon.variables.segments import Segment
from graphon.variables.template_resolution import convert_template
@@ -17,7 +17,7 @@ from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import BuiltinNodeTypes, NodeType
from graphon.file.enums import FileTransferMethod, FileType
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
from graphon.variables.consts import SELECTORS_LENGTH
from graphon.variables.segments import Segment
@@ -23,8 +23,7 @@ from .exc import (
)
if TYPE_CHECKING:
from graphon.entities import GraphInitParams
from graphon.runtime import GraphRuntimeState
from graphon.runtime import InitParams, RuntimeState
logger = logging.getLogger(__name__)
_INVOKE_FROM_DEBUGGER = "debugger"
@@ -39,14 +38,14 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
node_id: str,
data: KnowledgeIndexNodeData,
*,
graph_init_params: "GraphInitParams",
graph_runtime_state: "GraphRuntimeState",
init_params: "InitParams",
runtime_state: "RuntimeState",
) -> None:
super().__init__(
node_id=node_id,
data=data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
)
self.index_processor = IndexProcessor()
self.summary_index_service = SummaryIndex()
@@ -54,7 +53,7 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
@override
def _run(self) -> NodeRunResult:
node_data = self.node_data
variable_pool = self.graph_runtime_state.variable_pool
variable_pool = self.runtime_state.variable_pool
# get dataset id as string
dataset_id_segment = get_system_segment(variable_pool, SystemVariableKey.DATASET_ID)
@@ -17,7 +17,6 @@ from core.db.session_factory import session_factory
from core.rag.data_post_processor.data_post_processor import RerankingModelDict, WeightsDict
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
from core.workflow.file_reference import parse_file_reference
from graphon.entities import GraphInitParams
from graphon.enums import (
BuiltinNodeTypes,
WorkflowNodeExecutionMetadataKey,
@@ -27,6 +26,7 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.node_events import NodeRunResult
from graphon.nodes.base.node import Node
from graphon.runtime import InitParams
from graphon.variables import (
ArrayFileSegment,
FileSegment,
@@ -48,7 +48,7 @@ from .retrieval import KnowledgeRetrievalRequest, Source
if TYPE_CHECKING:
from graphon.file import File
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
logger = logging.getLogger(__name__)
@@ -77,15 +77,15 @@ class KnowledgeRetrievalNode(Node[KnowledgeRetrievalNodeData]):
node_id: str,
data: KnowledgeRetrievalNodeData,
*,
graph_init_params: "GraphInitParams",
graph_runtime_state: "GraphRuntimeState",
init_params: "InitParams",
runtime_state: "RuntimeState",
session_maker=None,
) -> None:
super().__init__(
node_id=node_id,
data=data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
)
# LLM file outputs, used for MultiModal outputs.
self._file_outputs = []
@@ -112,7 +112,7 @@ class KnowledgeRetrievalNode(Node[KnowledgeRetrievalNodeData]):
variables: dict[str, Any] = {}
# extract variables
if self._node_data.query_variable_selector:
variable = self.graph_runtime_state.variable_pool.get(self._node_data.query_variable_selector)
variable = self.runtime_state.variable_pool.get(self._node_data.query_variable_selector)
if not isinstance(variable, StringSegment):
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
@@ -123,7 +123,7 @@ class KnowledgeRetrievalNode(Node[KnowledgeRetrievalNodeData]):
variables["query"] = query
if self._node_data.query_attachment_selector:
variable = self.graph_runtime_state.variable_pool.get(self._node_data.query_attachment_selector)
variable = self.runtime_state.variable_pool.get(self._node_data.query_attachment_selector)
if not isinstance(variable, ArrayFileSegment) and not isinstance(variable, FileSegment):
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
@@ -309,7 +309,7 @@ class KnowledgeRetrievalNode(Node[KnowledgeRetrievalNodeData]):
conditions=None,
)
variable_pool = self.graph_runtime_state.variable_pool
variable_pool = self.runtime_state.variable_pool
resolved_conditions: list[Condition] = []
for cond in conditions.conditions or []:
value = cond.value
@@ -56,8 +56,8 @@ class TriggerEventNode(Node[TriggerEventNodeData]):
"plugin_unique_identifier": self.node_data.plugin_unique_identifier,
},
}
node_inputs = dict(self.graph_runtime_state.variable_pool.get_by_prefix(self.id))
system_inputs = self.graph_runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)
node_inputs = dict(self.runtime_state.variable_pool.get_by_prefix(self.id))
system_inputs = self.runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)
for variable_name, value in system_inputs.items():
node_inputs[f"{SYSTEM_VARIABLE_NODE_ID}.{variable_name}"] = value
@@ -34,8 +34,8 @@ class TriggerScheduleNode(Node[TriggerScheduleNodeData]):
@override
def _run(self) -> NodeRunResult:
node_inputs = dict(self.graph_runtime_state.variable_pool.get_by_prefix(self.id))
system_inputs = self.graph_runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)
node_inputs = dict(self.runtime_state.variable_pool.get_by_prefix(self.id))
system_inputs = self.runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)
for variable_name, value in system_inputs.items():
node_inputs[f"{SYSTEM_VARIABLE_NODE_ID}.{variable_name}"] = value
@@ -64,11 +64,11 @@ class TriggerWebhookNode(Node[WebhookData]):
happens in the trigger controller.
"""
# Get webhook data from variable pool (injected by Celery task)
webhook_inputs = dict(self.graph_runtime_state.variable_pool.get_by_prefix(self.id))
webhook_inputs = dict(self.runtime_state.variable_pool.get_by_prefix(self.id))
# Extract webhook-specific outputs based on node configuration
outputs = self._extract_configured_outputs(webhook_inputs)
system_inputs = self.graph_runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)
system_inputs = self.runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)
for variable_name, value in system_inputs.items():
outputs[f"{SYSTEM_VARIABLE_NODE_ID}.{variable_name}"] = value
+86 -56
View File
@@ -1,11 +1,11 @@
import logging
import time
from collections.abc import Generator, Mapping, Sequence
from collections.abc import Callable, Generator, Mapping, Sequence
from contextlib import ExitStack
from typing import Any, TypedDict
from uuid import uuid4
from configs import dify_config
from context import capture_current_context
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import (
InvokeFrom,
@@ -13,7 +13,10 @@ from core.app.entities.app_invoke_entities import (
build_dify_run_context,
)
from core.app.file_access import DatabaseFileAccessController
from core.app.layers.pause_state_persist_layer import PauseStatePersistenceLayer
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime, bind_dify_workflow_file_runtime
from core.app.workflow.layers.observability import ObservabilityLayer
from core.app.workflow.layers.persistence import WorkflowPersistenceLayer
from core.credit_usage import CreditUsageAppType
from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
from core.workflow.node_factory import (
@@ -33,32 +36,41 @@ from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add
from core.workflow.variable_prefixes import ENVIRONMENT_VARIABLE_NODE_ID
from extensions.otel.runtime import is_instrument_flag_enabled
from factories import file_factory
from graphon.engine import Engine
from graphon.engine.command import CommandChannel, InMemoryChannel
from graphon.engine.filter import EngineEventFilterContext, ResponseStreamFilter, filter_engine_events
from graphon.engine.layer import ExecutionLimitsLayer, Layer
from graphon.engine_events import EngineEvent, GraphRunFailedEvent, NodeEvent, is_node_result_event
from graphon.entities.graph_config import NodeConfigDictAdapter
from graphon.errors import WorkflowNodeRunFailedError
from graphon.file import File
from graphon.filters import GraphEventFilterContext, ResponseStreamFilter, filter_graph_events
from graphon.graph import Graph
from graphon.graph_engine import GraphEngine, GraphEngineConfig
from graphon.graph_engine.command_channels import CommandChannel, InMemoryChannel
from graphon.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer, GraphEngineLayer
from graphon.graph_events import GraphEngineEvent, GraphNodeEventBase, GraphRunFailedEvent, is_node_result_event
from graphon.nodes import BuiltinNodeTypes
from graphon.nodes.base.node import Node
from graphon.nodes.container_effects import ContainerAwaitRequest
from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool
from graphon.runtime import ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool
from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool
from models.workflow import Workflow
from services.workflow.event_ancestry import ContainerAncestry, WorkflowEventAncestry
from services.workflow.execution_lifecycle import (
WorkflowDebugLayer,
WorkflowExecutionContextLayer,
settled_workflow_events,
)
logger = logging.getLogger(__name__)
_file_access_controller = DatabaseFileAccessController()
def iter_dify_graph_engine_events(
engine: GraphEngine,
engine: Engine,
response_stream_filter: ResponseStreamFilter | None = None,
) -> Generator[GraphEngineEvent]:
*,
pause_callbacks: Sequence[Callable[[], None]] = (),
pause_failure_callbacks: Sequence[Callable[[Exception], None]] = (),
) -> Generator[EngineEvent]:
"""
Apply Dify's response streaming compatibility filter to GraphEngine events.
Apply Dify's response streaming compatibility filter to Engine events.
Graphon v0.5.0 emits raw variable stream chunks and requires callers to opt
into the legacy response-ordered stream behavior that Dify exposes to its
@@ -69,15 +81,19 @@ def iter_dify_graph_engine_events(
the filter's ``paths_map`` reflects everything the engine has actually
streamed for this run.
"""
yield from filter_graph_events(
events = filter_engine_events(
engine.run(),
context=GraphEventFilterContext.from_engine(engine),
context=EngineEventFilterContext.from_engine(engine),
filters=[
HumanInputFormEventFilter(form_repository=HumanInputFormSubmissionRepository()),
response_stream_filter or ResponseStreamFilter(),
],
)
yield from settled_workflow_events(
events, pause_callbacks=pause_callbacks, pause_failure_callbacks=pause_failure_callbacks
)
class _NodeConfigDict(TypedDict):
id: str
@@ -112,7 +128,7 @@ class WorkflowEntry:
invoke_from: InvokeFrom,
call_depth: int,
variable_pool: VariablePool,
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
command_channel: CommandChannel | None = None,
response_stream_filter: ResponseStreamFilter | None = None,
) -> None:
@@ -129,7 +145,7 @@ class WorkflowEntry:
:param invoke_from: invoke from
:param call_depth: call depth
:param variable_pool: variable pool
:param graph_runtime_state: pre-created graph runtime state
:param runtime_state: pre-created graph runtime state
:param command_channel: command channel for external control (optional, defaults to InMemoryChannel)
:param response_stream_filter: pre-restored filter for resumed runs (optional, defaults to a fresh
ResponseStreamFilter for runs with no prior pause)
@@ -146,50 +162,58 @@ class WorkflowEntry:
self.command_channel = command_channel
self._response_stream_filter = response_stream_filter or ResponseStreamFilter()
execution_context = capture_current_context()
# ponytail: Graphon snapshots omit process-local context; use a public rebind API when Graphon exposes one.
graph_runtime_state._execution_context = execution_context
self.graph_engine = GraphEngine(
workflow_id=workflow_id,
self._layers: list[Layer] = []
self._event_ancestry = WorkflowEventAncestry(graph_config)
self.graph_engine = Engine(
graph=graph,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
command_channel=command_channel,
config=GraphEngineConfig(
min_workers=dify_config.GRAPH_ENGINE_MIN_WORKERS,
max_workers=dify_config.GRAPH_ENGINE_MAX_WORKERS,
scale_up_threshold=dify_config.GRAPH_ENGINE_SCALE_UP_THRESHOLD,
scale_down_idle_time=dify_config.GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME,
),
workers=dify_config.GRAPH_ENGINE_MAX_WORKERS,
file_runtime=DifyWorkflowFileRuntime(file_access_controller=_file_access_controller),
)
# Add debug logging layer when in debug mode
self.add_layer(WorkflowExecutionContextLayer())
if dify_config.DEBUG:
logger.info("Debug mode enabled - adding DebugLoggingLayer to GraphEngine")
debug_layer = DebugLoggingLayer(
level="DEBUG",
include_inputs=True,
include_outputs=True,
include_process_data=False, # Process data can be very verbose
logger_name=f"GraphEngine.Debug.{workflow_id[:8]}", # Use workflow ID prefix for unique logger
)
self.graph_engine.layer(debug_layer)
self.add_layer(WorkflowDebugLayer())
# Add execution limits layer
limits_layer = ExecutionLimitsLayer(
max_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME
)
self.graph_engine.layer(limits_layer)
self.add_layer(limits_layer)
# Add observability layer when OTel is enabled
if dify_config.ENABLE_OTEL or is_instrument_flag_enabled():
self.graph_engine.layer(ObservabilityLayer())
self.add_layer(ObservabilityLayer())
def run(self) -> Generator[GraphEngineEvent]:
def event_ancestry(self, event: EngineEvent) -> ContainerAncestry:
return self._event_ancestry.resolve(event.container_id)
def add_layer(self, layer: Layer) -> None:
self.graph_engine.add_layer(layer)
self._layers.append(layer)
def run(self) -> Generator[EngineEvent]:
graph_engine = self.graph_engine
try:
# Preserve Dify's response-stream semantics on top of Graphon 0.5.0.
generator = iter_dify_graph_engine_events(graph_engine, self._response_stream_filter)
generator = iter_dify_graph_engine_events(
graph_engine,
self._response_stream_filter,
pause_callbacks=tuple(
layer.persist_paused_state
for layer in self._layers
if isinstance(layer, PauseStatePersistenceLayer)
)
+ tuple(
layer.persist_paused_state for layer in self._layers if isinstance(layer, WorkflowPersistenceLayer)
),
pause_failure_callbacks=tuple(
layer.fail_pause_persistence
for layer in self._layers
if isinstance(layer, WorkflowPersistenceLayer)
),
)
yield from generator
except GenerateTaskStoppedError:
pass
@@ -208,7 +232,7 @@ class WorkflowEntry:
user_inputs: Mapping[str, Any],
variable_pool: VariablePool,
variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
) -> tuple[Node, Generator[GraphNodeEventBase | ContainerAwaitRequest]]:
) -> tuple[Node, Generator[NodeEvent | ContainerAwaitRequest]]:
"""
Single step run workflow node
:param workflow: Workflow instance
@@ -246,10 +270,10 @@ class WorkflowEntry:
run_context=run_context,
call_depth=0,
)
graph_runtime_state = GraphRuntimeState(
runtime_state = RuntimeState(
variable_pool=variable_pool,
start_at=time.perf_counter(),
execution_context=capture_current_context(),
workflow_id=graph_init_context.workflow_id,
)
if is_start_node_type(node_type):
@@ -297,7 +321,7 @@ class WorkflowEntry:
# init workflow run state
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
)
node = node_factory.create_node(node_config)
@@ -363,7 +387,7 @@ class WorkflowEntry:
@classmethod
def run_free_node(
cls, node_data: dict[str, Any], node_id: str, tenant_id: str, user_id: str, user_inputs: dict[str, Any]
) -> tuple[Node, Generator[GraphNodeEventBase | ContainerAwaitRequest]]:
) -> tuple[Node, Generator[NodeEvent | ContainerAwaitRequest]]:
"""
Run free node
@@ -406,17 +430,17 @@ class WorkflowEntry:
run_context=run_context,
call_depth=0,
)
graph_runtime_state = GraphRuntimeState(
runtime_state = RuntimeState(
variable_pool=variable_pool,
start_at=time.perf_counter(),
execution_context=capture_current_context(),
workflow_id=graph_init_context.workflow_id,
)
# init workflow run state
node_config = NodeConfigDictAdapter.validate_python({"id": node_id, "data": node_data})
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
)
node = node_factory.create_node(node_config)
@@ -558,13 +582,13 @@ class WorkflowEntry:
variable_pool.add([variable_node_id] + variable_key_list, input_value)
@staticmethod
def _run_node_with_layers(node: Node, *, tenant_id: str) -> Generator[GraphNodeEventBase | ContainerAwaitRequest]:
def _run_node_with_layers(node: Node, *, tenant_id: str) -> Generator[NodeEvent | ContainerAwaitRequest]:
"""
Run a standalone node with the same quota and observability hooks as GraphEngine.
Run a standalone node with the same quota and observability hooks as Engine.
"""
layers: Sequence[GraphEngineLayer] = (ObservabilityLayer(),)
layers: Sequence[Layer] = (ObservabilityLayer(),)
command_channel = InMemoryChannel()
runtime_state = ReadOnlyGraphRuntimeStateWrapper(node.graph_runtime_state)
runtime_state = ReadOnlyRuntimeStateWrapper(node.runtime_state)
for layer in layers:
layer.initialize(runtime_state, command_channel)
layer.on_graph_start()
@@ -572,8 +596,14 @@ class WorkflowEntry:
node.bind_execution_id(str(uuid4()))
def _gen():
with bind_dify_workflow_file_runtime(), ExitStack() as stack:
for layer in layers:
stack.enter_context(layer.node_run_context(node))
yield from _run_scoped()
def _run_scoped():
error: Exception | None = None
result_event: GraphNodeEventBase | None = None
result_event: NodeEvent | None = None
layers_finished = False
def finish_layers() -> None:
@@ -590,7 +620,7 @@ class WorkflowEntry:
for layer in layers:
layer.on_node_run_start(node)
for event in node.run():
if isinstance(event, GraphNodeEventBase) and is_node_result_event(event):
if isinstance(event, NodeEvent) and is_node_result_event(event):
result_event = event
finish_layers()
yield event
+36 -1
View File
@@ -1,8 +1,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import sys
from contextlib import AbstractContextManager
from typing import TYPE_CHECKING, override
from flask import Flask
from flask.ctx import AppContext
if TYPE_CHECKING:
from extensions.ext_login import DifyLoginManager
@@ -12,3 +15,35 @@ class DifyApp(Flask):
"""Flask application type with Dify-specific extension attributes."""
login_manager: DifyLoginManager
@override
def app_context(self) -> AppContext:
return _DifyAppContext(self)
class _DifyAppContext(AppContext):
"""Own file rendering for requests and background jobs using this application."""
def __init__(self, app: Flask) -> None:
super().__init__(app)
self._file_scopes: list[AbstractContextManager[None]] = []
@override
def push(self) -> None:
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
super().push()
scope = bind_dify_workflow_file_runtime()
try:
scope.__enter__()
except BaseException:
super().pop(sys.exc_info()[1])
raise
self._file_scopes.append(scope)
@override
def pop(self, exc: BaseException | None = None) -> None:
try:
super().pop(exc if exc is not None else sys.exc_info()[1])
finally:
self._file_scopes.pop().__exit__(None, None, None)
-3
View File
@@ -141,6 +141,3 @@ storage = Storage()
def init_app(app: DifyApp):
storage.init_app(app)
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
bind_dify_workflow_file_runtime()
+3 -3
View File
@@ -17,9 +17,9 @@ from pydantic import BaseModel
from configs import dify_config
from enums import DeploymentEdition
from extensions.otel.semconv.gen_ai import ChainAttributes, GenAIAttributes
from graphon.engine_events import NodeEvent
from graphon.enums import BuiltinNodeTypes
from graphon.file import File
from graphon.graph_events import GraphNodeEventBase
from graphon.nodes.base.node import Node
from graphon.variables import Segment
@@ -90,7 +90,7 @@ class NodeOTelParser(Protocol):
"""Parser interface for node-specific OpenTelemetry enrichment."""
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
self, *, node: Node, span: "Span", error: Exception | None, result_event: NodeEvent | None = None
) -> None: ...
@@ -98,7 +98,7 @@ class DefaultNodeOTelParser:
"""Fallback parser used when no node-specific parser is registered."""
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
self, *, node: Node, span: "Span", error: Exception | None, result_event: NodeEvent | None = None
) -> None:
span.set_attribute("node.id", node.id)
if node.execution_id:
+2 -2
View File
@@ -10,7 +10,7 @@ from opentelemetry.trace import Span
from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps
from extensions.otel.semconv.gen_ai import LLMAttributes
from graphon.graph_events import GraphNodeEventBase
from graphon.engine_events import NodeEvent
from graphon.nodes.base.node import Node
logger = logging.getLogger(__name__)
@@ -99,7 +99,7 @@ class LLMNodeOTelParser:
self._delegate = DefaultNodeOTelParser()
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
self, *, node: Node, span: "Span", error: Exception | None, result_event: NodeEvent | None = None
) -> None:
self._delegate.parse(node=node, span=span, error=error, result_event=result_event)
+2 -2
View File
@@ -10,7 +10,7 @@ from opentelemetry.trace import Span
from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps
from extensions.otel.semconv.gen_ai import RetrieverAttributes
from graphon.graph_events import GraphNodeEventBase
from graphon.engine_events import NodeEvent
from graphon.nodes.base.node import Node
from graphon.variables import Segment
@@ -72,7 +72,7 @@ class RetrievalNodeOTelParser:
self._delegate = DefaultNodeOTelParser()
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
self, *, node: Node, span: "Span", error: Exception | None, result_event: NodeEvent | None = None
) -> None:
self._delegate.parse(node=node, span=span, error=error, result_event=result_event)
+2 -2
View File
@@ -6,8 +6,8 @@ from opentelemetry.trace import Span
from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps
from extensions.otel.semconv.gen_ai import ToolAttributes
from graphon.engine_events import NodeEvent
from graphon.enums import WorkflowNodeExecutionMetadataKey
from graphon.graph_events import GraphNodeEventBase
from graphon.nodes.base.node import Node
from graphon.nodes.tool.entities import ToolNodeData
@@ -19,7 +19,7 @@ class ToolNodeOTelParser:
self._delegate = DefaultNodeOTelParser()
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
self, *, node: Node, span: "Span", error: Exception | None, result_event: NodeEvent | None = None
) -> None:
self._delegate.parse(node=node, span=span, error=error, result_event=result_event)
+8 -2
View File
@@ -157,7 +157,10 @@ def build_icon_url(icon_type: Any, icon: str | None) -> str | None:
icon_type_value = icon_type.value if isinstance(icon_type, IconType) else str(icon_type)
if icon_type_value.lower() != IconType.IMAGE:
return None
return file_helpers.get_signed_file_url(icon)
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
with bind_dify_workflow_file_runtime():
return file_helpers.get_signed_file_url(icon)
def build_avatar_url(avatar: str | None) -> str | None:
@@ -165,7 +168,10 @@ def build_avatar_url(avatar: str | None) -> str | None:
return None
if avatar.startswith(("http://", "https://")):
return avatar
return file_helpers.get_signed_file_url(avatar)
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
with bind_dify_workflow_file_runtime():
return file_helpers.get_signed_file_url(avatar)
class TimestampField(fields.Raw):
+1 -1
View File
@@ -2118,7 +2118,7 @@ class WorkflowPause(DefaultFieldsDCMixin, TypeBase):
)
# state_object_key stores the object key referencing the serialized runtime state
# of the `GraphEngine`. This object captures the complete execution context of the
# of the `Engine`. This object captures the complete execution context of the
# workflow at the moment it was paused, enabling accurate resumption.
state_object_key: Mapped[str] = mapped_column(String(length=255), nullable=False)
+3 -1
View File
@@ -40,12 +40,14 @@ dependencies = [
"opentelemetry-instrumentation-redis==0.65b0",
"opentelemetry-instrumentation-sqlalchemy==0.65b0",
"opentelemetry-propagator-b3>=1.45.0,<2.0.0",
"pypandoc==1.17; sys_platform == 'win32'",
"pypandoc-binary==1.17; sys_platform != 'win32'",
"readabilipy==0.3.0",
"resend>=2.27.0,<3.0.0",
"zstandard==0.25.0",
# Emerging: newer and fast-moving, use compatible pins
"fastopenapi[flask]==0.7.0",
"graphon==0.7.0",
"graphon==0.8.0",
"httpx-sse==0.4.3",
"json-repair==0.60.1",
]
+4 -1
View File
@@ -30,4 +30,7 @@ class SQLAlchemyAccountAvatarFileGateway(AccountAvatarFileGateway):
if owned_file_id is None:
return None
return file_helpers.get_signed_file_url(upload_file_id=owned_file_id)
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
with bind_dify_workflow_file_runtime():
return file_helpers.get_signed_file_url(upload_file_id=owned_file_id)
+7 -7
View File
@@ -2,15 +2,15 @@
This service provides centralized logic for task control operations
like stopping tasks, handling both legacy Redis flag mechanism and
new GraphEngine command channel mechanism.
new Engine command channel mechanism.
"""
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.execution_coordinator import set_app_task_stop_flag
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_redis import RedisClientWrapper, redis_client
from graphon.graph_engine.manager import GraphEngineManager
from models.model import AppMode
from services.workflow.command_service import send_workflow_stop_command
class AppTaskControlService:
@@ -30,7 +30,7 @@ class AppTaskControlService:
This method handles stopping tasks using both mechanisms:
1. Legacy Redis flag mechanism (for backward compatibility)
2. New GraphEngine command channel (for workflow-based apps)
2. New Engine command channel (for workflow-based apps)
Args:
task_id: The task ID to stop
@@ -44,19 +44,19 @@ class AppTaskControlService:
# Legacy mechanism: Set stop flag in Redis
AppQueueManager.set_stop_flag(task_id, invoke_from, user_id, redis=self._redis_client)
# New mechanism: Send stop command via GraphEngine for workflow-based apps
# New mechanism: Send stop command via Engine for workflow-based apps
# This ensures proper workflow status recording in the persistence layer
if app_mode in (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW):
GraphEngineManager(self._redis_client).send_stop_command(task_id)
send_workflow_stop_command(task_id=task_id, redis=self._redis_client)
def stop_workflow_task_no_user_check(self, *, task_id: str) -> None:
"""Stop a workflow after app admission, without consulting the user ownership cache.
Keep the legacy stop flag before the GraphEngine command, including when
Keep the legacy stop flag before the Engine command, including when
the latter fails. Callers must authorize access to the workflow app first.
"""
set_app_task_stop_flag(task_id, redis=self._redis_client)
GraphEngineManager(self._redis_client).send_stop_command(task_id)
send_workflow_stop_command(task_id=task_id, redis=self._redis_client)
class AppTaskService:
@@ -11,6 +11,7 @@ from typing import Any, Protocol, override
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import DifyRunContext
from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler
from core.db.session_factory import session_factory
from core.tools.entities.tool_entities import ToolInvokeFrom, ToolInvokeMessage, ToolProviderType
@@ -182,7 +183,7 @@ class QualifiedJsonToolNodeRuntime(DifyToolNodeRuntime):
def __init__(
self,
run_context: Mapping[str, Any],
run_context: DifyRunContext | Mapping[str, Any],
*,
native_run_id: str,
native_task_id: str,
@@ -202,6 +203,7 @@ class QualifiedJsonToolNodeRuntime(DifyToolNodeRuntime):
self._healthy = True
@staticmethod
@override
def _require_no_transaction(session: Session) -> None:
if session.in_transaction():
raise BuilderExecutionPolicyError("json_tool_session_in_transaction")
+8 -2
View File
@@ -163,7 +163,10 @@ class FileService:
session.commit()
if not upload_file.source_url:
upload_file.source_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id)
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
with bind_dify_workflow_file_runtime():
upload_file.source_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id)
return upload_file
@@ -285,7 +288,10 @@ class FileService:
raise NotFound("File not found")
except NotFound as exc:
raise FileNotExistsError("File reference not found") from exc
return file_helpers.get_signed_file_url(upload_file_id=file_id)
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
with bind_dify_workflow_file_runtime():
return file_helpers.get_signed_file_url(upload_file_id=file_id)
def upload_text(self, text: str, text_name: str, user_id: str, tenant_id: str) -> UploadFile:
if len(text_name) > 200:
+3 -3
View File
@@ -30,8 +30,8 @@ from core.workflow.nodes.human_input.entities import (
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
from factories.file_factory import build_from_mapping, build_from_mappings
from graphon.file import FileUploadConfig
from graphon.runtime import GraphRuntimeState
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from graphon.runtime import RuntimeState
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
from libs.datetime_utils import ensure_naive_utc, naive_utc_now
from libs.exception import BaseHTTPException
from models.human_input import RecipientType
@@ -298,7 +298,7 @@ class HumanInputService:
return None
resumption_context = WorkflowResumptionContext.loads(pause_entity.get_state().decode())
runtime_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
runtime_state = RuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
return runtime_state.variable_pool
+3 -3
View File
@@ -45,10 +45,10 @@ from core.workflow.variable_pool_initializer import add_variables_to_pool
from core.workflow.workflow_entry import WorkflowEntry
from enterprise.telemetry.draft_trace import enqueue_draft_node_execution_trace
from extensions.ext_database import db
from graphon.engine_events import NodeEvent, NodeRunFailedEvent, NodeRunSucceededEvent
from graphon.entities import WorkflowNodeExecution
from graphon.enums import BuiltinNodeTypes, ErrorStrategy, NodeType, WorkflowNodeExecutionStatus
from graphon.errors import WorkflowNodeRunFailedError
from graphon.graph_events import GraphNodeEventBase, NodeRunFailedEvent, NodeRunSucceededEvent
from graphon.node_events import NodeRunResult
from graphon.nodes.base.node import Node
from graphon.nodes.container_effects import ContainerAwaitRequest
@@ -949,7 +949,7 @@ class RagPipelineService:
self,
getter: Callable[
[],
tuple[Node, Generator[GraphNodeEventBase | ContainerAwaitRequest]],
tuple[Node, Generator[NodeEvent | ContainerAwaitRequest]],
],
start_at: float,
tenant_id: str,
@@ -1048,7 +1048,7 @@ class RagPipelineService:
workflow_node_execution.status = WorkflowNodeExecutionStatus.FAILED
workflow_node_execution.error = error
# update document status
variable_pool = node_instance.graph_runtime_state.variable_pool
variable_pool = node_instance.runtime_state.variable_pool
invoke_from = get_system_segment(variable_pool, SystemVariableKey.INVOKE_FROM)
if invoke_from:
if invoke_from.value == InvokeFrom.PUBLISHED_PIPELINE:
+17 -14
View File
@@ -4,6 +4,7 @@ from collections.abc import Iterator
from typing import NamedTuple, Protocol
from constants import IMAGE_EXTENSIONS
from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
from graphon.file import helpers as file_helpers
from services.errors.file import UnsupportedFileTypeError
@@ -55,13 +56,14 @@ class UploadFileDeliveryService:
nonce: str,
sign: str,
) -> UploadFileDelivery:
if not file_helpers.verify_image_signature(
upload_file_id=file_id,
timestamp=timestamp,
nonce=nonce,
sign=sign,
):
raise UploadFileDeliveryNotFoundError("File not found or signature is invalid")
with bind_dify_workflow_file_runtime():
if not file_helpers.verify_image_signature(
upload_file_id=file_id,
timestamp=timestamp,
nonce=nonce,
sign=sign,
):
raise UploadFileDeliveryNotFoundError("File not found or signature is invalid")
file = self._get_file(file_id=file_id)
self._ensure_image(file=file)
@@ -78,13 +80,14 @@ class UploadFileDeliveryService:
nonce: str,
sign: str,
) -> UploadFileDelivery:
if not file_helpers.verify_file_signature(
upload_file_id=file_id,
timestamp=timestamp,
nonce=nonce,
sign=sign,
):
raise UploadFileDeliveryNotFoundError("File not found or signature is invalid")
with bind_dify_workflow_file_runtime():
if not file_helpers.verify_file_signature(
upload_file_id=file_id,
timestamp=timestamp,
nonce=nonce,
sign=sign,
):
raise UploadFileDeliveryNotFoundError("File not found or signature is invalid")
file = self._get_file(file_id=file_id)
return UploadFileDelivery(
+18
View File
@@ -0,0 +1,18 @@
"""Dify stop-command behavior over the public Graphon command channel."""
import logging
from extensions.ext_redis import RedisClientWrapper
from graphon.engine.command import AbortCommand, RedisChannel
logger = logging.getLogger(__name__)
def send_workflow_stop_command(*, task_id: str, redis: RedisClientWrapper) -> None:
"""Keep stop requests best-effort after the durable legacy stop flag is set."""
if not task_id:
return
try:
RedisChannel(redis, f"workflow:{task_id}:commands").send_command(AbortCommand(reason="User requested stop"))
except Exception:
logger.exception("Failed to send stop command for workflow task %s", task_id)
+42
View File
@@ -0,0 +1,42 @@
"""Project native container ownership into Dify's response metadata."""
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from graphon.enums import BuiltinNodeTypes
from graphon.graph.scoping import resolve_container_id
@dataclass(frozen=True)
class ContainerAncestry:
iteration_id: str | None = None
loop_id: str | None = None
class WorkflowEventAncestry:
"""Resolve nearest typed ancestors without conflating occurrence IDs and node IDs.
Graphon event.id remains the native occurrence key. container_id is topology;
traversal frame_id remains the execution frame identity and is never substituted
with a container node ID.
"""
def __init__(self, graph_config: Mapping[str, Any]) -> None:
self._nodes = {node["id"]: node for node in graph_config.get("nodes", []) if node.get("type") != "custom-note"}
def resolve(self, container_id: str) -> ContainerAncestry:
iteration_id = loop_id = None
visited: set[str] = set()
while container_id:
if container_id in visited or container_id not in self._nodes:
raise ValueError("Invalid workflow event container ancestry")
visited.add(container_id)
node = self._nodes[container_id]
node_type = node["data"]["type"]
if node_type == BuiltinNodeTypes.ITERATION and iteration_id is None:
iteration_id = container_id
if node_type == BuiltinNodeTypes.LOOP and loop_id is None:
loop_id = container_id
container_id = resolve_container_id(node, nodes_by_id=self._nodes)
return ContainerAncestry(iteration_id=iteration_id, loop_id=loop_id)
@@ -0,0 +1,63 @@
"""Host execution scopes and the post-filter pause persistence boundary."""
import logging
from collections.abc import Callable, Generator, Iterable, Sequence
from contextlib import contextmanager
from typing import override
from context import capture_current_context
from graphon.engine.layer import Layer
from graphon.engine_events import EngineEvent, GraphRunPausedEvent
from graphon.file.runtime import peek_workflow_file_runtime, use_workflow_file_runtime
from graphon.nodes.base.node import Node
logger = logging.getLogger(__name__)
class WorkflowExecutionContextLayer(Layer):
"""Restore host context separately for every worker segment, including resumes."""
def __init__(self) -> None:
super().__init__()
self._context = capture_current_context()
@contextmanager
@override
def node_run_context(self, node: Node, *, parent_execution_id: str | None = None) -> Generator[None]:
file_runtime = peek_workflow_file_runtime()
with self._context, use_workflow_file_runtime(file_runtime):
yield
class WorkflowDebugLayer(Layer):
@override
def on_event(self, event: EngineEvent) -> None:
logger.debug("Workflow event %s", event)
def settled_workflow_events(
events: Iterable[EngineEvent],
*,
pause_callbacks: Sequence[Callable[[], None]],
pause_failure_callbacks: Sequence[Callable[[Exception], None]] = (),
) -> Generator[EngineEvent]:
"""Expose paused only after engine quiescence, filter flush, and durable persistence.
Callbacks execute outside Graphon's isolated layer hooks so persistence failures
reach the host runner. Closing a partially consumed stream never checkpoints it.
"""
paused: GraphRunPausedEvent | None = None
for event in events:
if isinstance(event, GraphRunPausedEvent):
paused = event
else:
yield event
if paused is not None:
try:
for callback in pause_callbacks:
callback()
except Exception as error:
for callback in pause_failure_callbacks:
callback(error)
raise
yield paused
@@ -40,8 +40,8 @@ from core.workflow.nodes.human_input.pause_reason import (
)
from graphon.entities import WorkflowStartReason
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.runtime import GraphRuntimeState
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from graphon.runtime import RuntimeState
from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool
from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter
from libs.broadcast_channel.exc import SubscriptionClosedError
from libs.datetime_utils import to_utc_timestamp
@@ -511,7 +511,7 @@ def _load_variable_pool_from_resumption_context(
) -> ReadOnlyVariablePool | None:
if resumption_context is None:
return None
state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
state = RuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
return state.variable_pool
@@ -565,7 +565,7 @@ def _build_pause_event(
outputs: dict[str, Any] = {}
variable_pool: ReadOnlyVariablePool | None = None
if resumption_context is not None:
state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
state = RuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
outputs = dict(WorkflowRuntimeTypeConverter().to_json_encodable(state.outputs or {}))
variable_pool = state.variable_pool
+3 -3
View File
@@ -55,6 +55,7 @@ from events.app_event import app_draft_workflow_was_synced, app_published_workfl
from extensions.ext_database import db
from extensions.ext_storage import storage
from factories.file_factory import build_from_mapping, build_from_mappings
from graphon.engine_events import NodeEvent, NodeRunFailedEvent, NodeRunSucceededEvent
from graphon.entities import WorkflowNodeExecution
from graphon.entities.graph_config import NodeConfigDict
from graphon.enums import (
@@ -65,7 +66,6 @@ from graphon.enums import (
)
from graphon.errors import WorkflowNodeRunFailedError
from graphon.file import File
from graphon.graph_events import GraphNodeEventBase, NodeRunFailedEvent, NodeRunSucceededEvent
from graphon.node_events import NodeRunResult
from graphon.nodes import BuiltinNodeTypes
from graphon.nodes.base.node import Node
@@ -1703,7 +1703,7 @@ class WorkflowService:
self,
invoke_node_fn: Callable[
[],
tuple[Node, Generator[GraphNodeEventBase | ContainerAwaitRequest]],
tuple[Node, Generator[NodeEvent | ContainerAwaitRequest]],
],
start_at: float,
node_id: str,
@@ -1743,7 +1743,7 @@ class WorkflowService:
self,
invoke_node_fn: Callable[
[],
tuple[Node, Generator[GraphNodeEventBase | ContainerAwaitRequest]],
tuple[Node, Generator[NodeEvent | ContainerAwaitRequest]],
],
) -> tuple[Node, NodeRunResult | None, bool, str | None]:
"""
@@ -25,10 +25,10 @@ from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, Wor
from core.dify_builder.execution_policy import BuilderExecutionContextValue, BuilderExecutionPolicyError
from core.repositories import DifyCoreRepositoryFactory
from extensions.ext_database import db
from graphon.engine.filter import ResponseStreamFilter
from graphon.entities import WorkflowStartReason
from graphon.enums import WorkflowExecutionStatus
from graphon.filters import ResponseStreamFilter
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
from libs.datetime_utils import naive_utc_now
from libs.flask_utils import set_login_user
from libs.helper import to_timestamp
@@ -526,7 +526,7 @@ def _resume_app_execution(payload: dict[str, Any]) -> None:
if generate_entity.builder_execution is not None:
raise BuilderExecutionPolicyError("unsupported_execution_transport")
graph_runtime_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
runtime_state = RuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
response_stream_filter = resumption_context.get_response_stream_filter()
conversation = None
@@ -619,7 +619,7 @@ def _resume_app_execution(payload: dict[str, Any]) -> None:
conversation=conversation,
message=message,
generate_entity=generate_entity,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
response_stream_filter=response_stream_filter,
session_factory=session_factory,
pause_state_config=pause_config,
@@ -633,7 +633,7 @@ def _resume_app_execution(payload: dict[str, Any]) -> None:
workflow=workflow,
user=user,
generate_entity=generate_entity,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
response_stream_filter=response_stream_filter,
session_factory=session_factory,
pause_state_config=pause_config,
@@ -652,7 +652,7 @@ def _resume_advanced_chat(
conversation: Conversation,
message: Message,
generate_entity: AdvancedChatAppGenerateEntity,
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
response_stream_filter: ResponseStreamFilter,
session_factory: sessionmaker,
pause_state_config: PauseStateLayerConfig,
@@ -694,7 +694,7 @@ def _resume_advanced_chat(
application_generate_entity=resumed_generate_entity,
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
pause_state_config=pause_state_config,
response_stream_filter=response_stream_filter,
session=session,
@@ -720,7 +720,7 @@ def _resume_workflow(
workflow: Workflow,
user: Account | EndUser,
generate_entity: WorkflowAppGenerateEntity,
graph_runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
response_stream_filter: ResponseStreamFilter,
session_factory: sessionmaker,
pause_state_config: PauseStateLayerConfig,
@@ -759,7 +759,7 @@ def _resume_workflow(
workflow=workflow,
user=user,
application_generate_entity=resumed_generate_entity,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
pause_state_config=pause_state_config,
+3 -3
View File
@@ -23,7 +23,7 @@ from core.app.layers.trigger_post_layer import TriggerPostLayer
from core.db.session_factory import session_factory
from core.repositories import DifyCoreRepositoryFactory
from extensions.ext_database import db
from graphon.runtime import GraphRuntimeState
from graphon.runtime import RuntimeState
from models.account import Account
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom, WorkflowTriggerStatus
from models.model import App, EndUser, Tenant
@@ -224,7 +224,7 @@ def resume_workflow_execution(task_data_dict: dict[str, Any]) -> None:
)
return
graph_runtime_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
runtime_state = RuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
response_stream_filter = resumption_context.get_response_stream_filter()
with session_factory() as session:
@@ -285,7 +285,7 @@ def resume_workflow_execution(task_data_dict: dict[str, Any]) -> None:
workflow=workflow,
user=user,
application_generate_entity=generate_entity,
graph_runtime_state=graph_runtime_state,
runtime_state=runtime_state,
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
graph_engine_layers=graph_engine_layers,
+3 -3
View File
@@ -13,7 +13,7 @@ from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
from core.workflow.human_input_adapter import EmailDeliveryConfig, EmailDeliveryMethod
from extensions.ext_database import db
from extensions.ext_mail import mail
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from models.human_input import (
DeliveryMethodType,
HumanInputDelivery,
@@ -139,8 +139,8 @@ def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None:
logger.exception("Failed to load resumption context for workflow run %s", workflow_run_id)
return None
graph_runtime_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
return graph_runtime_state.variable_pool
runtime_state = RuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
return runtime_state.variable_pool
def _open_session(session_factory: sessionmaker | Session | None):
+3 -2
View File
@@ -4,5 +4,6 @@ from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime
@pytest.fixture(autouse=True)
def _bind_workflow_file_runtime() -> None:
bind_dify_workflow_file_runtime()
def _bind_workflow_file_runtime():
with bind_dify_workflow_file_runtime():
yield
@@ -15,7 +15,7 @@ from graphon.node_events import NodeRunResult
from graphon.nodes.code.code_node import CodeNode
from graphon.nodes.code.entities import CodeNodeData
from graphon.nodes.code.limits import CodeNodeLimits
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from tests.workflow_test_utils import build_test_graph_init_params
pytest_plugins = ("tests.integration_tests.workflow.nodes.__mock.code_executor",)
@@ -44,14 +44,14 @@ def _init_code_node(code_config: dict) -> CodeNode:
)
variable_pool.add(["code", "args1"], 1)
variable_pool.add(["code", "args2"], 2)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
node_factory = DifyNodeFactory(graph_init_params=init_params, graph_runtime_state=graph_runtime_state)
runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=time.perf_counter())
node_factory = DifyNodeFactory(init_params=init_params, runtime_state=runtime_state)
Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start")
return CodeNode(
node_id=str(uuid.uuid4()),
data=CodeNodeData.model_validate(code_config["data"]),
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
code_executor=node_factory._code_executor,
code_limits=CodeNodeLimits(
max_string_length=dify_config.CODE_MAX_STRING_LENGTH,
@@ -90,8 +90,8 @@ def _code_config(code: str, output_type: str, *, variables: bool = True) -> dict
def test_execute_code(setup_code_executor_mock) -> None:
code = "def main(args1: int, args2: int):\n return {'result': args1 + args2}"
node = _init_code_node(_code_config(code, "number"))
node.graph_runtime_state.variable_pool.add(["1", "args1"], 1)
node.graph_runtime_state.variable_pool.add(["1", "args2"], 2)
node.runtime_state.variable_pool.add(["1", "args1"], 1)
node.runtime_state.variable_pool.add(["1", "args2"], 2)
result = node._run()
@@ -106,8 +106,8 @@ def test_execute_code(setup_code_executor_mock) -> None:
def test_execute_code_output_validator(setup_code_executor_mock) -> None:
code = "def main(args1: int, args2: int):\n return {'result': args1 + args2}"
node = _init_code_node(_code_config(code, "string"))
node.graph_runtime_state.variable_pool.add(["1", "args1"], 1)
node.graph_runtime_state.variable_pool.add(["1", "args2"], 2)
node.runtime_state.variable_pool.add(["1", "args1"], 1)
node.runtime_state.variable_pool.add(["1", "args2"], 2)
result = node._run()
@@ -15,7 +15,7 @@ from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus
from graphon.file.file_manager import file_manager
from graphon.graph import Graph
from graphon.nodes.http_request import HttpRequestNode, HttpRequestNodeConfig, HttpRequestNodeData
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from tests.workflow_test_utils import build_test_graph_init_params
pytest_plugins = ("tests.integration_tests.workflow.nodes.__mock.http",)
@@ -64,12 +64,12 @@ def init_http_node(config: dict):
variable_pool.add(["a", "args1"], 1)
variable_pool.add(["a", "args2"], 2)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=time.perf_counter())
# Create node factory
node_factory = DifyNodeFactory(
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
)
graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start")
@@ -77,8 +77,8 @@ def init_http_node(config: dict):
node = HttpRequestNode(
node_id=str(uuid.uuid4()),
data=HttpRequestNodeData.model_validate(config["data"]),
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
http_request_config=HTTP_REQUEST_CONFIG,
http_client=ssrf_proxy,
tool_file_manager_factory=ToolFileManager,
@@ -711,12 +711,12 @@ def test_nested_object_variable_selector(setup_http_mock):
variable_pool.add(["a", "args2"], 2)
variable_pool.add(["a", "args3"], {"nested": "nested_value"}) # Only for this test
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=time.perf_counter())
# Create node factory
node_factory = DifyNodeFactory(
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
)
graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start")
@@ -724,8 +724,8 @@ def test_nested_object_variable_selector(setup_http_mock):
node = HttpRequestNode(
node_id=str(uuid.uuid4()),
data=HttpRequestNodeData.model_validate(graph_config["nodes"][1]["data"]),
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
init_params=init_params,
runtime_state=runtime_state,
http_request_config=HTTP_REQUEST_CONFIG,
http_client=ssrf_proxy,
tool_file_manager_factory=ToolFileManager,
@@ -22,7 +22,7 @@ from core.workflow.nodes.human_input.entities import (
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.human_input import (
@@ -108,7 +108,7 @@ def _build_resumption_context(*, app: App, workflow_run: WorkflowRun, options: l
call_depth=0,
workflow_execution_id=workflow_run.id,
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0)
runtime_state.variable_pool.add(("start", "options"), options)
return WorkflowResumptionContext(
generate_entity=_WorkflowGenerateEntityWrapper(entity=generate_entity),
@@ -11,7 +11,7 @@ This test suite covers complete integration scenarios including:
- Workflow status transitions in database
- Error handling with real database constraints
- Multiple pause events in sequence
- Integration with real ReadOnlyGraphRuntimeState implementations
- Integration with real ReadOnlyRuntimeState implementations
These tests use TestContainers to spin up real services for integration testing,
providing more reliable and realistic test scenarios than mocks.
@@ -33,14 +33,13 @@ from core.app.layers.pause_state_persist_layer import (
)
from core.workflow.system_variables import build_system_variables
from extensions.ext_storage import storage
from graphon.engine.command import Command
from graphon.engine.filter import EngineEventFilterContext, ResponseStreamFilter
from graphon.engine_events import GraphRunPausedEvent
from graphon.entities.pause_reason import SchedulingPause
from graphon.enums import WorkflowExecutionStatus
from graphon.filters import GraphEventFilterContext, ResponseStreamFilter
from graphon.graph_engine.entities.commands import GraphEngineCommand
from graphon.graph_engine.layers.base import GraphEngineLayerNotInitializedError
from graphon.graph_events import GraphRunPausedEvent
from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool
from graphon.runtime import ReadOnlyRuntimeState, ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool
from libs.datetime_utils import naive_utc_now
from models import Account
from models import WorkflowPause as WorkflowPauseModel
@@ -56,14 +55,14 @@ def _create_initialized_response_stream_filter() -> ResponseStreamFilter:
"""Build a `ResponseStreamFilter` that has already run `initialize()`.
`ResponseStreamFilter.dumps()` raises `RuntimeError` unless the filter has
processed a `GraphEventFilterContext` first. In production this always
processed a `EngineEventFilterContext` first. In production this always
happens before any event (including `GraphRunPausedEvent`) reaches
`PauseStatePersistenceLayer.on_event`, so tests that exercise `on_event`
or a subsequent `dumps()` call need a filter in that same state. A
nodeless graph is enough to satisfy the precondition.
"""
response_stream_filter = ResponseStreamFilter()
context = GraphEventFilterContext(graph=Mock(nodes={}), runtime_state=Mock())
context = EngineEventFilterContext(graph=Mock(nodes={}), runtime_state=Mock())
response_stream_filter.initialize(context)
return response_stream_filter
@@ -72,14 +71,14 @@ class _TestCommandChannelImpl:
"""Real implementation of CommandChannel for testing."""
def __init__(self):
self._commands: list[GraphEngineCommand] = []
self._commands: list[Command] = []
def fetch_commands(self) -> list[GraphEngineCommand]:
"""Fetch pending commands for this GraphEngine instance."""
def fetch_commands(self) -> list[Command]:
"""Fetch pending commands for this Engine instance."""
return self._commands.copy()
def send_command(self, command: GraphEngineCommand) -> None:
"""Send a command to be processed by this GraphEngine instance."""
def send_command(self, command: Command) -> None:
"""Send a command to be processed by this Engine instance."""
self._commands.append(command)
@@ -229,8 +228,8 @@ class TestPauseStatePersistenceLayerTestContainers:
node_run_steps: int = 0,
variables: dict[tuple[str, str], object] | None = None,
workflow_run_id: str | None = None,
) -> ReadOnlyGraphRuntimeState:
"""Create a real GraphRuntimeState for testing."""
) -> ReadOnlyRuntimeState:
"""Create a real RuntimeState for testing."""
start_at = time()
execution_id = workflow_run_id or getattr(self, "test_workflow_run_id", None) or str(uuid.uuid4())
@@ -248,7 +247,8 @@ class TestPauseStatePersistenceLayerTestContainers:
llm_usage.total_tokens = total_tokens
# Create graph runtime state
graph_runtime_state = GraphRuntimeState(
runtime_state = RuntimeState(
workflow_id="test-workflow",
variable_pool=variable_pool,
start_at=start_at,
llm_usage=llm_usage,
@@ -256,7 +256,7 @@ class TestPauseStatePersistenceLayerTestContainers:
node_run_steps=node_run_steps,
)
return ReadOnlyGraphRuntimeStateWrapper(graph_runtime_state)
return ReadOnlyRuntimeStateWrapper(runtime_state)
def _create_generate_entity(
self,
@@ -335,7 +335,7 @@ class TestPauseStatePersistenceLayerTestContainers:
("node1", "var1"): "string_value",
("node2", "var2"): {"complex": "object"},
}
graph_runtime_state = self._create_graph_runtime_state(
runtime_state = self._create_graph_runtime_state(
outputs=test_outputs,
total_tokens=100,
node_run_steps=5,
@@ -343,7 +343,7 @@ class TestPauseStatePersistenceLayerTestContainers:
)
command_channel = _TestCommandChannelImpl()
layer.initialize(graph_runtime_state, command_channel)
layer.initialize(runtime_state, command_channel)
# Create pause event
event = GraphRunPausedEvent(
@@ -353,6 +353,7 @@ class TestPauseStatePersistenceLayerTestContainers:
# Act
layer.on_event(event)
layer.persist_paused_state()
# Assert - Verify pause state was saved to database
self.session.refresh(self.test_workflow_run)
@@ -373,7 +374,7 @@ class TestPauseStatePersistenceLayerTestContainers:
storage_content = storage.load(pause_model.state_object_key).decode()
resumption_context = WorkflowResumptionContext.loads(storage_content)
assert resumption_context.version == "1"
assert resumption_context.serialized_graph_runtime_state == graph_runtime_state.dumps()
assert resumption_context.serialized_graph_runtime_state == runtime_state.dumps()
persisted_entity = resumption_context.get_generate_entity()
assert isinstance(persisted_entity, WorkflowAppGenerateEntity)
assert persisted_entity.workflow_execution_id == self.test_workflow_run_id
@@ -396,7 +397,7 @@ class TestPauseStatePersistenceLayerTestContainers:
("node3", "var3"): [1, 2, 3],
}
graph_runtime_state = self._create_graph_runtime_state(
runtime_state = self._create_graph_runtime_state(
outputs=complex_outputs,
total_tokens=250,
node_run_steps=10,
@@ -404,12 +405,13 @@ class TestPauseStatePersistenceLayerTestContainers:
)
command_channel = _TestCommandChannelImpl()
layer.initialize(graph_runtime_state, command_channel)
layer.initialize(runtime_state, command_channel)
event = GraphRunPausedEvent(reasons=[SchedulingPause(message="test pause")])
# Act - Save pause state
layer.on_event(event)
layer.persist_paused_state()
# Assert - Retrieve and verify
pause_entity = self.workflow_run_service._workflow_runs.get_workflow_pause(self.test_workflow_run_id)
@@ -419,7 +421,7 @@ class TestPauseStatePersistenceLayerTestContainers:
state_bytes = pause_entity.get_state()
resumption_context = WorkflowResumptionContext.loads(state_bytes.decode())
retrieved_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
retrieved_state = RuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
assert retrieved_state.outputs == complex_outputs
assert retrieved_state.total_tokens == 250
@@ -430,18 +432,19 @@ class TestPauseStatePersistenceLayerTestContainers:
"""Test that database transactions are handled correctly."""
# Arrange
layer = self._create_pause_state_persistence_layer()
graph_runtime_state = self._create_graph_runtime_state(
runtime_state = self._create_graph_runtime_state(
outputs={"test": "transaction"},
total_tokens=50,
)
command_channel = _TestCommandChannelImpl()
layer.initialize(graph_runtime_state, command_channel)
layer.initialize(runtime_state, command_channel)
event = GraphRunPausedEvent(reasons=[SchedulingPause(message="test pause")])
# Act
layer.on_event(event)
layer.persist_paused_state()
# Assert - Verify data is committed and accessible in new session
with Session(bind=self.session.get_bind(), expire_on_commit=False) as new_session:
@@ -464,18 +467,19 @@ class TestPauseStatePersistenceLayerTestContainers:
# Create large state data to test storage
large_outputs = {"data": "x" * 10000} # 10KB of data
graph_runtime_state = self._create_graph_runtime_state(
runtime_state = self._create_graph_runtime_state(
outputs=large_outputs,
total_tokens=1000,
)
command_channel = _TestCommandChannelImpl()
layer.initialize(graph_runtime_state, command_channel)
layer.initialize(runtime_state, command_channel)
event = GraphRunPausedEvent(reasons=[SchedulingPause(message="test pause")])
# Act
layer.on_event(event)
layer.persist_paused_state()
# Assert - Verify file was uploaded to storage
self.session.refresh(self.test_workflow_run)
@@ -488,7 +492,7 @@ class TestPauseStatePersistenceLayerTestContainers:
# Verify content in storage
storage_content = storage.load(pause_model.state_object_key).decode()
resumption_context = WorkflowResumptionContext.loads(storage_content)
assert resumption_context.serialized_graph_runtime_state == graph_runtime_state.dumps()
assert resumption_context.serialized_graph_runtime_state == runtime_state.dumps()
assert resumption_context.get_generate_entity().workflow_execution_id == self.test_workflow_run_id
def test_workflow_with_different_creators(self, db_session_with_containers: Session):
@@ -530,18 +534,19 @@ class TestPauseStatePersistenceLayerTestContainers:
workflow=different_workflow,
)
graph_runtime_state = self._create_graph_runtime_state(
runtime_state = self._create_graph_runtime_state(
outputs={"creator_test": "different_creator"},
workflow_run_id=different_workflow_run.id,
)
command_channel = _TestCommandChannelImpl()
layer.initialize(graph_runtime_state, command_channel)
layer.initialize(runtime_state, command_channel)
event = GraphRunPausedEvent(reasons=[SchedulingPause(message="test pause")])
# Act
layer.on_event(event)
layer.persist_paused_state()
# Assert - Should use workflow creator (not run creator)
self.session.refresh(different_workflow_run)
@@ -560,13 +565,13 @@ class TestPauseStatePersistenceLayerTestContainers:
"""Test that layer ignores non-pause events."""
# Arrange
layer = self._create_pause_state_persistence_layer()
graph_runtime_state = self._create_graph_runtime_state()
runtime_state = self._create_graph_runtime_state()
command_channel = _TestCommandChannelImpl()
layer.initialize(graph_runtime_state, command_channel)
layer.initialize(runtime_state, command_channel)
# Import other event types
from graphon.graph_events import (
from graphon.engine_events import (
GraphRunFailedEvent,
GraphRunStartedEvent,
GraphRunSucceededEvent,
@@ -590,10 +595,11 @@ class TestPauseStatePersistenceLayerTestContainers:
"""Test that layer requires proper initialization before handling events."""
# Arrange
layer = self._create_pause_state_persistence_layer()
# Don't initialize - graph_runtime_state should be uninitialized
# Don't initialize - runtime_state should be uninitialized
event = GraphRunPausedEvent(reasons=[SchedulingPause(message="test pause")])
# Act & Assert - Should raise GraphEngineLayerNotInitializedError
with pytest.raises(GraphEngineLayerNotInitializedError):
layer.on_event(event)
# Act & Assert - Should raise RuntimeError
layer.on_event(event)
with pytest.raises(RuntimeError):
layer.persist_paused_state()
@@ -17,16 +17,16 @@ from core.workflow.nodes.human_input.callback import (
from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
from core.workflow.system_variables import build_system_variables
from graphon.engine import Engine
from graphon.engine.command import InMemoryChannel
from graphon.enums import WorkflowType
from graphon.graph import Graph
from graphon.graph_engine import GraphEngine
from graphon.graph_engine.command_channels import InMemoryChannel
from graphon.nodes.end.end_node import EndNode
from graphon.nodes.end.entities import EndNodeData
from graphon.nodes.human_input.human_input_node import HumanInputNode
from graphon.nodes.start.entities import StartNodeData
from graphon.nodes.start.start_node import StartNode
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from libs.datetime_utils import naive_utc_now
from models import Account
from models.account import AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus
@@ -37,7 +37,7 @@ from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeEx
from tests.workflow_test_utils import build_test_graph_init_params
def _build_runtime_state(workflow_execution_id: str, app_id: str, workflow_id: str, user_id: str) -> GraphRuntimeState:
def _build_runtime_state(workflow_execution_id: str, app_id: str, workflow_id: str, user_id: str) -> RuntimeState:
variable_pool = VariablePool.from_bootstrap(
system_variables=build_system_variables(
workflow_execution_id=workflow_execution_id,
@@ -48,11 +48,11 @@ def _build_runtime_state(workflow_execution_id: str, app_id: str, workflow_id: s
user_inputs={},
conversation_variables=[],
)
return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
return RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=time.perf_counter())
def _build_graph(
runtime_state: GraphRuntimeState,
runtime_state: RuntimeState,
tenant_id: str,
app_id: str,
workflow_id: str,
@@ -75,8 +75,8 @@ def _build_graph(
start_node = StartNode(
node_id="start",
data=start_data,
graph_init_params=params,
graph_runtime_state=runtime_state,
init_params=params,
runtime_state=runtime_state,
)
human_data = HumanInputNodeData(
@@ -94,8 +94,8 @@ def _build_graph(
human_node = HumanInputNode(
node_id="human",
data=human_data,
graph_init_params=params,
graph_runtime_state=runtime_state,
init_params=params,
runtime_state=runtime_state,
hitl_callback=hitl_callback,
)
@@ -107,8 +107,8 @@ def _build_graph(
end_node = EndNode(
node_id="end",
data=end_data,
graph_init_params=params,
graph_runtime_state=runtime_state,
init_params=params,
runtime_state=runtime_state,
)
return (
@@ -271,14 +271,13 @@ class TestHumanInputResumeNodeExecutionIntegration:
workflow_node_execution_repository=node_execution_repo,
)
def _run_graph(self, graph: Graph, runtime_state: GraphRuntimeState, execution_id: str) -> None:
engine = GraphEngine(
workflow_id=self.workflow.id,
def _run_graph(self, graph: Graph, runtime_state: RuntimeState, execution_id: str) -> None:
engine = Engine(
graph=graph,
graph_runtime_state=runtime_state,
runtime_state=runtime_state,
command_channel=InMemoryChannel(),
)
engine.layer(self._build_persistence_layer(execution_id))
engine.add_layer(self._build_persistence_layer(execution_id))
for _ in engine.run():
continue
@@ -304,7 +303,7 @@ class TestHumanInputResumeNodeExecutionIntegration:
self._run_graph(paused_graph, runtime_state, execution_id)
snapshot = runtime_state.dumps()
resumed_state = GraphRuntimeState.from_snapshot(snapshot)
resumed_state = RuntimeState.from_snapshot(snapshot)
form = self.session.scalar(
select(HumanInputForm).where(
HumanInputForm.tenant_id == self.tenant.id,
@@ -1647,7 +1647,7 @@ class TestWorkflowService:
import uuid
from datetime import datetime
from graphon.graph_events import NodeRunSucceededEvent
from graphon.engine_events import NodeRunSucceededEvent
from graphon.node_events import NodeRunResult
node = _ExecutedNode(
@@ -1716,7 +1716,7 @@ class TestWorkflowService:
import uuid
from datetime import datetime
from graphon.graph_events import NodeRunFailedEvent
from graphon.engine_events import NodeRunFailedEvent
from graphon.node_events import NodeRunResult
node = _ExecutedNode(
@@ -1780,7 +1780,7 @@ class TestWorkflowService:
import uuid
from datetime import datetime
from graphon.graph_events import NodeRunFailedEvent
from graphon.engine_events import NodeRunFailedEvent
from graphon.node_events import NodeRunResult
node = _ExecutedNode(
@@ -16,7 +16,7 @@ from core.workflow.nodes.human_input.entities import SelectInputConfig, StringLi
from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from models.enums import CreatorUserRole
from models.human_input import HumanInputForm
from models.model import AppMode
@@ -79,7 +79,7 @@ def _build_resumption_context(workflow_run_id: str) -> WorkflowResumptionContext
call_depth=0,
workflow_execution_id=workflow_run_id,
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0)
runtime_state.variable_pool.add(("start", "options"), ["approve", "reject"])
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
return WorkflowResumptionContext(
@@ -21,7 +21,7 @@ from core.workflow.human_input_adapter import (
from core.workflow.nodes.human_input.entities import HumanInputNodeData
from extensions.ext_storage import storage
from graphon.enums import WorkflowExecutionStatus
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.human_input import HumanInputDelivery, HumanInputForm, HumanInputFormRecipient
@@ -138,7 +138,7 @@ def _create_workflow_pause_state(
)
db_session_with_containers.add(workflow_run)
runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0)
resumption_context = WorkflowResumptionContext(
generate_entity={
"type": AppMode.WORKFLOW,
@@ -561,13 +561,13 @@ def _stop_global_redis(monkeypatch: pytest.MonkeyPatch) -> Generator[_StopRedis]
redis = _StopRedis(
read_error=AssertionError("Must use the injected Redis for ownership reads"),
flag_error=AssertionError("Must use the injected Redis for stop flags"),
command_error=AssertionError("Must use the injected Redis for GraphEngine commands"),
command_error=AssertionError("Must use the injected Redis for Engine commands"),
)
monkeypatch.setattr(app_queue_module, "redis_client", redis)
monkeypatch.setattr(coordinator_module, "redis_client", redis)
monkeypatch.setattr(app_task_module, "redis_client", redis)
yield redis
# GraphEngine catches Redis failures, so inspect the trap even after HTTP success.
# Engine catches Redis failures, so inspect the trap even after HTTP success.
assert redis.reads == []
assert redis.operations == []
@@ -633,7 +633,7 @@ def test_stop_handlers_preserve_mode_specific_commands_and_response(
command_key = f"workflow:{_TASK_ID}:commands"
assert set(stop_redis.commands) == {command_key}
assert [json.loads(command) for command in stop_redis.commands[command_key]] == [
{"command_type": "abort", "payload": None, "reason": "User requested stop"}
{"command_type": "abort", "reason": "User requested stop"}
]
else:
assert stop_redis.commands == {}
@@ -669,7 +669,7 @@ def test_stop_handlers_preserve_mode_specific_behavior_when_task_ownership_does_
if mode == AppMode.ADVANCED_CHAT:
assert stop_redis.operations == ["graph_command"]
assert [json.loads(command) for command in stop_redis.commands[f"workflow:{_TASK_ID}:commands"]] == [
{"command_type": "abort", "payload": None, "reason": "User requested stop"}
{"command_type": "abort", "reason": "User requested stop"}
]
else:
assert stop_redis.operations == []
@@ -598,7 +598,7 @@ def test_draft_node_last_run_raises_not_found_when_execution_missing(
def test_workflow_task_stop_uses_queue_flag_and_graph_command(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
set_stop_flag = Mock()
send_stop_command = Mock()
send_command = Mock()
monkeypatch.setattr(
snippet_workflow_module.AppQueueManager,
"set_stop_flag_no_user_check",
@@ -606,8 +606,8 @@ def test_workflow_task_stop_uses_queue_flag_and_graph_command(app: Flask, monkey
)
monkeypatch.setattr(
snippet_workflow_module,
"GraphEngineManager",
Mock(return_value=Mock(send_stop_command=send_stop_command)),
"send_workflow_stop_command",
send_command,
)
api = snippet_workflow_module.SnippetWorkflowTaskStopApi()
@@ -618,4 +618,4 @@ def test_workflow_task_stop_uses_queue_flag_and_graph_command(app: Flask, monkey
assert result == {"result": "success"}
set_stop_flag.assert_called_once_with("task-1")
send_stop_command.assert_called_once_with("task-1")
send_command.assert_called_once_with(task_id="task-1", redis=snippet_workflow_module.redis_client)
@@ -267,7 +267,7 @@ def test_stop_task_rejects_other_token_owner(resource_fixture: ResourceFixture)
with (
patch("controllers.openapi.app_run.redis_client") as redis,
patch("controllers.openapi.app_run.AppQueueManager.set_stop_flag_no_user_check") as stop,
patch("controllers.openapi.app_run.GraphEngineManager") as engine,
patch("controllers.openapi.app_run.send_workflow_stop_command") as engine,
):
redis.get.return_value = b"end-user-other-user"
with pytest.raises(NotFound):
@@ -74,12 +74,10 @@ def test_chat_payload_normalizes_conversation_id():
def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, monkeypatch: pytest.MonkeyPatch):
queue_mock = Mock()
graph_mock = Mock()
graph_instance = Mock()
graph_mock.return_value = graph_instance
run_module = sys.modules["controllers.openapi.app_run"]
monkeypatch.setattr(run_module, "AppQueueManager", queue_mock)
monkeypatch.setattr(run_module, "GraphEngineManager", graph_mock)
monkeypatch.setattr(run_module, "send_workflow_stop_command", graph_mock)
monkeypatch.setattr(run_module, "redis_client", object())
api = AppRunTaskStopApi()
@@ -92,7 +90,7 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, monkeypatch:
)
queue_mock.set_stop_flag_no_user_check.assert_called_once_with("task-1")
graph_instance.send_stop_command.assert_called_once_with("task-1")
graph_mock.assert_called_once_with(task_id="task-1", redis=run_module.redis_client)
assert result == TaskStopResponse(result="success")
@@ -40,7 +40,7 @@ from core.workflow.system_variables import build_system_variables
from enums import DeploymentEdition
from graphon.entities import WorkflowStartReason
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from libs.datetime_utils import to_utc_timestamp
from models.account import Account
from models.enums import CreatorUserRole, EndUserType, MessageStatus
@@ -313,7 +313,7 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext:
call_depth=0,
workflow_execution_id="run-1",
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0)
runtime_state.set_output("result", "value")
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
return WorkflowResumptionContext(
@@ -504,7 +504,7 @@ class TestHitlServiceApi:
pipeline = AdvancedChatAppGenerateTaskPipeline(
application_generate_entity=application_generate_entity,
workflow=WorkflowSnapshot(id="workflow-id", tenant_id="tenant", features_dict={}),
queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None),
queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, runtime_state=None),
conversation=ConversationSnapshot(id="conv-id", mode=AppMode.ADVANCED_CHAT),
message=MessageSnapshot(
id="message-id",
@@ -597,7 +597,7 @@ class TestHitlServiceApi:
pipeline = WorkflowAppGenerateTaskPipeline(
application_generate_entity=application_generate_entity,
workflow=_workflow(workflow_id="workflow-id", app_id="app", tenant_id="tenant"),
queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None),
queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, runtime_state=None),
user=_end_user(user_id="user", app_id="app", tenant_id="tenant"),
stream=False,
draft_var_saver_factory=lambda **kwargs: None,
@@ -691,7 +691,7 @@ class TestHitlServiceApi:
responses = converter.workflow_pause_to_stream_response(
event=queue_event,
task_id="task",
graph_runtime_state=runtime_state,
runtime_state=runtime_state,
)
assert isinstance(responses[-1], WorkflowPauseStreamResponse)
@@ -36,7 +36,6 @@ from controllers.service_api.app.error import (
)
from controllers.service_api.app.workflow import (
AppQueueManager,
GraphEngineManager,
WorkflowAppLogApi,
WorkflowLogQuery,
WorkflowRunApi,
@@ -48,6 +47,7 @@ from controllers.service_api.app.workflow import (
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from enums import CloudPlan, DeploymentEdition
from graphon.engine.command import AbortCommand, RedisChannel
from graphon.enums import WorkflowExecutionStatus
from graphon.model_runtime.errors.invoke import InvokeRateLimitError as ProviderInvokeRateLimitError
from models import Account
@@ -727,7 +727,7 @@ class TestWorkflowTaskStopApi:
stop_mock = Mock()
send_mock = Mock()
monkeypatch.setattr(AppQueueManager, "set_stop_flag_no_user_check", stop_mock)
monkeypatch.setattr(GraphEngineManager, "send_stop_command", send_mock)
monkeypatch.setattr(RedisChannel, "send_command", send_mock)
api = WorkflowTaskStopApi()
handler = unwrap(api.post)
@@ -739,7 +739,7 @@ class TestWorkflowTaskStopApi:
assert response == {"result": "success"}
stop_mock.assert_called_once_with("t1")
send_mock.assert_called_once_with("t1")
send_mock.assert_called_once_with(AbortCommand(reason="User requested stop"))
class TestWorkflowAppLogApi:
@@ -15,6 +15,7 @@ from controllers.web.error import (
)
from controllers.web.workflow import WorkflowRunApi, WorkflowTaskStopApi
from core.errors.error import ProviderTokenNotInitError, QuotaExceededError
from graphon.engine.command import AbortCommand
from models.model import App, AppMode, EndUser
from services.errors.app import (
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
@@ -109,7 +110,7 @@ class TestWorkflowTaskStopApi:
with pytest.raises(NotWorkflowAppError):
WorkflowTaskStopApi().post(_chat_app(), _end_user(), "task-1")
@patch("controllers.web.workflow.GraphEngineManager.send_stop_command")
@patch("services.workflow.command_service.RedisChannel.send_command")
@patch("controllers.web.workflow.AppQueueManager.set_stop_flag_no_user_check")
def test_stop_calls_both_mechanisms(self, mock_legacy: MagicMock, mock_graph: MagicMock, app: Flask) -> None:
with app.test_request_context("/workflows/tasks/task-1/stop", method="POST"):
@@ -117,4 +118,4 @@ class TestWorkflowTaskStopApi:
assert result == {"result": "success"}
mock_legacy.assert_called_once_with("task-1")
mock_graph.assert_called_once_with("task-1")
mock_graph.assert_called_once_with(AbortCommand(reason="User requested stop"))
@@ -313,7 +313,7 @@ class TestAdvancedChatAppGeneratorInternals:
def _fake_generate(**kwargs):
nonlocal captured_entity, captured_graph_runtime_state
captured_entity = kwargs["application_generate_entity"]
captured_graph_runtime_state = kwargs["graph_runtime_state"]
captured_graph_runtime_state = kwargs["runtime_state"]
return SimpleNamespace(resumed=True)
monkeypatch.setattr(generator, "_generate", _fake_generate)
@@ -328,7 +328,7 @@ class TestAdvancedChatAppGeneratorInternals:
application_generate_entity=application_generate_entity,
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_runtime_state=SimpleNamespace(),
runtime_state=SimpleNamespace(),
pause_state_config=None,
)
@@ -733,7 +733,7 @@ class TestAdvancedChatAppGeneratorInternals:
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_engine_layers=(),
graph_runtime_state=None,
runtime_state=None,
)
def test_generate_worker_raises_when_app_not_found_for_internal_call(
@@ -785,7 +785,7 @@ class TestAdvancedChatAppGeneratorInternals:
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_engine_layers=(),
graph_runtime_state=None,
runtime_state=None,
)
def test_generate_worker_handles_stopped_error(
@@ -848,7 +848,7 @@ class TestAdvancedChatAppGeneratorInternals:
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_engine_layers=(),
graph_runtime_state=SimpleNamespace(),
runtime_state=SimpleNamespace(),
)
queue_manager.publish_error.assert_not_called()
@@ -922,7 +922,7 @@ class TestAdvancedChatAppGeneratorInternals:
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_engine_layers=(),
graph_runtime_state=None,
runtime_state=None,
)
queue_manager.publish_error.assert_called_once()
@@ -991,7 +991,7 @@ class TestAdvancedChatAppGeneratorInternals:
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_engine_layers=(),
graph_runtime_state=None,
runtime_state=None,
)
queue_manager.publish_error.assert_called_once()
@@ -1170,7 +1170,7 @@ class TestAdvancedChatAppGeneratorInternals:
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_engine_layers=(),
graph_runtime_state=None,
runtime_state=None,
)
assert queue_manager.publish_error.called
@@ -1388,7 +1388,7 @@ class TestAdvancedChatAppGeneratorResume:
application_generate_entity=application_generate_entity,
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_runtime_state=SimpleNamespace(),
runtime_state=SimpleNamespace(),
)
assert result.ok is True
@@ -1436,7 +1436,7 @@ class TestAdvancedChatAppGeneratorResume:
application_generate_entity=application_generate_entity,
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_runtime_state=SimpleNamespace(),
runtime_state=SimpleNamespace(),
)
assert result.ok is True
@@ -108,7 +108,7 @@ def _patch_common_run_deps(runner: AdvancedChatAppRunner):
RedisChannel=MagicMock(),
redis_client=MagicMock(),
WorkflowEntry=MagicMock(**{"return_value.run.return_value": iter([])}),
GraphRuntimeState=MagicMock(),
RuntimeState=MagicMock(),
)
@@ -155,7 +155,7 @@ def test_resume_appends_chunks_to_paused_answer(unbound_session: Session) -> Non
trace_manager=None,
extras={},
)
queue_manager = SimpleNamespace(graph_runtime_state=None)
queue_manager = SimpleNamespace(runtime_state=None)
conversation = pipeline_module.ConversationSnapshot(id="conversation-1", mode=AppMode.ADVANCED_CHAT)
message = pipeline_module.MessageSnapshot(
id="message-1",
@@ -209,7 +209,7 @@ def test_emit_message_trace_enqueues_message_trace() -> None:
trace_manager=mock_trace_manager,
extras={"trace_session_id": "session-trace-1"},
)
queue_manager = SimpleNamespace(graph_runtime_state=None)
queue_manager = SimpleNamespace(runtime_state=None)
conversation = pipeline_module.ConversationSnapshot(id="conv-1", mode=AppMode.ADVANCED_CHAT)
message = pipeline_module.MessageSnapshot(
id="msg-1",
@@ -286,7 +286,7 @@ def test_save_message_does_not_emit_trace() -> None:
trace_manager=mock_trace_manager,
extras={"trace_session_id": "session-trace-1"},
)
queue_manager = SimpleNamespace(graph_runtime_state=None)
queue_manager = SimpleNamespace(runtime_state=None)
conversation = pipeline_module.ConversationSnapshot(id="conv-1", mode=AppMode.ADVANCED_CHAT)
message = pipeline_module.MessageSnapshot(
id="msg-1",
@@ -353,7 +353,7 @@ def test_emit_message_trace_skips_when_no_trace_manager() -> None:
trace_manager=None,
extras={},
)
queue_manager = SimpleNamespace(graph_runtime_state=None)
queue_manager = SimpleNamespace(runtime_state=None)
conversation = pipeline_module.ConversationSnapshot(id="conv-1", mode=AppMode.ADVANCED_CHAT)
message = pipeline_module.MessageSnapshot(
id="msg-1",
@@ -57,7 +57,7 @@ from core.workflow.system_variables import build_system_variables
from graphon.enums import BuiltinNodeTypes
from graphon.file import FileTransferMethod, FileType
from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.runtime import RuntimeState, VariablePool
from libs.datetime_utils import naive_utc_now
from models.enums import MessageStatus
from models.model import AppMode, EndUser, Message, MessageFile
@@ -101,7 +101,7 @@ def _make_pipeline():
pipeline = AdvancedChatAppGenerateTaskPipeline(
application_generate_entity=application_generate_entity,
workflow=workflow,
queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None),
queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, runtime_state=None),
conversation=conversation,
message=message,
user=user,
@@ -170,7 +170,8 @@ class TestAdvancedChatGenerateTaskPipeline:
pipeline = _make_pipeline()
pipeline._task_state.answer = "partial answer"
pipeline._workflow_run_id = "run-id"
pipeline._graph_runtime_state = GraphRuntimeState(
pipeline._graph_runtime_state = RuntimeState(
workflow_id="test-workflow",
variable_pool=build_test_variable_pool(
variables=build_system_variables(workflow_execution_id="run-id"),
),
@@ -300,7 +301,8 @@ class TestAdvancedChatGenerateTaskPipeline:
pipeline = _make_pipeline()
message = _persist_message(sqlite_session)
other_message = _persist_message(sqlite_session, message_id="other-message-id")
pipeline._graph_runtime_state = GraphRuntimeState(
pipeline._graph_runtime_state = RuntimeState(
workflow_id="test-workflow",
variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")),
start_at=0.0,
)
@@ -480,7 +482,8 @@ class TestAdvancedChatGenerateTaskPipeline:
def test_workflow_finish_handlers(self):
pipeline = _make_pipeline()
pipeline._workflow_run_id = "run-id"
pipeline._graph_runtime_state = GraphRuntimeState(
pipeline._graph_runtime_state = RuntimeState(
workflow_id="test-workflow",
variable_pool=VariablePool.from_bootstrap(
system_variables=build_system_variables(workflow_execution_id="run-id")
),
@@ -665,14 +668,15 @@ class TestAdvancedChatGenerateTaskPipeline:
message = _persist_message(sqlite_session)
graph_runtime_state = GraphRuntimeState(
runtime_state = RuntimeState(
workflow_id="test-workflow",
variable_pool=VariablePool.from_bootstrap(
system_variables=build_system_variables(workflow_execution_id="run-id")
),
start_at=0.0,
)
pipeline._save_message(session=sqlite_session, graph_runtime_state=graph_runtime_state)
pipeline._save_message(session=sqlite_session, runtime_state=runtime_state)
sqlite_session.commit()
assert message.status == MessageStatus.NORMAL
@@ -697,7 +701,8 @@ class TestAdvancedChatGenerateTaskPipeline:
def test_handle_message_end_event_applies_output_moderation(self):
pipeline = _make_pipeline()
pipeline._graph_runtime_state = GraphRuntimeState(
pipeline._graph_runtime_state = RuntimeState(
workflow_id="test-workflow",
variable_pool=VariablePool.from_bootstrap(
system_variables=build_system_variables(workflow_execution_id="run-id")
),

Some files were not shown because too many files have changed in this diff Show More