diff --git a/.github/workflows/builder-safe-execution-tests.yml b/.github/workflows/builder-safe-execution-tests.yml index e1310ed7760..320a6dcebd3 100644 --- a/.github/workflows/builder-safe-execution-tests.yml +++ b/.github/workflows/builder-safe-execution-tests.yml @@ -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}]" diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 32c9f0b0dfc..5e06094a3af 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -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, ) diff --git a/api/context/execution_context.py b/api/context/execution_context.py index c0b4c90c4b9..c0a633b3993 100644 --- a/api/context/execution_context.py +++ b/api/context/execution_context.py @@ -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): diff --git a/api/context/flask_app_context.py b/api/context/flask_app_context.py index 8216aeb6509..67250c3e674 100644 --- a/api/context/flask_app_context.py +++ b/api/context/flask_app_context.py @@ -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 diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 801e260dcf0..0894b898aaf 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -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"} diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 6c837abd7d7..6b40ba7b75a 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -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"} diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 881242de377..1000cd26a89 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -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") diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index 1aa29e43cc1..e021b7ec317 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -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() diff --git a/api/controllers/web/workflow.py b/api/controllers/web/workflow.py index 3e43249d15f..9cdee8c85f3 100644 --- a/api/controllers/web/workflow.py +++ b/api/controllers/web/workflow.py @@ -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") diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 82d916a8b22..b02f33171ee 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -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, ) diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index 30470c0383b..a8b7625e554 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/api/core/app/apps/advanced_chat/app_runner.py @@ -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: diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index bad6020054d..d9594e68c07 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -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 diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py index 0305952724f..3fee2ef505d 100644 --- a/api/core/app/apps/base_app_generator.py +++ b/api/core/app/apps/base_app_generator.py @@ -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, diff --git a/api/core/app/apps/base_app_queue_manager.py b/api/core/app/apps/base_app_queue_manager.py index ea8d50b74ae..4cdeaa50081 100644 --- a/api/core/app/apps/base_app_queue_manager.py +++ b/api/core/app/apps/base_app_queue_manager.py @@ -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: """ diff --git a/api/core/app/apps/common/graph_runtime_state_support.py b/api/core/app/apps/common/graph_runtime_state_support.py index 2a90fbdad0e..6d85a25bfab 100644 --- a/api/core/app/apps/common/graph_runtime_state_support.py +++ b/api/core/app/apps/common/graph_runtime_state_support.py @@ -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 diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index e95a68c0b44..2949a49fd35 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -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, ), ) ) diff --git a/api/core/app/apps/execution_coordinator.py b/api/core/app/apps/execution_coordinator.py index f8ef6347f83..15295616c8f 100644 --- a/api/core/app/apps/execution_coordinator.py +++ b/api/core/app/apps/execution_coordinator.py @@ -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", diff --git a/api/core/app/apps/pipeline/pipeline_runner.py b/api/core/app/apps/pipeline/pipeline_runner.py index 5a3f6142201..a1db8d01b23 100644 --- a/api/core/app/apps/pipeline/pipeline_runner.py +++ b/api/core/app/apps/pipeline/pipeline_runner.py @@ -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, diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index 1bb62d54e2e..3e1d5b2d291 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -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, ) diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index 6fdf4ddb2e8..ae161ef15d1 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -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() diff --git a/api/core/app/apps/workflow/command_channels.py b/api/core/app/apps/workflow/command_channels.py index 6dab7bebbf4..6c63fb55604 100644 --- a/api/core/app/apps/workflow/command_channels.py +++ b/api/core/app/apps/workflow/command_channels.py @@ -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 diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index 90360f76bb6..4401d749612 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -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, ) diff --git a/api/core/app/apps/workflow/stop_aware_ready_queue.py b/api/core/app/apps/workflow/stop_aware_ready_queue.py index dadc402da1e..53a28397a56 100644 --- a/api/core/app/apps/workflow/stop_aware_ready_queue.py +++ b/api/core/app/apps/workflow/stop_aware_ready_queue.py @@ -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, ) diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 9174becfe1e..ad3bf0b9aa1 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -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, diff --git a/api/core/app/file_access/scope.py b/api/core/app/file_access/scope.py index 2c80c166b72..754c64c6b73 100644 --- a/api/core/app/file_access/scope.py +++ b/api/core/app/file_access/scope.py @@ -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 diff --git a/api/core/app/layers/conversation_variable_persist_layer.py b/api/core/app/layers/conversation_variable_persist_layer.py index 9125236af61..281f778b60a 100644 --- a/api/core/app/layers/conversation_variable_persist_layer.py +++ b/api/core/app/layers/conversation_variable_persist_layer.py @@ -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 diff --git a/api/core/app/layers/pause_state_persist_layer.py b/api/core/app/layers/pause_state_persist_layer.py index 5d327adb065..70458d8104f 100644 --- a/api/core/app/layers/pause_state_persist_layer.py +++ b/api/core/app/layers/pause_state_persist_layer.py @@ -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: diff --git a/api/core/app/layers/suspend_layer.py b/api/core/app/layers/suspend_layer.py index 3e28303a7d3..62e08654486 100644 --- a/api/core/app/layers/suspend_layer.py +++ b/api/core/app/layers/suspend_layer.py @@ -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. """ diff --git a/api/core/app/layers/timeslice_layer.py b/api/core/app/layers/timeslice_layer.py index 094c21944df..5878e7a9a89 100644 --- a/api/core/app/layers/timeslice_layer.py +++ b/api/core/app/layers/timeslice_layer.py @@ -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 diff --git a/api/core/app/layers/trigger_post_layer.py b/api/core/app/layers/trigger_post_layer.py index b30603b860d..8450903de11 100644 --- a/api/core/app/layers/trigger_post_layer.py +++ b/api/core/app/layers/trigger_post_layer.py @@ -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)] diff --git a/api/core/app/workflow/file_runtime.py b/api/core/app/workflow/file_runtime.py index 7bdabd93717..1ab0a2bee2b 100644 --- a/api/core/app/workflow/file_runtime.py +++ b/api/core/app/workflow/file_runtime.py @@ -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())) diff --git a/api/core/app/workflow/layers/__init__.py b/api/core/app/workflow/layers/__init__.py index 945f75303c7..70a38f5c3a2 100644 --- a/api/core/app/workflow/layers/__init__.py +++ b/api/core/app/workflow/layers/__init__.py @@ -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 diff --git a/api/core/app/workflow/layers/observability.py b/api/core/app/workflow/layers/observability.py index 317dab91ad3..672df326aa9 100644 --- a/api/core/app/workflow/layers/observability.py +++ b/api/core/app/workflow/layers/observability.py @@ -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 diff --git a/api/core/app/workflow/layers/persistence.py b/api/core/app/workflow/layers/persistence.py index 8b04661deb3..0db3e638680 100644 --- a/api/core/app/workflow/layers/persistence.py +++ b/api/core/app/workflow/layers/persistence.py @@ -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) diff --git a/api/core/base/tts/audio_mime.py b/api/core/base/tts/audio_mime.py index f7347fe5104..26fe9f04934 100644 --- a/api/core/base/tts/audio_mime.py +++ b/api/core/base/tts/audio_mime.py @@ -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]): diff --git a/api/core/model_manager.py b/api/core/model_manager.py index 418d56fb3b9..858f997eed3 100644 --- a/api/core/model_manager.py +++ b/api/core/model_manager.py @@ -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: diff --git a/api/core/plugin/impl/model_runtime.py b/api/core/plugin/impl/model_runtime.py index 4446670b8ef..15e62a78c35 100644 --- a/api/core/plugin/impl/model_runtime.py +++ b/api/core/plugin/impl/model_runtime.py @@ -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, diff --git a/api/core/rag/index_processor/processor/paragraph_index_processor.py b/api/core/rag/index_processor/processor/paragraph_index_processor.py index 589b15ca007..b7b18f7977e 100644 --- a/api/core/rag/index_processor/processor/paragraph_index_processor.py +++ b/api/core/rag/index_processor/processor/paragraph_index_processor.py @@ -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) diff --git a/api/core/workflow/human_input_policy.py b/api/core/workflow/human_input_policy.py index 6bc415649ed..db5aff35c9b 100644 --- a/api/core/workflow/human_input_policy.py +++ b/api/core/workflow/human_input_policy.py @@ -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 diff --git a/api/core/workflow/llm_node.py b/api/core/workflow/llm_node.py index 2bb1aa49040..730740af2f4 100644 --- a/api/core/workflow/llm_node.py +++ b/api/core/workflow/llm_node.py @@ -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, ) diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index f6231f97472..22257afa372 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -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, diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 0f9ae0480d8..d5036f09f4c 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -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( diff --git a/api/core/workflow/nodes/agent/events.py b/api/core/workflow/nodes/agent/events.py index 824decb5199..c6c2e29a8e2 100644 --- a/api/core/workflow/nodes/agent/events.py +++ b/api/core/workflow/nodes/agent/events.py @@ -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 diff --git a/api/core/workflow/nodes/agent/message_transformer.py b/api/core/workflow/nodes/agent/message_transformer.py index f23c9d2e6c8..de64593159f 100644 --- a/api/core/workflow/nodes/agent/message_transformer.py +++ b/api/core/workflow/nodes/agent/message_transformer.py @@ -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( diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py index 8d5c30a33b1..e4d292d074f 100644 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -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 diff --git a/api/core/workflow/nodes/agent_v2/workspace_retirement_layer.py b/api/core/workflow/nodes/agent_v2/workspace_retirement_layer.py index 7c44897fcb7..0749f01d370 100644 --- a/api/core/workflow/nodes/agent_v2/workspace_retirement_layer.py +++ b/api/core/workflow/nodes/agent_v2/workspace_retirement_layer.py @@ -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: diff --git a/api/core/workflow/nodes/datasource/datasource_node.py b/api/core/workflow/nodes/datasource/datasource_node.py index f90c06537aa..c4d6b023250 100644 --- a/api/core/workflow/nodes/datasource/datasource_node.py +++ b/api/core/workflow/nodes/datasource/datasource_node.py @@ -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") diff --git a/api/core/workflow/nodes/human_input/boundary.py b/api/core/workflow/nodes/human_input/boundary.py index b15bdf9f74f..9c00ba9c321 100644 --- a/api/core/workflow/nodes/human_input/boundary.py +++ b/api/core/workflow/nodes/human_input/boundary.py @@ -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, diff --git a/api/core/workflow/nodes/human_input/callback.py b/api/core/workflow/nodes/human_input/callback.py index 17b26f3cde6..fad2d1030c0 100644 --- a/api/core/workflow/nodes/human_input/callback.py +++ b/api/core/workflow/nodes/human_input/callback.py @@ -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 diff --git a/api/core/workflow/nodes/human_input/entities.py b/api/core/workflow/nodes/human_input/entities.py index 8324a46bf4f..0a4f8180304 100644 --- a/api/core/workflow/nodes/human_input/entities.py +++ b/api/core/workflow/nodes/human_input/entities.py @@ -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 diff --git a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py index 944a6bb9109..0e16b6f0734 100644 --- a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py +++ b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py @@ -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) diff --git a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py index a3547fbcf76..ae84c7856d3 100644 --- a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py +++ b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py @@ -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 diff --git a/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py b/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py index a3641c34f32..571b5fbf9a8 100644 --- a/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py +++ b/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py @@ -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 diff --git a/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py b/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py index d2cdf96d157..7add30f4be7 100644 --- a/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py +++ b/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py @@ -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 diff --git a/api/core/workflow/nodes/trigger_webhook/node.py b/api/core/workflow/nodes/trigger_webhook/node.py index 64180275707..b4b1c956b73 100644 --- a/api/core/workflow/nodes/trigger_webhook/node.py +++ b/api/core/workflow/nodes/trigger_webhook/node.py @@ -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 diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index c0f532a6750..d4b6d821093 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -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 diff --git a/api/dify_app.py b/api/dify_app.py index bbe3f33787a..00a9b3ff319 100644 --- a/api/dify_app.py +++ b/api/dify_app.py @@ -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) diff --git a/api/extensions/ext_storage.py b/api/extensions/ext_storage.py index bc8b83b268b..984f53253d1 100644 --- a/api/extensions/ext_storage.py +++ b/api/extensions/ext_storage.py @@ -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() diff --git a/api/extensions/otel/parser/base.py b/api/extensions/otel/parser/base.py index fc10f183c99..d517fc290ac 100644 --- a/api/extensions/otel/parser/base.py +++ b/api/extensions/otel/parser/base.py @@ -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: diff --git a/api/extensions/otel/parser/llm.py b/api/extensions/otel/parser/llm.py index ec3c78a12d4..35e62de4ec1 100644 --- a/api/extensions/otel/parser/llm.py +++ b/api/extensions/otel/parser/llm.py @@ -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) diff --git a/api/extensions/otel/parser/retrieval.py b/api/extensions/otel/parser/retrieval.py index 56672d1fd45..27655c4719d 100644 --- a/api/extensions/otel/parser/retrieval.py +++ b/api/extensions/otel/parser/retrieval.py @@ -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) diff --git a/api/extensions/otel/parser/tool.py b/api/extensions/otel/parser/tool.py index 75ddbba4480..4045db1a654 100644 --- a/api/extensions/otel/parser/tool.py +++ b/api/extensions/otel/parser/tool.py @@ -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) diff --git a/api/libs/helper.py b/api/libs/helper.py index 613db1f074f..382770fd26f 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -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): diff --git a/api/models/workflow.py b/api/models/workflow.py index 42d17032762..f3c3b5b5ced 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -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) diff --git a/api/pyproject.toml b/api/pyproject.toml index 1664695c8d9..ef5e7c9b2e8 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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", ] diff --git a/api/services/account_avatar_file_gateway.py b/api/services/account_avatar_file_gateway.py index 7367d4abdca..c1687c83b2e 100644 --- a/api/services/account_avatar_file_gateway.py +++ b/api/services/account_avatar_file_gateway.py @@ -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) diff --git a/api/services/app_task_service.py b/api/services/app_task_service.py index 453ce4d9bc0..e92d17a8d0e 100644 --- a/api/services/app_task_service.py +++ b/api/services/app_task_service.py @@ -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: diff --git a/api/services/dify_builder/json_tool_runtime.py b/api/services/dify_builder/json_tool_runtime.py index de5bbb1a29a..232fadfc463 100644 --- a/api/services/dify_builder/json_tool_runtime.py +++ b/api/services/dify_builder/json_tool_runtime.py @@ -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") diff --git a/api/services/file_service.py b/api/services/file_service.py index f0a5ce4b82b..997553b9b83 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -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: diff --git a/api/services/human_input_service.py b/api/services/human_input_service.py index 416c3a0c5d2..077ef2709c4 100644 --- a/api/services/human_input_service.py +++ b/api/services/human_input_service.py @@ -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 diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index 833732b77ff..2996eebeea0 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -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: diff --git a/api/services/upload_file_delivery_service.py b/api/services/upload_file_delivery_service.py index 3ae6742c2fe..b218ed60446 100644 --- a/api/services/upload_file_delivery_service.py +++ b/api/services/upload_file_delivery_service.py @@ -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( diff --git a/api/services/workflow/command_service.py b/api/services/workflow/command_service.py new file mode 100644 index 00000000000..18ffc258077 --- /dev/null +++ b/api/services/workflow/command_service.py @@ -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) diff --git a/api/services/workflow/event_ancestry.py b/api/services/workflow/event_ancestry.py new file mode 100644 index 00000000000..56168af4767 --- /dev/null +++ b/api/services/workflow/event_ancestry.py @@ -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) diff --git a/api/services/workflow/execution_lifecycle.py b/api/services/workflow/execution_lifecycle.py new file mode 100644 index 00000000000..108f9c1f1f6 --- /dev/null +++ b/api/services/workflow/execution_lifecycle.py @@ -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 diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py index fe1296b3b2d..9ac02a16fe8 100644 --- a/api/services/workflow_event_snapshot_service.py +++ b/api/services/workflow_event_snapshot_service.py @@ -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 diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index ab8962224b8..ea3069ef34a 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -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]: """ diff --git a/api/tasks/app_generate/workflow_execute_task.py b/api/tasks/app_generate/workflow_execute_task.py index e5267e7fbef..bf683a40dbf 100644 --- a/api/tasks/app_generate/workflow_execute_task.py +++ b/api/tasks/app_generate/workflow_execute_task.py @@ -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, diff --git a/api/tasks/async_workflow_tasks.py b/api/tasks/async_workflow_tasks.py index a3187b148c8..092f1548d00 100644 --- a/api/tasks/async_workflow_tasks.py +++ b/api/tasks/async_workflow_tasks.py @@ -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, diff --git a/api/tasks/mail_human_input_delivery_task.py b/api/tasks/mail_human_input_delivery_task.py index 6748ca3ae8e..31530f2c38c 100644 --- a/api/tasks/mail_human_input_delivery_task.py +++ b/api/tasks/mail_human_input_delivery_task.py @@ -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): diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e526685433c..f00394e9ad9 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -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 diff --git a/api/tests/integration_tests/workflow/nodes/test_code.py b/api/tests/integration_tests/workflow/nodes/test_code.py index 8bbb0957b3c..d93d1e2d3c5 100644 --- a/api/tests/integration_tests/workflow/nodes/test_code.py +++ b/api/tests/integration_tests/workflow/nodes/test_code.py @@ -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() diff --git a/api/tests/integration_tests/workflow/nodes/test_http.py b/api/tests/integration_tests/workflow/nodes/test_http.py index 86b0cd0fd71..3f834de90d0 100644 --- a/api/tests/integration_tests/workflow/nodes/test_http.py +++ b/api/tests/integration_tests/workflow/nodes/test_http.py @@ -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, diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py index d80e3fe31ff..8be332e097f 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py @@ -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), diff --git a/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py index 56da1c948d1..2dcdc1253d4 100644 --- a/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py +++ b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py @@ -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() diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py index 27d0b452456..5b1a3135a58 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py @@ -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, diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_service.py index 4c47037dfca..937696eb4e4 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_service.py @@ -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( diff --git a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py index 4b5a0fb8bc6..86bd5985b5a 100644 --- a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -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( diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py index 068c29740db..a2600d2605e 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py @@ -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, diff --git a/api/tests/unit_tests/controllers/console/explore/test_installed_app_admission.py b/api/tests/unit_tests/controllers/console/explore/test_installed_app_admission.py index bc1e3d61717..aa047ef27f8 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_installed_app_admission.py +++ b/api/tests/unit_tests/controllers/console/explore/test_installed_app_admission.py @@ -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 == [] diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py index f1ca241c03d..b4adf8346df 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py @@ -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) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_resource_access.py b/api/tests/unit_tests/controllers/openapi/auth/test_resource_access.py index 5a9e1aea76b..cf83f2d3d2c 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_resource_access.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_resource_access.py @@ -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): diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index 88940909543..e7508a6e428 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -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") diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py index f71c3694859..e331e2bb966 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -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) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py index 1f8e56a348f..bdf76417506 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py @@ -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: diff --git a/api/tests/unit_tests/controllers/web/test_workflow.py b/api/tests/unit_tests/controllers/web/test_workflow.py index 49c6ad74c6b..a4fa96e1ff4 100644 --- a/api/tests/unit_tests/controllers/web/test_workflow.py +++ b/api/tests/unit_tests/controllers/web/test_workflow.py @@ -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")) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index 018482b4f8d..4165a8b33de 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -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 diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py index 5e420ed6c5f..10d0b2de34e 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py @@ -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(), ) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py index 5166d33fbb7..92c46dbeaab 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py @@ -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", diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index 69f80a393a8..e69d55357bb 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -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") ), diff --git a/api/tests/unit_tests/core/app/apps/common/test_graph_runtime_state_support.py b/api/tests/unit_tests/core/app/apps/common/test_graph_runtime_state_support.py index 201923e0e40..f0f40eb63e9 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_graph_runtime_state_support.py +++ b/api/tests/unit_tests/core/app/apps/common/test_graph_runtime_state_support.py @@ -5,19 +5,19 @@ import pytest from core.app.apps.common.graph_runtime_state_support import GraphRuntimeStateSupport from core.workflow.system_variables import build_system_variables from core.workflow.variable_pool_initializer import add_variables_to_pool -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool -def _make_state(workflow_run_id: str | None) -> GraphRuntimeState: +def _make_state(workflow_run_id: str | None) -> RuntimeState: variable_pool = VariablePool() add_variables_to_pool(variable_pool, build_system_variables(workflow_execution_id=workflow_run_id)) - return GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) + return RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0) class _StubPipeline(GraphRuntimeStateSupport): - def __init__(self, *, cached_state: GraphRuntimeState | None, queue_state: GraphRuntimeState | None): + def __init__(self, *, cached_state: RuntimeState | None, queue_state: RuntimeState | None): self._graph_runtime_state = cached_state - self._base_task_pipeline = SimpleNamespace(queue_manager=SimpleNamespace(graph_runtime_state=queue_state)) + self._base_task_pipeline = SimpleNamespace(queue_manager=SimpleNamespace(runtime_state=queue_state)) def test_ensure_graph_runtime_initialized_caches_explicit_state(): diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py index 8b898d8bd2f..0b58babae4c 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py @@ -6,7 +6,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueHumanInputFormFilledEvent, QueueHumanInputFormTimeoutEvent from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from graphon.variables.segments import StringSegment from models.account import Account @@ -19,7 +19,7 @@ def _build_converter(): workflow_id="wf-1", 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) app_entity = SimpleNamespace( task_id="task-1", app_config=SimpleNamespace(app_id="app-1", tenant_id="tenant-1"), diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py index 7e397ebc73f..a28fca4d351 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py @@ -4,7 +4,7 @@ from core.app.apps.common.workflow_response_converter import WorkflowResponseCon from core.app.entities.app_invoke_entities import InvokeFrom from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from models.account import Account @@ -17,7 +17,7 @@ def _build_converter() -> WorkflowResponseConverter: workflow_id="wf-1", 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) app_entity = SimpleNamespace( task_id="task-1", app_config=SimpleNamespace(app_id="app-1", tenant_id="tenant-1"), diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py index ce8360eaada..7377b2a2675 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py @@ -28,9 +28,9 @@ from core.app.entities.queue_entities import AppQueueEvent, QueueWorkflowFailedE from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository from core.workflow.workflow_entry import WorkflowEntry +from graphon.engine_events import EngineEvent, GraphRunFailedEvent 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 DUMMY_VARIABLE_LOADER from models.account import Account from models.dataset import Dataset, Document, Pipeline @@ -189,19 +189,22 @@ def test_init_rag_pipeline_graph_invalid_config(mocker, runner): with pytest.raises(ValueError): runner._init_rag_pipeline_graph( - workflow=workflow, graph_runtime_state=GraphRuntimeState(variable_pool=VariablePool(), start_at=0) + workflow=workflow, + runtime_state=RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0), ) workflow.graph = json.dumps({"nodes": "bad", "edges": []}) with pytest.raises(ValueError): runner._init_rag_pipeline_graph( - workflow=workflow, graph_runtime_state=GraphRuntimeState(variable_pool=VariablePool(), start_at=0) + workflow=workflow, + runtime_state=RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0), ) workflow.graph = json.dumps({"nodes": [], "edges": "bad"}) with pytest.raises(ValueError): runner._init_rag_pipeline_graph( - workflow=workflow, graph_runtime_state=GraphRuntimeState(variable_pool=VariablePool(), start_at=0) + workflow=workflow, + runtime_state=RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0), ) @@ -211,7 +214,8 @@ def test_init_rag_pipeline_graph_not_found(mocker, runner): with pytest.raises(ValueError): runner._init_rag_pipeline_graph( - workflow=workflow, graph_runtime_state=GraphRuntimeState(variable_pool=VariablePool(), start_at=0) + workflow=workflow, + runtime_state=RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0), ) @@ -378,7 +382,7 @@ def test_run_single_iteration_path(monkeypatch: pytest.MonkeyPatch, sqlite_sessi published: list[AppQueueEvent] = [] failure = GraphRunFailedEvent(error="iteration failed") variable_pool = VariablePool() - state = GraphRuntimeState(variable_pool=variable_pool, start_at=0) + state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0) def prepare_single_node_execution( *, @@ -386,13 +390,13 @@ def test_run_single_iteration_path(monkeypatch: pytest.MonkeyPatch, sqlite_sessi single_iteration_run: RagPipelineGenerateEntity.SingleIterationRunEntity | None, single_loop_run: RagPipelineGenerateEntity.SingleLoopRunEntity | None, user_id: str, - ) -> tuple[Graph, VariablePool, GraphRuntimeState]: + ) -> tuple[Graph, VariablePool, RuntimeState]: prepared.append((workflow.id, single_iteration_run, single_loop_run, user_id)) - graph = runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=state, start_node_id="start") + graph = runner._init_rag_pipeline_graph(workflow=workflow, runtime_state=state, start_node_id="start") return graph, variable_pool, state - def run_workflow(entry: WorkflowEntry) -> Generator[GraphEngineEvent]: - assert entry.graph_engine.graph_runtime_state is state + def run_workflow(entry: WorkflowEntry) -> Generator[EngineEvent]: + assert entry.graph_engine.runtime_state is state yield failure monkeypatch.setattr(runner, "_prepare_single_node_execution", prepare_single_node_execution) @@ -402,7 +406,7 @@ def test_run_single_iteration_path(monkeypatch: pytest.MonkeyPatch, sqlite_sessi runner.run() assert prepared == [("wf", app_generate_entity.single_iteration_run, None, "user")] - assert runner._queue_manager.graph_runtime_state is state + assert runner._queue_manager.runtime_state is state sqlite_session.expire_all() document = sqlite_session.get(Document, "doc") assert document is not None @@ -426,7 +430,7 @@ def test_run_normal_path_builds_graph(monkeypatch: pytest.MonkeyPatch, sqlite_se runner = _build_runner(app_generate_entity, sqlite_engine, workflow) entries: list[WorkflowEntry] = [] - def run_workflow(entry: WorkflowEntry) -> Generator[GraphEngineEvent]: + def run_workflow(entry: WorkflowEntry) -> Generator[EngineEvent]: events.append("workflow_run") entries.append(entry) yield from () @@ -447,7 +451,7 @@ def test_run_normal_path_builds_graph(monkeypatch: pytest.MonkeyPatch, sqlite_se assert len(entries) == 1 graph_engine = entries[0].graph_engine assert graph_engine.graph.root_node.id == "start" - assert graph_engine.graph_runtime_state is runner._queue_manager.graph_runtime_state - input_variable = graph_engine.graph_runtime_state.variable_pool.get(["start", "input1"]) + assert graph_engine.runtime_state is runner._queue_manager.runtime_state + input_variable = graph_engine.runtime_state.variable_pool.get(["start", "input1"]) assert input_variable is not None assert input_variable.value == "v1" diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_queue_manager.py b/api/tests/unit_tests/core/app/apps/test_base_app_queue_manager.py index 52f47bfb79f..f2b9ccdae73 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_queue_manager.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_queue_manager.py @@ -10,6 +10,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.execution_coordinator import AppExecutionState from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueErrorEvent +from graphon.engine.command import AbortCommand from models import Tenant @@ -103,35 +104,35 @@ class TestBaseAppQueueManager: def test_completed_listener_defers_graph_runtime_state_cleanup_until_listener_exits(self): with ( patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): mock_redis.setex.return_value = True mock_redis.get.return_value = None manager = DummyQueueManager(task_id="t1", user_id="u1", invoke_from=InvokeFrom.SERVICE_API) runtime_state = SimpleNamespace(name="runtime-state") - manager.graph_runtime_state = runtime_state + manager.runtime_state = runtime_state manager.stop_listen(execution_state=AppExecutionState.TERMINAL) - assert manager.graph_runtime_state is runtime_state + assert manager.runtime_state is runtime_state assert list(manager.listen()) == [] - assert manager.graph_runtime_state is None - graph_engine_manager.return_value.send_stop_command.assert_not_called() + assert manager.runtime_state is None + graph_engine_manager.return_value.send_command.assert_not_called() def test_execution_coordinator_abort_is_idempotent_when_graph_stop_command_fails(self, caplog): with ( patch("core.app.apps.base_app_queue_manager.redis_client") as queue_redis, patch("core.app.apps.execution_coordinator.redis_client") as execution_redis, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): queue_redis.setex.return_value = True execution_redis.setex.return_value = True - graph_engine_manager.return_value.send_stop_command.side_effect = RuntimeError("redis unavailable") + graph_engine_manager.return_value.send_command.side_effect = RuntimeError("redis unavailable") manager = DummyQueueManager(task_id="t1", user_id="u1", invoke_from=InvokeFrom.SERVICE_API) assert manager._execution_coordinator.request_abort("stream closed") is True assert manager._execution_coordinator.request_abort("duplicate") is False execution_redis.setex.assert_called_once_with("generate_task_stopped:t1", 600, 1) - graph_engine_manager.return_value.send_stop_command.assert_called_once_with("t1", reason="stream closed") + graph_engine_manager.return_value.send_command.assert_called_once_with(AbortCommand(reason="stream closed")) assert "Failed to send stop command for app execution task=t1" in caplog.text diff --git a/api/tests/unit_tests/core/app/apps/test_builder_execution_admission.py b/api/tests/unit_tests/core/app/apps/test_builder_execution_admission.py index 0cdd631f5f1..2901b9ba67c 100644 --- a/api/tests/unit_tests/core/app/apps/test_builder_execution_admission.py +++ b/api/tests/unit_tests/core/app/apps/test_builder_execution_admission.py @@ -197,7 +197,7 @@ def test_json_resume_refuses_before_trace_or_graph_restore(): workflow=Workflow(id=marker.workflow_id), user=MagicMock(), application_generate_entity=AppGenerateEntity.model_construct(builder_execution=marker), - graph_runtime_state=MagicMock(), + runtime_state=MagicMock(), workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), ) @@ -208,14 +208,13 @@ def test_json_factory_refuses_nonissued_recorder_before_ordinary_setup(monkeypat from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY from core.workflow import node_factory - from graphon.entities.graph_init_params import GraphInitParams - from graphon.runtime import GraphRuntimeState, VariablePool + from graphon.runtime import InitParams, RuntimeState, VariablePool from tests.unit_tests.services.dify_builder.test_execution_policy_service import _json_context marker = _json_context() ordinary = MagicMock(side_effect=AssertionError("ordinary setup forbidden")) monkeypatch.setattr(node_factory, "build_dify_model_access", ordinary) - params = GraphInitParams( + params = InitParams( workflow_id=marker.workflow_id, graph_config={"nodes": [], "edges": []}, call_depth=0, @@ -230,7 +229,7 @@ def test_json_factory_refuses_nonissued_recorder_before_ordinary_setup(monkeypat ) }, ) - state = GraphRuntimeState(variable_pool=VariablePool(), start_at=time.perf_counter()) + state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=time.perf_counter()) with pytest.raises(BuilderExecutionPolicyError, match="missing_json_tool_issuer"): node_factory.DifyNodeFactory(params, state, execution_recorder=MagicMock(healthy=True)) ordinary.assert_not_called() @@ -259,7 +258,7 @@ def test_json_task_resume_refuses_before_snapshot_restore(monkeypatch): state.get_generate_entity.return_value = entity monkeypatch.setattr(module.WorkflowResumptionContext, "loads", lambda _: state) restore = MagicMock(side_effect=AssertionError("restore forbidden")) - monkeypatch.setattr(module.GraphRuntimeState, "from_snapshot", restore) + monkeypatch.setattr(module.RuntimeState, "from_snapshot", restore) with pytest.raises(BuilderExecutionPolicyError, match="unsupported_execution_transport"): module._resume_app_execution({"workflow_run_id": "run"}) restore.assert_not_called() @@ -296,8 +295,7 @@ def test_factory_composes_restricted_without_live_adapters(monkeypatch, entry, h import core.workflow.node_factory as module from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY from core.dify_builder.execution_policy import admitted_node_bindings, context_digest, fixture_digest - from graphon.entities.graph_init_params import GraphInitParams - from graphon.runtime import GraphRuntimeState, VariablePool + from graphon.runtime import InitParams, RuntimeState, VariablePool from tests.unit_tests.core.dify_builder.test_execution_policy import graph, snapshot marker = context().model_copy( @@ -306,7 +304,7 @@ def test_factory_composes_restricted_without_live_adapters(monkeypatch, entry, h marker = marker.model_copy(update={"context_digest": context_digest(marker)}) live = MagicMock(side_effect=AssertionError("live construction forbidden")) monkeypatch.setattr(module, "build_dify_model_access", live) - params = GraphInitParams( + params = InitParams( workflow_id="workflow", graph_config=graph(), call_depth=0, @@ -321,7 +319,7 @@ def test_factory_composes_restricted_without_live_adapters(monkeypatch, entry, h ) }, ) - state = GraphRuntimeState(variable_pool=VariablePool(), start_at=time.perf_counter()) + state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=time.perf_counter()) def construct(): recorder = MagicMock(healthy=True) if has_recorder else None @@ -332,7 +330,7 @@ def test_factory_composes_restricted_without_live_adapters(monkeypatch, entry, h graph_init_context=module.DifyGraphInitContext( workflow_id="workflow", graph_config=graph(), run_context=params.run_context, call_depth=0 ), - graph_runtime_state=state, + runtime_state=state, execution_recorder=recorder, ) else: @@ -341,7 +339,7 @@ def test_factory_composes_restricted_without_live_adapters(monkeypatch, entry, h runner = WorkflowBasedAppRunner(queue_manager=MagicMock(), app_id="app") return runner._init_graph( graph_config=graph(), - graph_runtime_state=state, + runtime_state=state, workflow_id="workflow", tenant_id="tenant", user_id="account", @@ -593,7 +591,7 @@ def test_resume_marker_refused_before_restore_trace_or_cancellation_reset(monkey monkeypatch.setattr(module.WorkflowResumptionContext, "loads", MagicMock(return_value=state)) restore = MagicMock(side_effect=AssertionError("restore forbidden")) reset = MagicMock(side_effect=AssertionError("reset forbidden")) - monkeypatch.setattr(module.GraphRuntimeState, "from_snapshot", restore) + monkeypatch.setattr(module.RuntimeState, "from_snapshot", restore) monkeypatch.setattr(module, "clear_app_task_cancellation_signals", reset) with pytest.raises(BuilderExecutionPolicyError, match="unsupported_execution_transport"): module._resume_app_execution({"workflow_run_id": "run"}) diff --git a/api/tests/unit_tests/core/app/apps/test_execution_coordinator.py b/api/tests/unit_tests/core/app/apps/test_execution_coordinator.py index 5ab95b961cb..75d3a2461f9 100644 --- a/api/tests/unit_tests/core/app/apps/test_execution_coordinator.py +++ b/api/tests/unit_tests/core/app/apps/test_execution_coordinator.py @@ -10,13 +10,14 @@ from core.app.apps.execution_coordinator import ( clear_app_task_cancellation_signals, is_app_task_stop_flag_set, ) +from graphon.engine.command import AbortCommand def test_listener_close_does_not_abort_running_attempt() -> None: on_timeout = Mock() with ( patch("core.app.apps.execution_coordinator.redis_client") as redis_client, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): coordinator = AppExecutionCoordinator(task_id="task", on_timeout=on_timeout, timeout_seconds=1200) @@ -25,7 +26,7 @@ def test_listener_close_does_not_abort_running_attempt() -> None: assert coordinator.state is AppExecutionState.RUNNING redis_client.setex.assert_not_called() - graph_engine_manager.return_value.send_stop_command.assert_not_called() + graph_engine_manager.return_value.send_command.assert_not_called() on_timeout.assert_not_called() @@ -33,7 +34,7 @@ def test_paused_attempt_ignores_listener_close_and_timeout() -> None: on_timeout = Mock() with ( patch("core.app.apps.execution_coordinator.redis_client") as redis_client, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): coordinator = AppExecutionCoordinator(task_id="task", on_timeout=on_timeout, timeout_seconds=0) coordinator.mark_paused() @@ -43,7 +44,7 @@ def test_paused_attempt_ignores_listener_close_and_timeout() -> None: assert coordinator.state is AppExecutionState.PAUSED redis_client.setex.assert_not_called() - graph_engine_manager.return_value.send_stop_command.assert_not_called() + graph_engine_manager.return_value.send_command.assert_not_called() on_timeout.assert_not_called() @@ -51,7 +52,7 @@ def test_watchdog_aborts_and_notifies_response_pipeline() -> None: on_timeout = Mock() with ( patch("core.app.apps.execution_coordinator.redis_client") as redis_client, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): coordinator = AppExecutionCoordinator(task_id="task", on_timeout=on_timeout, timeout_seconds=0) @@ -60,9 +61,8 @@ def test_watchdog_aborts_and_notifies_response_pipeline() -> None: assert coordinator.state is AppExecutionState.ABORTING redis_client.setex.assert_called_once_with("generate_task_stopped:task", 600, 1) - graph_engine_manager.return_value.send_stop_command.assert_called_once_with( - "task", - reason="App execution exceeded 0 seconds", + graph_engine_manager.return_value.send_command.assert_called_once_with( + AbortCommand(reason="App execution exceeded 0 seconds") ) on_timeout.assert_called_once_with("App execution exceeded 0 seconds") @@ -122,7 +122,7 @@ def test_clearing_cancellation_signals_survives_command_channel_failure( # The stop flag is cleared first, so a command-channel failure cannot leave it armed. redis_client.delete.assert_called_once_with("generate_task_stopped:task") - assert "Failed to clear pending GraphEngine commands for app task task" in caplog.text + assert "Failed to clear pending Engine commands for app task task" in caplog.text def test_command_channel_key_matches_the_channel_the_stop_command_targets() -> None: @@ -133,17 +133,14 @@ def test_stop_flag_failure_does_not_block_graph_stop(caplog: pytest.LogCaptureFi on_timeout = Mock() with ( patch("core.app.apps.execution_coordinator.redis_client") as redis_client, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): redis_client.setex.side_effect = RuntimeError("redis write failed") coordinator = AppExecutionCoordinator(task_id="task", on_timeout=on_timeout, timeout_seconds=1200) coordinator.request_abort("test abort") - graph_engine_manager.return_value.send_stop_command.assert_called_once_with( - "task", - reason="test abort", - ) + graph_engine_manager.return_value.send_command.assert_called_once_with(AbortCommand(reason="test abort")) assert "Failed to set stop flag for app execution task=task" in caplog.text diff --git a/api/tests/unit_tests/core/app/apps/test_message_based_app_queue_manager.py b/api/tests/unit_tests/core/app/apps/test_message_based_app_queue_manager.py index 9d480366334..38827d313b5 100644 --- a/api/tests/unit_tests/core/app/apps/test_message_based_app_queue_manager.py +++ b/api/tests/unit_tests/core/app/apps/test_message_based_app_queue_manager.py @@ -93,7 +93,7 @@ class TestMessageBasedAppQueueManager: with ( patch("core.app.apps.base_app_queue_manager.redis_client") as queue_redis, patch("core.app.apps.execution_coordinator.redis_client") as execution_redis, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): queue_redis.get.return_value = None manager = MessageBasedAppQueueManager( @@ -115,4 +115,4 @@ class TestMessageBasedAppQueueManager: assert isinstance(messages[0].event, QueueWorkflowPausedEvent) assert manager.execution_state is AppExecutionState.PAUSED execution_redis.setex.assert_not_called() - graph_engine_manager.return_value.send_stop_command.assert_not_called() + graph_engine_manager.return_value.send_command.assert_not_called() diff --git a/api/tests/unit_tests/core/app/apps/test_pause_resume.py b/api/tests/unit_tests/core/app/apps/test_pause_resume.py index 71f3fbd570b..babcfeef800 100644 --- a/api/tests/unit_tests/core/app/apps/test_pause_resume.py +++ b/api/tests/unit_tests/core/app/apps/test_pause_resume.py @@ -15,26 +15,26 @@ from core.app.entities.app_invoke_entities import InvokeFrom from core.workflow import node_factory as node_factory_module from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables -from graphon.entities import WorkflowStartReason -from graphon.entities.base_node_data import BaseNodeData, RetryConfig -from graphon.entities.pause_reason import SchedulingPause -from graphon.enums import BuiltinNodeTypes, NodeType, WorkflowNodeExecutionStatus -from graphon.graph import Graph -from graphon.graph_engine import GraphEngine -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import ( - GraphEngineEvent, +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine_events import ( + EngineEvent, GraphRunPausedEvent, GraphRunStartedEvent, GraphRunSucceededEvent, NodeRunSucceededEvent, ) +from graphon.entities import WorkflowStartReason +from graphon.entities.base_node_data import BaseNodeData, RetryConfig +from graphon.entities.pause_reason import SchedulingPause +from graphon.enums import BuiltinNodeTypes, NodeType, WorkflowNodeExecutionStatus +from graphon.graph import Graph from graphon.node_events import NodeRunResult, PauseRequestedEvent from graphon.nodes.base.entities import OutputVariableEntity from graphon.nodes.base.node import Node from graphon.nodes.end.entities import EndNodeData from graphon.nodes.start.entities import StartNodeData -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from models.account import Account from models.enums import ConversationFromSource from models.model import App, AppMode, Conversation, Message @@ -69,15 +69,15 @@ class _StubToolNode(Node[_StubToolNodeData]): node_id: str, data: _StubToolNodeData, *, - graph_init_params, - graph_runtime_state, + init_params, + runtime_state, **_kwargs: 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, ) def _get_error_strategy(self): @@ -107,7 +107,7 @@ class _StubToolNode(Node[_StubToolNodeData]): status=WorkflowNodeExecutionStatus.SUCCEEDED, outputs={"value": f"{self.id}-done"}, ) - yield self._convert_node_run_result_to_graph_node_event(result) + yield self._convert_node_run_result_to_node_event(result) def _patch_tool_node(mocker: MockerFixture): @@ -163,7 +163,7 @@ def _build_graph_config(*, pause_on: str | None) -> dict[str, object]: return {"nodes": nodes, "edges": edges} -def _build_graph(runtime_state: GraphRuntimeState, *, pause_on: str | None) -> Graph: +def _build_graph(runtime_state: RuntimeState, *, pause_on: str | None) -> Graph: graph_config = _build_graph_config(pause_on=pause_on) params = build_test_graph_init_params( workflow_id="workflow", @@ -177,21 +177,21 @@ def _build_graph(runtime_state: GraphRuntimeState, *, pause_on: str | None) -> G ) node_factory = DifyNodeFactory( - graph_init_params=params, - graph_runtime_state=runtime_state, + init_params=params, + runtime_state=runtime_state, ) return Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") -def _build_runtime_state(run_id: str) -> GraphRuntimeState: +def _build_runtime_state(run_id: str) -> RuntimeState: variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="user", app_id="app", workflow_id="workflow"), user_inputs={}, conversation_variables=[], ) variable_pool.add(("sys", "workflow_run_id"), run_id) - 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 _make_app() -> App: @@ -251,23 +251,22 @@ def _make_message() -> Message: ) -def _run_with_optional_pause(runtime_state: GraphRuntimeState, *, pause_on: str | None) -> list[GraphEngineEvent]: +def _run_with_optional_pause(runtime_state: RuntimeState, *, pause_on: str | None) -> list[EngineEvent]: command_channel = InMemoryChannel() graph = _build_graph(runtime_state, pause_on=pause_on) - engine = GraphEngine( - workflow_id="workflow", + engine = Engine( graph=graph, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, command_channel=command_channel, ) - events: list[GraphEngineEvent] = [] + events: list[EngineEvent] = [] for event in engine.run(): events.append(event) return events -def _node_successes(events: list[GraphEngineEvent]) -> list[str]: +def _node_successes(events: list[EngineEvent]) -> list[str]: return [evt.node_id for evt in events if isinstance(evt, NodeRunSucceededEvent)] @@ -286,12 +285,12 @@ def test_workflow_app_pause_resume_matches_baseline(mocker: MockerFixture): paused_nodes = _node_successes(paused_events) snapshot = paused_state.dumps() - resumed_state = GraphRuntimeState.from_snapshot(snapshot) + resumed_state = RuntimeState.from_snapshot(snapshot) generator = wf_app_gen_module.WorkflowAppGenerator() def _fake_generate(**kwargs): - state: GraphRuntimeState = kwargs["graph_runtime_state"] + state: RuntimeState = kwargs["runtime_state"] events = _run_with_optional_pause(state, pause_on=None) return _node_successes(events) @@ -303,11 +302,13 @@ def test_workflow_app_pause_resume_matches_baseline(mocker: MockerFixture): user=_make_account(), application_generate_entity=SimpleNamespace( builder_execution=None, + native_test_required=False, + native_test_ref=None, stream=False, invoke_from=InvokeFrom.SERVICE_API, trace_manager=SimpleNamespace(), ), - graph_runtime_state=resumed_state, + runtime_state=resumed_state, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), ) @@ -331,12 +332,12 @@ def test_advanced_chat_pause_resume_matches_baseline(mocker: MockerFixture, unbo paused_nodes = _node_successes(paused_events) snapshot = paused_state.dumps() - resumed_state = GraphRuntimeState.from_snapshot(snapshot) + resumed_state = RuntimeState.from_snapshot(snapshot) generator = adv_app_gen_module.AdvancedChatAppGenerator() def _fake_generate(**kwargs): - state: GraphRuntimeState = kwargs["graph_runtime_state"] + state: RuntimeState = kwargs["runtime_state"] events = _run_with_optional_pause(state, pause_on=None) return _node_successes(events) @@ -356,7 +357,7 @@ def test_advanced_chat_pause_resume_matches_baseline(mocker: MockerFixture, unbo ), workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), - graph_runtime_state=resumed_state, + runtime_state=resumed_state, ) assert paused_nodes + resumed_nodes == baseline_nodes @@ -371,7 +372,7 @@ def test_resume_emits_resumption_start_reason(mocker: MockerFixture) -> None: initial_start = next(event for event in paused_events if isinstance(event, GraphRunStartedEvent)) assert initial_start.reason == WorkflowStartReason.INITIAL - resumed_state = GraphRuntimeState.from_snapshot(paused_state.dumps()) + resumed_state = RuntimeState.from_snapshot(paused_state.dumps()) resumed_events = _run_with_optional_pause(resumed_state, pause_on=None) resume_start = next(event for event in resumed_events if isinstance(event, GraphRunStartedEvent)) assert resume_start.reason == WorkflowStartReason.RESUMPTION diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py index 4e1e01780dd..c5b52a8a055 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -17,7 +17,7 @@ from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, Pau from core.ops.ops_trace_manager import OpsTraceManager, TraceQueueManager from core.repositories import SQLAlchemyWorkflowExecutionRepository, SQLAlchemyWorkflowNodeExecutionRepository from graphon.enums import WorkflowExecutionStatus -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser from models.snippet import CustomizedSnippet @@ -107,8 +107,8 @@ def _generate_entity( ) -def _runtime_state() -> GraphRuntimeState: - return GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) +def _runtime_state() -> RuntimeState: + return RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0) def _repositories( @@ -303,7 +303,7 @@ def test_resume_delegates_to_generate( workflow=workflow, user=end_user, application_generate_entity=application_generate_entity, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, graph_engine_layers=("layer",), @@ -313,7 +313,7 @@ def test_resume_delegates_to_generate( assert result == "ok" mock_generate.assert_called_once() kwargs = mock_generate.call_args.kwargs - assert kwargs["graph_runtime_state"] is runtime_state + assert kwargs["runtime_state"] is runtime_state assert kwargs["pause_state_config"] is pause_config assert kwargs["streaming"] is False assert kwargs["invoke_from"] == InvokeFrom.DEBUGGER @@ -377,7 +377,7 @@ def test_generate_appends_pause_layer_and_forwards_state( monkeypatch.setattr("core.app.apps.workflow.app_generator.threading.Thread", DummyThread) application_generate_entity = _generate_entity(app, workflow, end_user) - graph_runtime_state = _runtime_state() + runtime_state = _runtime_state() workflow_execution_repository, workflow_node_execution_repository = _repositories( sqlite_session_factory, app, end_user ) @@ -394,7 +394,7 @@ def test_generate_appends_pause_layer_and_forwards_state( workflow_node_execution_repository=workflow_node_execution_repository, streaming=True, graph_engine_layers=("base-layer",), - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, pause_state_config=PauseStateLayerConfig( session_factory=sqlite_session_factory, state_owner_user_id="owner", @@ -405,7 +405,7 @@ def test_generate_appends_pause_layer_and_forwards_state( graph_engine_layers = worker_kwargs["kwargs"]["graph_engine_layers"] assert graph_engine_layers[0] == "base-layer" assert isinstance(graph_engine_layers[1], PauseStatePersistenceLayer) - assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state + assert worker_kwargs["kwargs"]["runtime_state"] is runtime_state assert worker_kwargs["joined"] is True assert worker_kwargs["join_timeout"] == 300 assert draft_factory_tenant_ids == [app.tenant_id] @@ -454,7 +454,7 @@ def test_resume_path_runs_worker_with_runtime_state( runner_instance = MagicMock() def runner_ctor(**kwargs): - assert kwargs["graph_runtime_state"] is runtime_state + assert kwargs["runtime_state"] is runtime_state assert kwargs["workflow"].id == workflow.id assert kwargs["system_user_id"] == end_user.session_id assert kwargs["queue_manager"] is queue_manager @@ -498,7 +498,7 @@ def test_resume_path_runs_worker_with_runtime_state( workflow=workflow, user=end_user, application_generate_entity=application_generate_entity, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, pause_state_config=pause_config, diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index 6cc14925ca9..7fe2a1525e7 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -31,10 +31,7 @@ from core.app.entities.queue_entities import ( from core.workflow.nodes.agent.events import NodeRunAgentLogEvent from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import default_system_variables -from graphon.entities.base_node_data import DefaultValueType -from graphon.entities.pause_reason import HitlRequired -from graphon.enums import BuiltinNodeTypes, ErrorStrategy -from graphon.graph_events import ( +from graphon.engine_events import ( GraphRunAbortedEvent, GraphRunPausedEvent, GraphRunStartedEvent, @@ -50,8 +47,11 @@ from graphon.graph_events import ( NodeRunStreamChunkEvent, NodeRunSucceededEvent, ) +from graphon.entities.base_node_data import DefaultValueType +from graphon.entities.pause_reason import HitlRequired +from graphon.enums import BuiltinNodeTypes, ErrorStrategy from graphon.node_events import NodeRunResult -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from graphon.variables.segments import StringSegment from graphon.variables.variables import StringVariable from models.workflow import Workflow @@ -67,6 +67,22 @@ def _workflow(graph: dict[str, object] | None = None) -> Workflow: ) +def _entry_for_events(runtime_state): + from services.workflow.event_ancestry import WorkflowEventAncestry + + ancestry = WorkflowEventAncestry( + { + "nodes": [ + {"id": "iteration", "data": {"type": "iteration", "container_id": ""}}, + ] + } + ) + return SimpleNamespace( + graph_engine=SimpleNamespace(runtime_state=runtime_state), + event_ancestry=lambda event: ancestry.resolve(event.container_id), + ) + + class TestWorkflowBasedAppRunner: def test_resolve_user_from(self): runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") @@ -78,7 +94,8 @@ class TestWorkflowBasedAppRunner: def test_init_graph_validates_graph_structure(self): runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -86,7 +103,7 @@ class TestWorkflowBasedAppRunner: with pytest.raises(ValidationError, match="nodes"): runner._init_graph( graph_config={}, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, ) @@ -94,7 +111,7 @@ class TestWorkflowBasedAppRunner: with pytest.raises(ValidationError, match="nodes"): runner._init_graph( graph_config={"nodes": {}, "edges": []}, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, ) @@ -102,14 +119,15 @@ class TestWorkflowBasedAppRunner: with pytest.raises(ValidationError, match="edges"): runner._init_graph( graph_config={"nodes": [], "edges": {}}, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, ) def test_init_graph_includes_trace_session_id_in_run_context(self, monkeypatch: pytest.MonkeyPatch): runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -127,7 +145,7 @@ class TestWorkflowBasedAppRunner: runner._init_graph( graph_config={"nodes": [], "edges": []}, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, root_node_id="root", @@ -138,7 +156,7 @@ class TestWorkflowBasedAppRunner: def test_ordinary_init_graph_keeps_python_mode_typed_serialization(self, monkeypatch: pytest.MonkeyPatch): runner = WorkflowBasedAppRunner(queue_manager=Mock(spec=AppQueueManager), app_id="app") - runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0) raw_graph = { "nodes": [ { @@ -166,7 +184,7 @@ class TestWorkflowBasedAppRunner: monkeypatch.setattr("core.app.apps.workflow_app_runner.Graph.init", lambda **_kwargs: Mock()) runner._init_graph( graph_config=raw_graph, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, root_node_id="http", @@ -188,7 +206,8 @@ class TestWorkflowBasedAppRunner: def test_get_graph_and_variable_pool_for_single_node_run(self, monkeypatch: pytest.MonkeyPatch): runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -230,20 +249,21 @@ class TestWorkflowBasedAppRunner: workflow=workflow, node_id="node-1", user_inputs={}, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, node_type_filter_key="iteration_id", node_type_label="iteration", user_id="00000000-0000-0000-0000-000000000001", ) assert graph is not None - assert variable_pool is graph_runtime_state.variable_pool + assert variable_pool is runtime_state.variable_pool def test_get_graph_and_variable_pool_for_single_node_run_does_not_mutate_inputs( self, monkeypatch: pytest.MonkeyPatch ): runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -290,14 +310,14 @@ class TestWorkflowBasedAppRunner: workflow=workflow, node_id="node-1", user_inputs=user_inputs, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, node_type_filter_key="iteration_id", node_type_label="iteration", user_id="00000000-0000-0000-0000-000000000001", ) assert graph is not None - assert variable_pool is graph_runtime_state.variable_pool + assert variable_pool is runtime_state.variable_pool assert workflow.graph_dict == expected_graph_config assert user_inputs == expected_user_inputs assert captured_graph_config["value"] is not workflow.graph_dict @@ -308,7 +328,8 @@ class TestWorkflowBasedAppRunner: self, monkeypatch: pytest.MonkeyPatch ): runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -347,7 +368,7 @@ class TestWorkflowBasedAppRunner: workflow=workflow, node_id="node-1", user_inputs={}, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, node_type_filter_key="iteration_id", node_type_label="iteration", user_id="00000000-0000-0000-0000-000000000001", @@ -377,7 +398,8 @@ class TestWorkflowBasedAppRunner: variable_loader=variable_loader, app_id="app", ) - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -407,7 +429,7 @@ class TestWorkflowBasedAppRunner: return {} def _graph_init(**kwargs): - variable_pool = graph_runtime_state.variable_pool + variable_pool = runtime_state.variable_pool assert variable_pool.get(["sys", "conversation_id"]) is not None return SimpleNamespace() @@ -432,7 +454,7 @@ class TestWorkflowBasedAppRunner: workflow=workflow, node_id="loop-node", user_inputs={}, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, node_type_filter_key="loop_id", node_type_label="loop", ) @@ -448,11 +470,12 @@ class TestWorkflowBasedAppRunner: published.append((event, publish_from)) runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) - workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) + workflow_entry = _entry_for_events(runtime_state) emails: list[dict] = [] @@ -517,11 +540,12 @@ class TestWorkflowBasedAppRunner: published.append(event) runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) - workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) + workflow_entry = _entry_for_events(runtime_state) runner._handle_event( workflow_entry, @@ -615,7 +639,8 @@ class TestWorkflowBasedAppRunner: published.append(event) runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, @@ -623,7 +648,7 @@ class TestWorkflowBasedAppRunner: ), start_at=0.0, ) - workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) + workflow_entry = _entry_for_events(runtime_state) runner._handle_event( workflow_entry, @@ -651,11 +676,12 @@ class TestWorkflowBasedAppRunner: published.append(event) runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) - workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) + workflow_entry = _entry_for_events(runtime_state) runner._handle_event( workflow_entry, @@ -666,7 +692,7 @@ class TestWorkflowBasedAppRunner: start_at=datetime.now(UTC), finished_at=datetime.now(UTC), node_run_result=NodeRunResult(outputs={"answer": "inside iteration"}), - in_iteration_id="iteration", + container_id="iteration", ), ) @@ -685,11 +711,12 @@ class TestWorkflowBasedAppRunner: published.append(event) runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) - workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) + workflow_entry = _entry_for_events(runtime_state) runner._handle_event( workflow_entry, @@ -768,11 +795,12 @@ class TestWorkflowBasedAppRunner: published.append(event) runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) - workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) + workflow_entry = _entry_for_events(runtime_state) started_at = datetime.now(UTC) finished_at = datetime.now(UTC) result = NodeRunResult( diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py index f7f9bdb4ba2..c5ee82734fe 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py @@ -5,8 +5,8 @@ import pytest from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.queue_entities import QueueWorkflowPausedEvent from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from graphon.engine_events import GraphRunPausedEvent from graphon.entities.pause_reason import HitlRequired -from graphon.graph_events import GraphRunPausedEvent class _DummyQueueManager: @@ -23,7 +23,7 @@ class _DummyRuntimeState: class _DummyGraphEngine: def __init__(self): - self.graph_runtime_state = _DummyRuntimeState() + self.runtime_state = _DummyRuntimeState() class _DummyWorkflowEntry: diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py index 8e83fbc1ac6..83827b17410 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py @@ -13,19 +13,23 @@ from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.credit_usage import CreditUsageAppType from core.workflow.system_variables import default_system_variables -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from models.model import AppMode from models.workflow import Workflow, WorkflowKind -def _make_graph_state() -> tuple[MagicMock, VariablePool, GraphRuntimeState]: +def _make_graph_state() -> tuple[MagicMock, VariablePool, RuntimeState]: variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, environment_variables=[], conversation_variables=[], ) - return MagicMock(), variable_pool, GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) + return ( + MagicMock(), + variable_pool, + RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0), + ) @pytest.fixture @@ -83,7 +87,7 @@ def test_run_uses_single_node_execution_branch( workflow_node_execution_repository=MagicMock(), ) - graph, variable_pool, graph_runtime_state = _make_graph_state() + graph, variable_pool, runtime_state = _make_graph_state() mock_workflow_entry = MagicMock() mock_workflow_entry.graph_engine = MagicMock() mock_workflow_entry.graph_engine.layer = MagicMock() @@ -99,7 +103,7 @@ def test_run_uses_single_node_execution_branch( return_value=( graph, variable_pool, - graph_runtime_state, + runtime_state, ), ) as prepare_single, patch.object(runner, "_init_graph") as init_graph, @@ -119,7 +123,7 @@ def test_run_uses_single_node_execution_branch( entry_kwargs = entry_class.call_args.kwargs assert entry_kwargs["invoke_from"] == InvokeFrom.DEBUGGER assert entry_kwargs["variable_pool"] is variable_pool - assert entry_kwargs["graph_runtime_state"] is graph_runtime_state + assert entry_kwargs["runtime_state"] is runtime_state def test_single_node_run_validates_target_node_config(queue_manager: WorkflowAppQueueManager) -> None: @@ -145,14 +149,14 @@ def test_single_node_run_validates_target_node_config(queue_manager: WorkflowApp ), ) - _, _, graph_runtime_state = _make_graph_state() + _, _, runtime_state = _make_graph_state() with pytest.raises(ValidationError, match="nodes.0.data.type"): runner._get_graph_and_variable_pool_for_single_node_run( workflow=workflow, node_id="loop-node", user_inputs={}, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, node_type_filter_key="loop_id", node_type_label="loop", user_id="00000000-0000-0000-0000-000000000001", diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py index fb9b7f6a4b1..7d15abbea7e 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py @@ -21,10 +21,10 @@ from core.workflow.nodes.human_input.entities import ( from core.workflow.nodes.human_input.enums import ValueSourceType from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import build_system_variables +from graphon.engine_events import GraphRunPausedEvent from graphon.entities import WorkflowStartReason from graphon.entities.pause_reason import HitlRequired -from graphon.graph_events import GraphRunPausedEvent -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from models.account import Account from models.human_input import HumanInputForm, HumanInputFormRecipient, RecipientType from models.workflow import Workflow, WorkflowType @@ -118,7 +118,7 @@ def _build_runner(): workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), graph_engine_layers=(), - graph_runtime_state=None, + runtime_state=None, ) @@ -131,7 +131,7 @@ def test_graph_run_paused_event_emits_queue_pause_event(monkeypatch: pytest.Monk ) event = GraphRunPausedEvent(reasons=[graph_reason], outputs={"foo": "bar"}) workflow_entry = SimpleNamespace( - graph_engine=SimpleNamespace(graph_runtime_state=_FakeRuntimeState()), + graph_engine=SimpleNamespace(runtime_state=_FakeRuntimeState()), ) enriched_reason = HumanInputRequired( @@ -216,11 +216,11 @@ def test_queue_workflow_paused_event_to_stream_responses(sqlite_pause_session: S paused_nodes=["node-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) 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) @@ -272,11 +272,11 @@ def _build_paused_human_input_response( paused_nodes=["node-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) 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[0], HumanInputRequiredResponse) return responses @@ -365,12 +365,12 @@ def test_queue_workflow_paused_event_resolves_variable_select_options(sqlite_pau paused_nodes=["node-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"]) 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[0], HumanInputRequiredResponse) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py index 50b6122b141..e3118c92c39 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py @@ -635,7 +635,7 @@ class TestWorkflowAppGeneratorResume: workflow=_workflow(), user=_end_user(), application_generate_entity=application_generate_entity, - graph_runtime_state=SimpleNamespace(), + runtime_state=SimpleNamespace(), workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), ) @@ -685,7 +685,7 @@ class TestWorkflowAppGeneratorResume: workflow=_workflow(), user=_end_user(), application_generate_entity=application_generate_entity, - graph_runtime_state=SimpleNamespace(), + runtime_state=SimpleNamespace(), workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), ) @@ -753,7 +753,7 @@ class TestWorkflowAppGeneratorWorker: variable_loader=SimpleNamespace(), workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), - graph_runtime_state=SimpleNamespace(), + runtime_state=SimpleNamespace(), ) assert runner_kwargs["system_user_id"] == "session-id" diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py index 6d329724fc5..4cfcbf69348 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py @@ -13,6 +13,7 @@ from core.app.entities.queue_entities import ( QueueStopEvent, QueueWorkflowPausedEvent, ) +from graphon.engine.command import AbortCommand class TestWorkflowAppQueueManager: @@ -37,7 +38,7 @@ class TestWorkflowAppQueueManager: with ( patch("core.app.apps.base_app_queue_manager.redis_client") as queue_redis, patch("core.app.apps.execution_coordinator.redis_client") as execution_redis, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): queue_redis.get.return_value = None manager = WorkflowAppQueueManager( @@ -54,7 +55,7 @@ class TestWorkflowAppQueueManager: assert manager.execution_state is AppExecutionState.RUNNING execution_redis.setex.assert_not_called() - graph_engine_manager.return_value.send_stop_command.assert_not_called() + graph_engine_manager.return_value.send_command.assert_not_called() manager._execution_coordinator.mark_terminal() def test_execution_timeout_aborts_graph_before_stop_event(self, config_overrides: Callable[..., None]): @@ -62,7 +63,7 @@ class TestWorkflowAppQueueManager: with ( patch("core.app.apps.base_app_queue_manager.redis_client") as queue_redis, patch("core.app.apps.execution_coordinator.redis_client") as execution_redis, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): queue_redis.get.return_value = None manager = WorkflowAppQueueManager( @@ -77,16 +78,15 @@ class TestWorkflowAppQueueManager: assert any(isinstance(message.event, QueueStopEvent) for message in messages) execution_redis.setex.assert_called_once_with("generate_task_stopped:task", 600, 1) - graph_engine_manager.return_value.send_stop_command.assert_called_once_with( - "task", - reason="App execution exceeded 0 seconds", + graph_engine_manager.return_value.send_command.assert_called_once_with( + AbortCommand(reason="App execution exceeded 0 seconds") ) def test_terminal_event_does_not_abort_completed_execution(self): with ( patch("core.app.apps.base_app_queue_manager.redis_client") as queue_redis, patch("core.app.apps.execution_coordinator.redis_client") as execution_redis, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): queue_redis.get.return_value = None manager = WorkflowAppQueueManager( @@ -100,13 +100,13 @@ class TestWorkflowAppQueueManager: _ = list(manager.listen()) execution_redis.setex.assert_not_called() - graph_engine_manager.return_value.send_stop_command.assert_not_called() + graph_engine_manager.return_value.send_command.assert_not_called() def test_pause_completes_listener_without_aborting_resumable_execution(self): with ( patch("core.app.apps.base_app_queue_manager.redis_client") as queue_redis, patch("core.app.apps.execution_coordinator.redis_client") as execution_redis, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): queue_redis.get.return_value = None manager = WorkflowAppQueueManager( @@ -126,13 +126,13 @@ class TestWorkflowAppQueueManager: assert isinstance(messages[0].event, QueueWorkflowPausedEvent) assert manager.execution_state is AppExecutionState.PAUSED execution_redis.setex.assert_not_called() - graph_engine_manager.return_value.send_stop_command.assert_not_called() + graph_engine_manager.return_value.send_command.assert_not_called() def test_workflow_pause_does_not_abort_execution(self): with ( patch("core.app.apps.base_app_queue_manager.redis_client") as queue_redis, patch("core.app.apps.execution_coordinator.redis_client") as execution_redis, - patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, + patch("core.app.apps.execution_coordinator.RedisChannel") as graph_engine_manager, ): queue_redis.get.return_value = None manager = WorkflowAppQueueManager( @@ -149,4 +149,4 @@ class TestWorkflowAppQueueManager: assert manager.execution_state is AppExecutionState.PAUSED execution_redis.setex.assert_not_called() - graph_engine_manager.return_value.send_stop_command.assert_not_called() + graph_engine_manager.return_value.send_command.assert_not_called() diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_command_channels.py b/api/tests/unit_tests/core/app/apps/workflow/test_command_channels.py index f555fe945c1..c03f339e029 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_command_channels.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_command_channels.py @@ -10,7 +10,7 @@ from core.app.apps.workflow.command_channels import ( CombinedCommandChannel, StopFlagCommandChannel, ) -from graphon.graph_engine.entities.commands import AbortCommand, PauseCommand +from graphon.engine.command import AbortCommand, PauseCommand class _CommandChannelStub: @@ -66,7 +66,7 @@ def test_combined_command_channel_continues_after_source_failure(caplog: pytest. combined = CombinedCommandChannel((failing, _CommandChannelStub([abort]))) assert combined.fetch_commands() == [abort] - assert "Failed to fetch GraphEngine commands" in caplog.text + assert "Failed to fetch Engine commands" in caplog.text def test_celery_signal_command_channel_emits_abort_when_shutdown_starts() -> None: diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py index 83506ce42bc..c104e62e1f8 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py @@ -13,7 +13,7 @@ from core.app.entities.queue_entities import QueueWorkflowStartedEvent from core.workflow.system_variables import build_system_variables from extensions.ext_redis import RedisClientWrapper from graphon.entities import WorkflowStartReason -from graphon.runtime import GraphRuntimeState +from graphon.runtime import RuntimeState from models.account import Account from models.model import AppMode from models.workflow import Workflow, WorkflowType @@ -47,16 +47,16 @@ def _build_generate_entity(run_id: str) -> WorkflowAppGenerateEntity: ) -def _build_runtime_state(run_id: str) -> GraphRuntimeState: +def _build_runtime_state(run_id: str) -> RuntimeState: variable_pool = build_test_variable_pool(variables=build_system_variables(workflow_execution_id=run_id)) - 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_pipeline(run_id: str, unbound_session_factory: sessionmaker[Session]) -> WorkflowAppGenerateTaskPipeline: queue_manager = WorkflowAppQueueManager( task_id="task-id", user_id="user-id", invoke_from=InvokeFrom.SERVICE_API, app_mode=AppMode.WORKFLOW ) - queue_manager.graph_runtime_state = _build_runtime_state(run_id) + queue_manager.runtime_state = _build_runtime_state(run_id) workflow = Workflow( id="workflow-id", tenant_id="tenant-id", diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index b63e323af1a..2af08d6f075 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -50,7 +50,7 @@ from core.base.tts.app_generator_tts_publisher import AudioTrunk from core.workflow.system_variables import build_system_variables, system_variables_to_mapping from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus 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 CreatorUserRole from models.model import AppMode, EndUser @@ -95,7 +95,7 @@ def _make_pipeline(): pipeline = WorkflowAppGenerateTaskPipeline( 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), user=user, stream=False, draft_var_saver_factory=lambda **kwargs: None, @@ -107,7 +107,8 @@ def _make_pipeline(): class TestWorkflowGenerateTaskPipeline: def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing(self): pipeline = _make_pipeline() - 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"), ), @@ -205,7 +206,8 @@ class TestWorkflowGenerateTaskPipeline: def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine): pipeline = _make_pipeline() - 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, ) @@ -245,7 +247,8 @@ class TestWorkflowGenerateTaskPipeline: def test_handle_workflow_failed_event_yields_error(self): pipeline = _make_pipeline() pipeline._workflow_execution_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")), start_at=0.0, ) @@ -332,7 +335,8 @@ class TestWorkflowGenerateTaskPipeline: def test_handle_stop_event_yields_finish(self): pipeline = _make_pipeline() pipeline._workflow_execution_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") ), @@ -521,7 +525,7 @@ class TestWorkflowGenerateTaskPipeline: call_depth=0, ) 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) end_user = _end_user(end_user_id="end-user-id", session_id="session-id") pipeline = WorkflowAppGenerateTaskPipeline( @@ -768,7 +772,8 @@ class TestWorkflowGenerateTaskPipeline: def test_success_partial_and_pause_handlers(self): pipeline = _make_pipeline() pipeline._workflow_execution_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") ), @@ -798,7 +803,8 @@ class TestWorkflowGenerateTaskPipeline: def test_dispatch_event_direct_failed_and_unhandled_paths(self): pipeline = _make_pipeline() pipeline._workflow_execution_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") ), @@ -816,7 +822,8 @@ class TestWorkflowGenerateTaskPipeline: def test_process_stream_response_main_match_paths_and_cleanup(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") ), diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_stop_aware_ready_queue.py b/api/tests/unit_tests/core/app/apps/workflow/test_stop_aware_ready_queue.py index 6dc1781cc00..631cf3862fc 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_stop_aware_ready_queue.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_stop_aware_ready_queue.py @@ -6,9 +6,9 @@ from core.app.apps.workflow.stop_aware_ready_queue import ( StopAwareReadyQueue, attach_stop_aware_ready_queue, ) -from graphon.graph_engine.domain.graph_execution import GraphExecution -from graphon.graph_engine.ready_queue import StartTask -from graphon.runtime.graph_runtime_state import GraphRuntimeState +from graphon.runtime.execution import GraphExecution +from graphon.runtime.ready_queue import StartTask +from graphon.runtime.runtime_state import RuntimeState from graphon.runtime.variable_pool import VariablePool @@ -63,7 +63,8 @@ def test_ready_queue_rejects_next_node_when_stop_flag_is_set() -> None: def test_attach_stop_aware_ready_queue_wraps_once() -> None: inner = Mock() - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="workflow-1", variable_pool=VariablePool(), start_at=0, ready_queue=inner, diff --git a/api/tests/unit_tests/core/app/layers/test_conversation_variable_persist_layer.py b/api/tests/unit_tests/core/app/layers/test_conversation_variable_persist_layer.py index cfbe27f191b..17f8356bcbf 100644 --- a/api/tests/unit_tests/core/app/layers/test_conversation_variable_persist_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_conversation_variable_persist_layer.py @@ -3,18 +3,19 @@ from unittest.mock import Mock from core.app.layers.conversation_variable_persist_layer import ConversationVariablePersistenceLayer from core.workflow.system_variables import build_system_variables from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID +from graphon.engine.command import InMemoryChannel +from graphon.engine_events import NodeRunSucceededEvent, NodeRunVariableUpdatedEvent from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import NodeRunSucceededEvent, NodeRunVariableUpdatedEvent from graphon.node_events import NodeRunResult -from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool +from graphon.runtime import ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool from graphon.variables import StringVariable from libs.datetime_utils import naive_utc_now -def _build_graph_runtime_state(conversation_id: str) -> ReadOnlyGraphRuntimeStateWrapper: - return ReadOnlyGraphRuntimeStateWrapper( - GraphRuntimeState( +def _build_graph_runtime_state(conversation_id: str) -> ReadOnlyRuntimeStateWrapper: + return ReadOnlyRuntimeStateWrapper( + RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap( system_variables=build_system_variables(conversation_id=conversation_id) ), diff --git a/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py b/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py index 697cd531663..2ceceb6bd0d 100644 --- a/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py @@ -18,16 +18,15 @@ from core.app.layers.pause_state_persist_layer import ( ) from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import SystemVariableKey -from graphon.entities.pause_reason import HitlRequired, SchedulingPause -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 ( +from graphon.engine.command import Command +from graphon.engine.filter import EngineEventFilterContext, ResponseStreamFilter +from graphon.engine_events import ( GraphRunFailedEvent, GraphRunPausedEvent, GraphRunStartedEvent, GraphRunSucceededEvent, ) +from graphon.entities.pause_reason import HitlRequired, SchedulingPause from graphon.runtime import ReadOnlyVariablePool from graphon.variables import build_segment from graphon.variables.segments import Segment @@ -46,14 +45,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 @@ -103,7 +102,7 @@ class MockReadOnlyVariablePool: class MockReadOnlyGraphRuntimeState: - """Mock implementation of ReadOnlyGraphRuntimeState for testing.""" + """Mock implementation of ReadOnlyRuntimeState for testing.""" def __init__( self, @@ -187,12 +186,12 @@ class MockCommandChannel: """Mock implementation of CommandChannel for testing.""" def __init__(self): - self._commands: list[GraphEngineCommand] = [] + self._commands: list[Command] = [] - def fetch_commands(self) -> list[GraphEngineCommand]: + def fetch_commands(self) -> list[Command]: return self._commands.copy() - def send_command(self, command: GraphEngineCommand) -> None: + def send_command(self, command: Command) -> None: self._commands.append(command) @@ -230,8 +229,8 @@ class TestPauseStatePersistenceLayer: assert layer._session_maker is sqlite_session_factory assert layer._state_owner_user_id == state_owner_user_id - with pytest.raises(GraphEngineLayerNotInitializedError): - _ = layer.graph_runtime_state + with pytest.raises(RuntimeError): + _ = layer.runtime_state assert layer.command_channel is None def test_initialize_sets_dependencies(self, sqlite_session_factory: sessionmaker[Session]): @@ -242,12 +241,12 @@ class TestPauseStatePersistenceLayer: response_stream_filter=ResponseStreamFilter(), ) - graph_runtime_state = MockReadOnlyGraphRuntimeState() + runtime_state = MockReadOnlyGraphRuntimeState() command_channel = MockCommandChannel() - layer.initialize(graph_runtime_state, command_channel) + layer.initialize(runtime_state, command_channel) - assert layer.graph_runtime_state is graph_runtime_state + assert layer.runtime_state is runtime_state assert layer.command_channel is command_channel def test_on_event_with_graph_run_paused_event( @@ -265,18 +264,19 @@ class TestPauseStatePersistenceLayer: mock_factory = Mock(return_value=mock_repo) monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory) - graph_runtime_state = MockReadOnlyGraphRuntimeState( + runtime_state = MockReadOnlyGraphRuntimeState( outputs={"result": "test_output"}, total_tokens=100, workflow_execution_id="run-123", ) command_channel = MockCommandChannel() - layer.initialize(graph_runtime_state, command_channel) + layer.initialize(runtime_state, command_channel) event = TestDataFactory.create_graph_run_paused_event(outputs={"intermediate": "result"}) - expected_state = graph_runtime_state.dumps() + expected_state = runtime_state.dumps() layer.on_event(event) + layer.persist_paused_state() mock_factory.assert_called_once_with(sqlite_session_factory) assert mock_repo.create_workflow_pause.call_count == 1 @@ -328,11 +328,11 @@ class TestPauseStatePersistenceLayer: raising=False, ) - graph_runtime_state = MockReadOnlyGraphRuntimeState( + runtime_state = MockReadOnlyGraphRuntimeState( workflow_execution_id="run-123", ) command_channel = MockCommandChannel() - layer.initialize(graph_runtime_state, command_channel) + layer.initialize(runtime_state, command_channel) raw_reason = HitlRequired( session_id="session-123", @@ -342,11 +342,12 @@ class TestPauseStatePersistenceLayer: event = GraphRunPausedEvent(reasons=[raw_reason], outputs={}) layer.on_event(event) + layer.persist_paused_state() enrich_mock.assert_called_once_with( reasons=[raw_reason], form_repository=mock_form_repository, - variable_pool=graph_runtime_state.variable_pool, + variable_pool=runtime_state.variable_pool, ) assert mock_repo.create_workflow_pause.call_args.kwargs["pause_reasons"] == [enriched_reason] @@ -364,9 +365,9 @@ class TestPauseStatePersistenceLayer: mock_factory = Mock(return_value=mock_repo) monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory) - graph_runtime_state = MockReadOnlyGraphRuntimeState() + runtime_state = MockReadOnlyGraphRuntimeState() command_channel = MockCommandChannel() - layer.initialize(graph_runtime_state, command_channel) + layer.initialize(runtime_state, command_channel) events = [ TestDataFactory.create_graph_run_started_event(), @@ -376,6 +377,7 @@ class TestPauseStatePersistenceLayer: for event in events: layer.on_event(event) + layer.persist_paused_state() mock_factory.assert_not_called() mock_repo.create_workflow_pause.assert_not_called() @@ -392,8 +394,9 @@ class TestPauseStatePersistenceLayer: event = TestDataFactory.create_graph_run_paused_event() - with pytest.raises(GraphEngineLayerNotInitializedError): - layer.on_event(event) + layer.on_event(event) + with pytest.raises(RuntimeError): + layer.persist_paused_state() def test_on_event_asserts_when_workflow_execution_id_missing( self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session] @@ -409,14 +412,15 @@ class TestPauseStatePersistenceLayer: mock_factory = Mock(return_value=mock_repo) monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory) - graph_runtime_state = MockReadOnlyGraphRuntimeState(workflow_execution_id=None) + runtime_state = MockReadOnlyGraphRuntimeState(workflow_execution_id=None) command_channel = MockCommandChannel() - layer.initialize(graph_runtime_state, command_channel) + layer.initialize(runtime_state, command_channel) event = TestDataFactory.create_graph_run_paused_event() + layer.on_event(event) with pytest.raises(AssertionError): - layer.on_event(event) + layer.persist_paused_state() mock_factory.assert_not_called() mock_repo.create_workflow_pause.assert_not_called() @@ -520,11 +524,12 @@ def test_on_event_persists_response_stream_filter_dump( mock_factory = Mock(return_value=mock_repo) monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory) - graph_runtime_state = MockReadOnlyGraphRuntimeState(workflow_execution_id="run-123") - layer.initialize(graph_runtime_state, MockCommandChannel()) + runtime_state = MockReadOnlyGraphRuntimeState(workflow_execution_id="run-123") + layer.initialize(runtime_state, MockCommandChannel()) event = TestDataFactory.create_graph_run_paused_event() layer.on_event(event) + layer.persist_paused_state() serialized_state = mock_repo.create_workflow_pause.call_args.kwargs["state"] resumption_context = WorkflowResumptionContext.loads(serialized_state) diff --git a/api/tests/unit_tests/core/app/layers/test_suspend_layer.py b/api/tests/unit_tests/core/app/layers/test_suspend_layer.py index 12d49be0f1a..394d032eba0 100644 --- a/api/tests/unit_tests/core/app/layers/test_suspend_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_suspend_layer.py @@ -1,5 +1,5 @@ from core.app.layers.suspend_layer import SuspendLayer -from graphon.graph_events import GraphRunPausedEvent +from graphon.engine_events import GraphRunPausedEvent class TestSuspendLayer: diff --git a/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py b/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py index 61ced61d8c8..44cc574eb17 100644 --- a/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch import pytest from core.app.layers.timeslice_layer import TimeSliceLayer -from graphon.graph_engine.entities.commands import CommandType, GraphEngineCommand +from graphon.engine.command import PauseCommand from services.workflow.entities import WorkflowScheduleCFSPlanEntity from services.workflow.scheduler import SchedulerCommand @@ -95,5 +95,5 @@ class TestTimeSliceLayer: scheduler.remove_job.assert_called_once_with("job-1") layer.command_channel.send_command.assert_called_once() sent_command = layer.command_channel.send_command.call_args[0][0] - assert isinstance(sent_command, GraphEngineCommand) - assert sent_command.command_type == CommandType.PAUSE + assert isinstance(sent_command, PauseCommand) + assert sent_command.command_type == "pause" diff --git a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py index 150f05081ea..1a7c21cf870 100644 --- a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.layers.trigger_post_layer import TriggerPostLayer from core.workflow.system_variables import build_system_variables -from graphon.graph_events import ( +from graphon.engine_events import ( GraphRunAbortedEvent, GraphRunFailedEvent, GraphRunSucceededEvent, diff --git a/api/tests/unit_tests/core/app/workflow/layers/test_persistence_inspector_publish.py b/api/tests/unit_tests/core/app/workflow/layers/test_persistence_inspector_publish.py index e23f4a81814..7f308fec643 100644 --- a/api/tests/unit_tests/core/app/workflow/layers/test_persistence_inspector_publish.py +++ b/api/tests/unit_tests/core/app/workflow/layers/test_persistence_inspector_publish.py @@ -55,8 +55,9 @@ def layer() -> WorkflowPersistenceLayer: instance._node_execution_cache = {} instance._node_snapshots = {} instance._node_sequence = 0 - # `graph_runtime_state` is a layer-base property; stub it. - instance._graph_runtime_state = MagicMock(total_tokens=0, node_run_steps=0, outputs={}, exceptions_count=0) + instance._event_ancestry = persistence_mod.WorkflowEventAncestry({}) + # `runtime_state` is a layer-base property; stub it. + instance._runtime_state = MagicMock(total_tokens=0, node_run_steps=0, outputs={}, exceptions_count=0) return instance @@ -131,8 +132,7 @@ def _node_started_event(node_id: str = "agent-1", exec_id: str = "exec-1") -> Ma node_type="agent", node_title="Greeter", predecessor_node_id=None, - in_iteration_id=None, - in_loop_id=None, + container_id="", start_at=datetime(2026, 5, 26, 0, 0, 0), ) diff --git a/api/tests/unit_tests/core/app/workflow/test_file_runtime.py b/api/tests/unit_tests/core/app/workflow/test_file_runtime.py index 222c4039f61..79ac527736c 100644 --- a/api/tests/unit_tests/core/app/workflow/test_file_runtime.py +++ b/api/tests/unit_tests/core/app/workflow/test_file_runtime.py @@ -400,15 +400,11 @@ def test_runtime_helper_wrappers_delegate_to_config_and_io( mock_load.assert_called_once_with("path", stream=True) -def test_bind_dify_workflow_file_runtime_registers_runtime(monkeypatch: pytest.MonkeyPatch) -> None: - runtimes: list[DifyWorkflowFileRuntime] = [] +def test_bind_dify_workflow_file_runtime_scopes_runtime() -> None: + from graphon.file.runtime import peek_workflow_file_runtime - def set_runtime(runtime: DifyWorkflowFileRuntime) -> None: - runtimes.append(runtime) - - monkeypatch.setattr(file_runtime, "set_workflow_file_runtime", set_runtime) - - bind_dify_workflow_file_runtime() - - assert len(runtimes) == 1 - assert isinstance(runtimes[0], DifyWorkflowFileRuntime) + before = peek_workflow_file_runtime() + with bind_dify_workflow_file_runtime(): + assert isinstance(peek_workflow_file_runtime(), DifyWorkflowFileRuntime) + assert peek_workflow_file_runtime() is not before + assert peek_workflow_file_runtime() is before diff --git a/api/tests/unit_tests/core/app/workflow/test_node_factory.py b/api/tests/unit_tests/core/app/workflow/test_node_factory.py index 29aa2e632d6..94acd924530 100644 --- a/api/tests/unit_tests/core/app/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/app/workflow/test_node_factory.py @@ -6,14 +6,15 @@ import pytest from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context from core.workflow.node_factory import DifyNodeFactory from graphon.enums import BuiltinNodeTypes +from graphon.runtime import InitParams class DummyNode: - def __init__(self, *, node_id, data, graph_init_params, graph_runtime_state, **kwargs): + def __init__(self, *, node_id, data, init_params, runtime_state, **kwargs): self.id = node_id self.data = data - self.graph_init_params = graph_init_params - self.graph_runtime_state = graph_runtime_state + self.init_params = init_params + self.runtime_state = runtime_state self.kwargs = kwargs @@ -73,8 +74,8 @@ class TestDifyNodeFactory: ) return DifyNodeFactory( - graph_init_params=SimpleNamespace(run_context=run_context, workflow_id="workflow"), - graph_runtime_state=SimpleNamespace(), + init_params=InitParams(run_context=run_context, workflow_id="workflow", graph_config={}, call_depth=0), + runtime_state=SimpleNamespace(), ) def test_create_node_unknown_type(self): diff --git a/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py b/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py index 53f2763e4a3..5844c5b40af 100644 --- a/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py +++ b/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py @@ -150,9 +150,7 @@ class TestObservabilityLayerExtras: assert ended == ["ended"] assert "exec" not in layer._node_contexts - def test_on_node_run_end_logs_detach_failure( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ): + def test_node_run_context_propagates_detach_failure_for_engine_isolation(self, monkeypatch: pytest.MonkeyPatch): layer = ObservabilityLayer() layer._is_disabled = False @@ -169,9 +167,9 @@ class TestObservabilityLayerExtras: monkeypatch.setattr("core.app.workflow.layers.observability.context_api.detach", _raise) node = SimpleNamespace(execution_id="exec", title="Node", id="node", node_type=None) - layer.on_node_run_end(node, error=None) - - assert "Failed to detach OpenTelemetry token" in caplog.text + with pytest.raises(RuntimeError, match="detach failed"): + with layer.node_run_context(node): + layer.on_node_run_end(node, error=None) assert "exec" not in layer._node_contexts def test_on_node_run_start_and_end_creates_span(self, monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py index bc67bfc12d5..04695c2608c 100644 --- a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py +++ b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py @@ -9,16 +9,7 @@ from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.ops.ops_trace_manager import TraceTask, TraceTaskName from core.workflow.system_variables import SystemVariableKey, build_system_variables -from graphon.entities import WorkflowNodeExecution, WorkflowStartReason -from graphon.entities.pause_reason import SchedulingPause -from graphon.enums import ( - BuiltinNodeTypes, - WorkflowExecutionStatus, - WorkflowNodeExecutionMetadataKey, - WorkflowNodeExecutionStatus, - WorkflowType, -) -from graphon.graph_events import ( +from graphon.engine_events import ( GraphRunAbortedEvent, GraphRunFailedEvent, GraphRunPartialSucceededEvent, @@ -32,9 +23,18 @@ from graphon.graph_events import ( NodeRunStartedEvent, NodeRunSucceededEvent, ) +from graphon.entities import WorkflowNodeExecution, WorkflowStartReason +from graphon.entities.pause_reason import SchedulingPause +from graphon.enums import ( + BuiltinNodeTypes, + WorkflowExecutionStatus, + WorkflowNodeExecutionMetadataKey, + WorkflowNodeExecutionStatus, + WorkflowType, +) from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import NodeRunResult -from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool +from graphon.runtime import ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool class _RepoRecorder: @@ -71,11 +71,12 @@ def _make_layer( workflow_execution_id="run-id", conversation_id="conv-id", ) - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap(system_variables=system_variables), start_at=0.0, ) - read_only_state = ReadOnlyGraphRuntimeStateWrapper(runtime_state) + read_only_state = ReadOnlyRuntimeStateWrapper(runtime_state) application_generate_entity = WorkflowAppGenerateEntity.model_construct( task_id="task", @@ -734,6 +735,8 @@ class TestWorkflowPersistenceLayer: for event in events: layer.on_event(event) + if isinstance(event, GraphRunPausedEvent): + layer.persist_paused_state() assert called == expected_order @@ -771,3 +774,16 @@ class TestWorkflowPersistenceLayer: layer._handle_graph_run_succeeded(GraphRunSucceededEvent(outputs={"ok": True})) assert exec_repo.saved assert not trace_tasks + + +def test_paused_status_is_not_saved_before_joint_snapshot_barrier(): + layer, workflow_repo, _, state = _make_layer() + layer.on_graph_start() + layer.on_event(GraphRunStartedEvent()) + before = len(workflow_repo.saved) + layer.on_event(GraphRunPausedEvent()) + assert len(workflow_repo.saved) == before + assert layer._workflow_execution.status == WorkflowExecutionStatus.RUNNING + layer.persist_paused_state() + assert len(workflow_repo.saved) == before + 1 + assert layer._workflow_execution.status == WorkflowExecutionStatus.PAUSED diff --git a/api/tests/unit_tests/core/base/tts/test_graphon_v08_audio.py b/api/tests/unit_tests/core/base/tts/test_graphon_v08_audio.py new file mode 100644 index 00000000000..78b02604951 --- /dev/null +++ b/api/tests/unit_tests/core/base/tts/test_graphon_v08_audio.py @@ -0,0 +1,67 @@ +"""Formal Graphon TTS chunks retain bytes/MIME and lazy stream ownership.""" + +import pytest + +from core.base.tts.audio_mime import inspect_audio_stream +from graphon.model_runtime.errors.invoke import InvokeBadRequestError +from graphon.model_runtime.protocols.tts_runtime import TTSChunk + + +def test_native_tts_chunk_bytes_mime_and_early_close(): + closed = [] + audio = b"RIFF" + b"\x00" * 4 + b"WAVE" + b"\x00" * 32 + + def provider(): + try: + yield TTSChunk(data=audio, mime_type="audio/wav") + raise AssertionError("provider read beyond requested chunk") + finally: + closed.append(True) + + stream, mime = inspect_audio_stream(provider()) + assert mime == "audio/wav" + assert next(stream) == audio + stream.close() + assert closed == [True] + + +def test_native_tts_mime_mismatch_closes_provider(): + closed = [] + + def provider(): + try: + yield TTSChunk(data=b"RIFF" + b"\x00" * 4 + b"WAVE" + b"\x00" * 32, mime_type="audio/mpeg") + finally: + closed.append(True) + + with pytest.raises(InvokeBadRequestError, match="does not match"): + inspect_audio_stream(provider()) + assert closed == [True] + + +def test_plugin_runtime_adapts_bytes_to_native_chunks_lazily_and_closes(): + from unittest.mock import Mock + + from core.plugin.entities.plugin_daemon import TTSAudioChunk + from core.plugin.impl.model_runtime import PluginModelRuntime + + lifecycle = [] + + def plugin_stream(**_kwargs): + lifecycle.append("invoke") + try: + yield TTSAudioChunk(b"audio", mime_type="audio/wav") + raise AssertionError("unrequested provider consumption") + finally: + lifecycle.append("closed") + + runtime = object.__new__(PluginModelRuntime) + runtime.tenant_id = "tenant" + runtime.user_id = "user" + runtime.client = Mock(invoke_tts=plugin_stream) + runtime._split_provider = lambda _: ("plugin", "provider") + stream = runtime.invoke_tts(provider="provider", model="model", credentials={}, content_text="text", voice="") + assert lifecycle == [] + assert next(iter(stream)) == TTSChunk(data=b"audio", mime_type="audio/wav") + stream.close() + assert lifecycle == ["invoke", "closed"] diff --git a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py index ec55237688e..89f1d6ea76a 100644 --- a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py +++ b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py @@ -504,7 +504,10 @@ class TestPluginModelRuntime: def test_invoke_tts_uses_bound_runtime_user_when_runtime_is_unbound(self, monkeypatch: pytest.MonkeyPatch) -> None: client, client_results, client_calls = _recording_model_client(monkeypatch) - client_results["invoke_tts"] = iter([b"chunk"]) + from core.plugin.entities.plugin_daemon import TTSAudioChunk + from graphon.model_runtime.protocols.tts_runtime import TTSChunk + + client_results["invoke_tts"] = iter([TTSAudioChunk(b"chunk", mime_type="audio/wav")]) runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=client, plugin_service=PluginService) result = runtime.invoke_tts( @@ -515,7 +518,7 @@ class TestPluginModelRuntime: voice="alloy", ) - assert list(result) == [b"chunk"] + assert list(result) == [TTSChunk(data=b"chunk", mime_type="audio/wav")] assert client_calls["invoke_tts"] == [ call( tenant_id="tenant", diff --git a/api/tests/unit_tests/core/test_model_manager.py b/api/tests/unit_tests/core/test_model_manager.py index cab6013f033..1c7b9337b4d 100644 --- a/api/tests/unit_tests/core/test_model_manager.py +++ b/api/tests/unit_tests/core/test_model_manager.py @@ -509,3 +509,38 @@ def test_lb_model_manager_fetch_next(mocker: MockerFixture, lb_model_manager: LB config = lb_model_manager.fetch_next() assert config == config3 + + +def test_quota_managed_tts_closes_provider_on_early_consumer_close() -> None: + from graphon.model_runtime.protocols.tts_runtime import TTSChunk + + manager, _ = _build_model_manager_bundle( + provider_type=ProviderType.SYSTEM, + restrict_models=[RestrictModel(model="tts-model", model_type=ModelType.TTS)], + model_type=ModelType.TTS, + ) + model_instance = manager.get_model_instance("tenant-1", "openai", ModelType.TTS, "tts-model") + reservation = MagicMock() + lifecycle = [] + + def provider_stream(): + try: + yield TTSChunk(data=b"audio", mime_type="audio/wav") + raise AssertionError("unrequested provider consumption") + finally: + lifecycle.append("closed") + + # Keep the provider alive independently: reference counting must not own cleanup. + provider = provider_stream() + with ( + patch.object(model_instance, "reserve_quota", return_value=reservation) as reserve, + patch.object(ModelInstance, "invoke_tts", return_value=provider) as invoke, + ): + response = model_instance.invoke_tts(content_text="hello") + reserve.assert_not_called() + invoke.assert_not_called() + assert next(response).data == b"audio" + reservation.commit.assert_called_once_with() + response.close() + assert lifecycle == ["closed"] + reservation.release.assert_called_once_with() diff --git a/api/tests/unit_tests/core/workflow/context/test_execution_context.py b/api/tests/unit_tests/core/workflow/context/test_execution_context.py index 82411e1b10d..8d12a8168a5 100644 --- a/api/tests/unit_tests/core/workflow/context/test_execution_context.py +++ b/api/tests/unit_tests/core/workflow/context/test_execution_context.py @@ -106,9 +106,8 @@ class TestExecutionContext: # Variable should be restored to original value assert test_var.get() == "original_value" - # After exiting, variable stays at the value from within the context - # (this is expected Python contextvars behavior) - assert test_var.get() == "original_value" + # Exiting the segment restores the worker's value from before entry. + assert test_var.get() == "new_value" def test_enter_with_app_context(self): """Test enter enters app context if available.""" diff --git a/api/tests/unit_tests/core/workflow/context/test_flask_app_context.py b/api/tests/unit_tests/core/workflow/context/test_flask_app_context.py index 503f1f9c338..3897aaef5d1 100644 --- a/api/tests/unit_tests/core/workflow/context/test_flask_app_context.py +++ b/api/tests/unit_tests/core/workflow/context/test_flask_app_context.py @@ -192,9 +192,8 @@ class TestFlaskExecutionContextIntegration: # Value should be restored to original assert test_var.get() == "original_value" - # After exiting, variable stays at the value from within the context - # (this is expected Python contextvars behavior) - assert test_var.get() == "original_value" + # Exiting the segment restores the worker's value from before entry. + assert test_var.get() == "new_value" def test_enter_enters_flask_app_context(self, flask_app): """Test that enter enters Flask app context.""" diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/PROVENANCE.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/PROVENANCE.json new file mode 100644 index 00000000000..e203ed2ba5b --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/PROVENANCE.json @@ -0,0 +1,18 @@ +{ + "dify_commit": "b6055da9e81c6a1704e9fcae5ab0d0db773e4090", + "graphon": "0.7.0", + "image": "sha256:15a04da9b81ca6a0b1318dd94e7941a6e92fffd82e2ae85ae427794f85b23cf8", + "scope": "Actual Dify factory/WorkflowEntry/pause-layer runtime/filter/envelope component capture. Synthetic HITL callback, repository sink and pause-reason enrichment; no DB/storage/provider/network. Exact graph saved before engine initialization.", + "files": { + "container.runtime.json": "3e343e7dea8b9a0e2131e80854cb8aba613a2e0b010306ad103a3f47410b58ca", + "root.runtime.json": "3f0631914bca75916367799af4ca1d6ae6a2949512073bb3d84915277ba9affc", + "container.graph.json": "ce6df9f04b49439074146a8bd53ec31f3f70aa9d15f9f0d3d830db71e93043b6", + "root.envelope.json": "f60a52b049feac198732efc56a5c1aa68dd7dab27454ab4f709adb544b264706", + "root.graph.json": "c3b358180f5e8544e396ec6b001ac5a6cce1c09c54b08cebab91d8f4cc13406b", + "container.envelope.json": "091db2769a0b22666a58c80052e0a634856d44d03101227b426914f0b3b1ae97", + "root.filter.json": "5a94b4a6df349b8e4001dc32e6662014bd8677159cbfbff0a3e037c833611b24", + "container.filter.json": "80e97dce3ae2d7eb5fb66b41a04a8b345a0f057b66a765b0bfc1adafb534c4d8", + "root.events.json": "f9bc8130b4f89e53bec9939341d2242d098fcfb53eb4dddbf4f6cd35aa6bc47d", + "container.events.json": "a928a3d42590ea5f28fe0072c560f3027e6a8f7b9ded08ac8fb0d2e0923e6a6c" + } +} \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.envelope.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.envelope.json new file mode 100644 index 00000000000..9df31b7e9ad --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.envelope.json @@ -0,0 +1 @@ +{"version":"1","generate_entity":{"type":"workflow","entity":{"builder_execution":null,"native_test_required":false,"native_test_ref":null,"task_id":"captured-task-container","app_config":{"tenant_id":"tenant","app_id":"app","app_mode":"workflow","additional_features":null,"variables":[],"sensitive_word_avoidance":null,"workflow_id":"workflow"},"file_upload_config":null,"inputs":{},"files":[],"user_id":"user","stream":true,"invoke_from":"debugger","call_depth":0,"extras":{},"workflow_execution_id":"captured-run-container","single_iteration_run":null,"single_loop_run":null}},"serialized_graph_runtime_state":"{\"version\":\"2.0\",\"start_at\":0.0,\"node_run_steps\":3,\"llm_usage\":{\"prompt_tokens\":0,\"prompt_unit_price\":\"0.0\",\"prompt_price_unit\":\"0.0\",\"prompt_price\":\"0.0\",\"completion_tokens\":0,\"completion_unit_price\":\"0.0\",\"completion_price_unit\":\"0.0\",\"completion_price\":\"0.0\",\"total_tokens\":0,\"total_price\":\"0.0\",\"currency\":\"USD\",\"latency\":0.0,\"time_to_first_token\":null,\"time_to_generate\":null},\"outputs\":{\"answer\":\"container-before-pause\",\"files\":{\"value_type\":\"array[file]\",\"value\":[]}},\"variable_pool\":{\"variable_dictionary\":{\"sys\":{\"user_id\":{\"value_type\":\"string\",\"value\":\"user\",\"id\":\"fc878ca6-6d3f-4b2b-83b0-698f0a3c34f5\",\"name\":\"user_id\",\"description\":\"\",\"selector\":[\"sys\",\"user_id\"]},\"app_id\":{\"value_type\":\"string\",\"value\":\"app\",\"id\":\"f74ae13c-5a3c-41fa-a09b-4589d8a5bf15\",\"name\":\"app_id\",\"description\":\"\",\"selector\":[\"sys\",\"app_id\"]},\"workflow_id\":{\"value_type\":\"string\",\"value\":\"workflow\",\"id\":\"07876975-5a42-4047-b011-f9657ac8136a\",\"name\":\"workflow_id\",\"description\":\"\",\"selector\":[\"sys\",\"workflow_id\"]},\"workflow_run_id\":{\"value_type\":\"string\",\"value\":\"captured-run-container\",\"id\":\"ebd589d8-43e9-4604-90c9-b82980d49c61\",\"name\":\"workflow_run_id\",\"description\":\"\",\"selector\":[\"sys\",\"workflow_run_id\"]},\"files\":{\"value_type\":\"array[any]\",\"value\":[],\"id\":\"d069edfd-ad2a-4d30-9373-295f66c6dab3\",\"name\":\"files\",\"description\":\"\",\"selector\":[\"sys\",\"files\"]}},\"captured-start\":{\"sys.user_id\":{\"value_type\":\"string\",\"value\":\"user\",\"id\":\"38900a36-62bd-428a-bbd9-83342a887dc1\",\"name\":\"sys.user_id\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.user_id\"]},\"sys.app_id\":{\"value_type\":\"string\",\"value\":\"app\",\"id\":\"c81ba8aa-7c69-456b-a6f6-84bca62526e1\",\"name\":\"sys.app_id\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.app_id\"]},\"sys.workflow_id\":{\"value_type\":\"string\",\"value\":\"workflow\",\"id\":\"5b929b5f-061b-4a04-adeb-66b1459ba0e6\",\"name\":\"sys.workflow_id\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.workflow_id\"]},\"sys.workflow_run_id\":{\"value_type\":\"string\",\"value\":\"captured-run-container\",\"id\":\"01a3aac1-3d3c-4c01-b450-bb578e3afe65\",\"name\":\"sys.workflow_run_id\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.workflow_run_id\"]},\"sys.files\":{\"value_type\":\"array[any]\",\"value\":[],\"id\":\"cb318ce2-a9a7-4eff-ae02-6dc90f7e4577\",\"name\":\"sys.files\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.files\"]}},\"captured-preface\":{\"answer\":{\"value_type\":\"string\",\"value\":\"container-before-pause\",\"id\":\"eb3f467d-5a98-4a5b-8077-989aeaa06134\",\"name\":\"answer\",\"description\":\"\",\"selector\":[\"captured-preface\",\"answer\"]},\"files\":{\"value_type\":\"array[file]\",\"value\":[],\"id\":\"c59c1dfc-2d1e-492a-8f4d-9cca262a4fd1\",\"name\":\"files\",\"description\":\"\",\"selector\":[\"captured-preface\",\"files\"]}},\"captured-answer\":{\"answer\":{\"value_type\":\"string\",\"value\":\"before-pause\",\"id\":\"870a51cf-4aec-4c99-808d-31d066c38e3c\",\"name\":\"answer\",\"description\":\"\",\"selector\":[\"captured-answer\",\"answer\"]},\"files\":{\"value_type\":\"array[file]\",\"value\":[],\"id\":\"b07c239e-881b-4f31-b6f8-27f3bf1a2e77\",\"name\":\"files\",\"description\":\"\",\"selector\":[\"captured-answer\",\"files\"]}},\"captured-human\":{},\"captured-loop-start\":{}}},\"ready_queue\":\"{\\\"version\\\":\\\"2.0\\\",\\\"items\\\":[]}\",\"graph_execution\":\"{\\\"version\\\":\\\"2.0\\\",\\\"workflow_id\\\":\\\"workflow\\\",\\\"started\\\":true,\\\"completed\\\":false,\\\"aborted\\\":false,\\\"paused\\\":true,\\\"pause_reasons\\\":[{\\\"TYPE\\\":\\\"hitl_required\\\",\\\"session_id\\\":\\\"synthetic-form\\\",\\\"node_id\\\":\\\"captured-human\\\",\\\"node_title\\\":\\\"Approve\\\"}],\\\"error\\\":null,\\\"exceptions_count\\\":0,\\\"node_executions\\\":[{\\\"frame_id\\\":\\\"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0\\\",\\\"node_id\\\":\\\"captured-answer\\\",\\\"retry_count\\\":0,\\\"execution_id\\\":\\\"56180ad8-5025-4835-8f7b-e342f582df91\\\"},{\\\"frame_id\\\":\\\"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0\\\",\\\"node_id\\\":\\\"captured-human\\\",\\\"retry_count\\\":0,\\\"execution_id\\\":\\\"7110c866-2ac2-4c39-9b81-a37c4a3b76b1\\\"},{\\\"frame_id\\\":\\\"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0\\\",\\\"node_id\\\":\\\"captured-loop-start\\\",\\\"retry_count\\\":0,\\\"execution_id\\\":\\\"9d73132b-1f29-4cb5-9c0f-1bf1f781df7a\\\"},{\\\"frame_id\\\":\\\"root\\\",\\\"node_id\\\":\\\"captured-loop\\\",\\\"retry_count\\\":0,\\\"execution_id\\\":\\\"94fa2f00-273c-4220-85d7-449b938774dc\\\"},{\\\"frame_id\\\":\\\"root\\\",\\\"node_id\\\":\\\"captured-preface\\\",\\\"retry_count\\\":0,\\\"execution_id\\\":\\\"251337e4-5d8f-4395-babd-e9530294c7b0\\\"},{\\\"frame_id\\\":\\\"root\\\",\\\"node_id\\\":\\\"captured-start\\\",\\\"retry_count\\\":0,\\\"execution_id\\\":\\\"44454c4f-e4d8-440f-bbfb-4bc17fd8fd2e\\\"}]}\",\"deferred_ready_tasks\":\"{\\\"version\\\":\\\"2.0\\\",\\\"items\\\":[{\\\"kind\\\":\\\"start\\\",\\\"frame_id\\\":\\\"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0\\\",\\\"node_id\\\":\\\"captured-human\\\"}]}\",\"container_runs\":[{\"kind\":\"loop\",\"invocation_id\":\"d030c4a7-c859-4db4-87c0-3e0129eccd90\",\"frame_id\":\"root\",\"node_id\":\"captured-loop\",\"started_at\":\"2026-10-15T21:03:08.626173\",\"inputs\":{\"loop_count\":{\"value_type\":\"integer\",\"value\":2}},\"outputs\":{},\"loop_count\":2,\"root_node_id\":\"captured-loop-start\",\"loop_variable_selectors\":{},\"loop_node_ids\":[\"captured-answer\",\"captured-human\",\"captured-loop-start\"],\"duration_map\":{},\"variable_map\":{},\"usage\":{\"prompt_tokens\":0,\"prompt_unit_price\":\"0.0\",\"prompt_price_unit\":\"0.0\",\"prompt_price\":\"0.0\",\"completion_tokens\":0,\"completion_unit_price\":\"0.0\",\"completion_price_unit\":\"0.0\",\"completion_price\":\"0.0\",\"total_tokens\":0,\"total_price\":\"0.0\",\"currency\":\"USD\",\"latency\":0.0,\"time_to_first_token\":null,\"time_to_generate\":null},\"completed_count\":0,\"reached_break\":false}],\"container_frames\":[{\"kind\":\"loop\",\"frame_id\":\"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0\",\"parent_invocation_id\":\"d030c4a7-c859-4db4-87c0-3e0129eccd90\",\"root_node_id\":\"captured-loop-start\",\"index\":0,\"started_at\":\"2026-10-15T21:03:08.627218\",\"reached_break\":false,\"errors\":[],\"runtime_data\":{\"variable_pool\":\"parent\",\"outputs\":{\"answer\":\"before-pause\",\"files\":{\"value_type\":\"array[file]\",\"value\":[]}},\"llm_usage\":{\"prompt_tokens\":0,\"prompt_unit_price\":\"0.0\",\"prompt_price_unit\":\"0.0\",\"prompt_price\":\"0.0\",\"completion_tokens\":0,\"completion_unit_price\":\"0.0\",\"completion_price_unit\":\"0.0\",\"completion_price\":\"0.0\",\"total_tokens\":0,\"total_price\":\"0.0\",\"currency\":\"USD\",\"latency\":0.0,\"time_to_first_token\":null,\"time_to_generate\":null},\"node_run_steps\":3,\"graph_node_states\":{\"captured-start\":\"unknown\",\"captured-preface\":\"unknown\",\"captured-loop\":\"unknown\",\"captured-loop-start\":\"taken\",\"captured-answer\":\"taken\",\"captured-human\":\"unknown\",\"captured-end\":\"unknown\"},\"graph_edge_states\":{\"edge_0\":\"unknown\",\"edge_1\":\"unknown\",\"edge_2\":\"taken\",\"edge_3\":\"taken\",\"edge_4\":\"unknown\"}}}],\"graph_node_states\":{\"captured-start\":\"taken\",\"captured-preface\":\"taken\",\"captured-loop\":\"taken\",\"captured-loop-start\":\"unknown\",\"captured-answer\":\"unknown\",\"captured-human\":\"unknown\",\"captured-end\":\"unknown\"},\"graph_edge_states\":{\"edge_0\":\"taken\",\"edge_1\":\"taken\",\"edge_2\":\"unknown\",\"edge_3\":\"unknown\",\"edge_4\":\"unknown\"}}","serialized_response_stream_filter_state":"{\"type\":\"ResponseStreamFilter\",\"version\":\"1.0\",\"response_nodes\":[\"captured-answer\",\"captured-end\",\"captured-preface\"],\"active_session\":null,\"waiting_sessions\":[],\"pending_sessions\":[{\"node_id\":\"captured-answer\",\"index\":0},{\"node_id\":\"captured-end\",\"index\":0}],\"node_execution_ids\":{\"captured-answer\":\"56180ad8-5025-4835-8f7b-e342f582df91\",\"captured-human\":\"7110c866-2ac2-4c39-9b81-a37c4a3b76b1\",\"captured-loop\":\"94fa2f00-273c-4220-85d7-449b938774dc\",\"captured-preface\":\"251337e4-5d8f-4395-babd-e9530294c7b0\",\"captured-start\":\"44454c4f-e4d8-440f-bbfb-4bc17fd8fd2e\"},\"paths_map\":{\"captured-answer\":[],\"captured-end\":[[\"edge_4\"]],\"captured-preface\":[[]]},\"stream_buffers\":[],\"stream_positions\":[],\"closed_streams\":[]}"} \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.events.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.events.json new file mode 100644 index 00000000000..7ef07a6dcec --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.events.json @@ -0,0 +1 @@ +[{"reason": "initial"}, {"id": "411a2ff9-48b6-4444-b3e0-ae4c0b4ad40a", "node_id": "captured-preface", "node_type": "answer", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "selector": ["captured-preface", "answer"], "chunk": "container-before-pause", "is_final": true}, {"id": "44454c4f-e4d8-440f-bbfb-4bc17fd8fd2e", "node_id": "captured-start", "node_type": "start", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "node_title": "", "predecessor_node_id": null, "start_at": "2026-10-15T21:03:08.524903", "extras": {}, "provider_type": "", "provider_id": ""}, {"id": "44454c4f-e4d8-440f-bbfb-4bc17fd8fd2e", "node_id": "captured-start", "node_type": "start", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "succeeded", "inputs": {}, "process_data": {}, "outputs": {"sys.user_id": "user", "sys.app_id": "app", "sys.workflow_id": "workflow", "sys.workflow_run_id": "captured-run-container", "sys.files": []}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "start_at": "2026-10-15T21:03:08.524903", "finished_at": "2026-10-15T21:03:08.525044"}, {"id": "251337e4-5d8f-4395-babd-e9530294c7b0", "node_id": "captured-preface", "node_type": "answer", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "node_title": "", "predecessor_node_id": null, "start_at": "2026-10-15T21:03:08.526064", "extras": {}, "provider_type": "", "provider_id": ""}, {"id": "251337e4-5d8f-4395-babd-e9530294c7b0", "node_id": "captured-preface", "node_type": "answer", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "succeeded", "inputs": {}, "process_data": {}, "outputs": {"answer": "container-before-pause", "files": {"value_type": "array[file]", "value": []}}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "start_at": "2026-10-15T21:03:08.526064", "finished_at": "2026-10-15T21:03:08.526143"}, {"id": "94fa2f00-273c-4220-85d7-449b938774dc", "node_id": "captured-loop", "node_type": "loop", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "node_title": "Two rounds", "predecessor_node_id": null, "start_at": "2026-10-15T21:03:08.626173", "extras": {}, "provider_type": "", "provider_id": ""}, {"id": "94fa2f00-273c-4220-85d7-449b938774dc", "node_id": "captured-loop", "node_type": "loop", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "node_title": "Two rounds", "start_at": "2026-10-15T21:03:08.626295", "inputs": {"loop_count": 2}, "metadata": {"loop_length": 2}, "predecessor_node_id": null}, {"id": "56180ad8-5025-4835-8f7b-e342f582df91", "node_id": "captured-answer", "node_type": "answer", "in_iteration_id": null, "in_loop_id": "captured-loop", "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {"loop_id": "captured-loop", "loop_index": 0}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "node_title": "", "predecessor_node_id": null, "start_at": "2026-10-15T21:03:08.628138", "extras": {}, "provider_type": "", "provider_id": ""}, {"id": "56180ad8-5025-4835-8f7b-e342f582df91", "node_id": "captured-answer", "node_type": "answer", "in_iteration_id": null, "in_loop_id": "captured-loop", "node_version": "1", "node_run_result": {"status": "succeeded", "inputs": {}, "process_data": {}, "outputs": {"answer": "before-pause", "files": {"value_type": "array[file]", "value": []}}, "metadata": {"loop_id": "captured-loop", "loop_index": 0}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "start_at": "2026-10-15T21:03:08.628138", "finished_at": "2026-10-15T21:03:08.628227"}, {"id": "7110c866-2ac2-4c39-9b81-a37c4a3b76b1", "node_id": "captured-human", "node_type": "human-input", "in_iteration_id": null, "in_loop_id": "captured-loop", "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {"loop_id": "captured-loop", "loop_index": 0}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "node_title": "Approve", "predecessor_node_id": null, "start_at": "2026-10-15T21:03:08.727312", "extras": {}, "provider_type": "", "provider_id": ""}, {"id": "7110c866-2ac2-4c39-9b81-a37c4a3b76b1", "node_id": "captured-human", "node_type": "human-input", "in_iteration_id": null, "in_loop_id": "captured-loop", "node_version": "1", "node_run_result": {"status": "paused", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {"loop_id": "captured-loop", "loop_index": 0}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "reason": {"TYPE": "hitl_required", "session_id": "synthetic-form", "node_id": "captured-human", "node_title": "Approve"}}, {"reasons": [{"TYPE": "hitl_required", "session_id": "synthetic-form", "node_id": "captured-human", "node_title": "Approve"}], "outputs": {"answer": "container-before-pause", "files": {"value_type": "array[file]", "value": []}}}] \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.filter.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.filter.json new file mode 100644 index 00000000000..df50518624a --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.filter.json @@ -0,0 +1 @@ +{"type":"ResponseStreamFilter","version":"1.0","response_nodes":["captured-answer","captured-end","captured-preface"],"active_session":null,"waiting_sessions":[],"pending_sessions":[{"node_id":"captured-answer","index":0},{"node_id":"captured-end","index":0}],"node_execution_ids":{"captured-answer":"56180ad8-5025-4835-8f7b-e342f582df91","captured-human":"7110c866-2ac2-4c39-9b81-a37c4a3b76b1","captured-loop":"94fa2f00-273c-4220-85d7-449b938774dc","captured-preface":"251337e4-5d8f-4395-babd-e9530294c7b0","captured-start":"44454c4f-e4d8-440f-bbfb-4bc17fd8fd2e"},"paths_map":{"captured-answer":[],"captured-end":[["edge_4"]],"captured-preface":[[]]},"stream_buffers":[],"stream_positions":[],"closed_streams":[]} \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.graph.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.graph.json new file mode 100644 index 00000000000..2205af37234 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.graph.json @@ -0,0 +1,97 @@ +{ + "nodes": [ + { + "id": "captured-start", + "data": { + "type": "start", + "variables": [] + } + }, + { + "id": "captured-preface", + "data": { + "type": "answer", + "answer": "container-before-pause" + } + }, + { + "id": "captured-loop", + "data": { + "type": "loop", + "title": "Two rounds", + "loop_count": 2, + "start_node_id": "captured-loop-start", + "break_conditions": [], + "logical_operator": "and", + "loop_variables": [] + } + }, + { + "id": "captured-loop-start", + "data": { + "type": "loop-start", + "loop_id": "captured-loop" + } + }, + { + "id": "captured-answer", + "data": { + "type": "answer", + "answer": "before-pause", + "loop_id": "captured-loop" + } + }, + { + "id": "captured-human", + "data": { + "type": "human-input", + "title": "Approve", + "loop_id": "captured-loop" + } + }, + { + "id": "captured-end", + "data": { + "type": "end", + "outputs": [] + } + } + ], + "edges": [ + { + "id": "captured-edge-0", + "source": "captured-start", + "target": "captured-preface", + "sourceHandle": "source", + "targetHandle": "target" + }, + { + "id": "captured-edge-1", + "source": "captured-preface", + "target": "captured-loop", + "sourceHandle": "source", + "targetHandle": "target" + }, + { + "id": "captured-edge-2", + "source": "captured-loop-start", + "target": "captured-answer", + "sourceHandle": "source", + "targetHandle": "target" + }, + { + "id": "captured-edge-3", + "source": "captured-answer", + "target": "captured-human", + "sourceHandle": "source", + "targetHandle": "target" + }, + { + "id": "captured-edge-4", + "source": "captured-loop", + "target": "captured-end", + "sourceHandle": "source", + "targetHandle": "target" + } + ] +} \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.runtime.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.runtime.json new file mode 100644 index 00000000000..5511465cb0a --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/container.runtime.json @@ -0,0 +1 @@ +{"version":"2.0","start_at":0.0,"node_run_steps":3,"llm_usage":{"prompt_tokens":0,"prompt_unit_price":"0.0","prompt_price_unit":"0.0","prompt_price":"0.0","completion_tokens":0,"completion_unit_price":"0.0","completion_price_unit":"0.0","completion_price":"0.0","total_tokens":0,"total_price":"0.0","currency":"USD","latency":0.0,"time_to_first_token":null,"time_to_generate":null},"outputs":{"answer":"container-before-pause","files":{"value_type":"array[file]","value":[]}},"variable_pool":{"variable_dictionary":{"sys":{"user_id":{"value_type":"string","value":"user","id":"fc878ca6-6d3f-4b2b-83b0-698f0a3c34f5","name":"user_id","description":"","selector":["sys","user_id"]},"app_id":{"value_type":"string","value":"app","id":"f74ae13c-5a3c-41fa-a09b-4589d8a5bf15","name":"app_id","description":"","selector":["sys","app_id"]},"workflow_id":{"value_type":"string","value":"workflow","id":"07876975-5a42-4047-b011-f9657ac8136a","name":"workflow_id","description":"","selector":["sys","workflow_id"]},"workflow_run_id":{"value_type":"string","value":"captured-run-container","id":"ebd589d8-43e9-4604-90c9-b82980d49c61","name":"workflow_run_id","description":"","selector":["sys","workflow_run_id"]},"files":{"value_type":"array[any]","value":[],"id":"d069edfd-ad2a-4d30-9373-295f66c6dab3","name":"files","description":"","selector":["sys","files"]}},"captured-start":{"sys.user_id":{"value_type":"string","value":"user","id":"38900a36-62bd-428a-bbd9-83342a887dc1","name":"sys.user_id","description":"","selector":["captured-start","sys.user_id"]},"sys.app_id":{"value_type":"string","value":"app","id":"c81ba8aa-7c69-456b-a6f6-84bca62526e1","name":"sys.app_id","description":"","selector":["captured-start","sys.app_id"]},"sys.workflow_id":{"value_type":"string","value":"workflow","id":"5b929b5f-061b-4a04-adeb-66b1459ba0e6","name":"sys.workflow_id","description":"","selector":["captured-start","sys.workflow_id"]},"sys.workflow_run_id":{"value_type":"string","value":"captured-run-container","id":"01a3aac1-3d3c-4c01-b450-bb578e3afe65","name":"sys.workflow_run_id","description":"","selector":["captured-start","sys.workflow_run_id"]},"sys.files":{"value_type":"array[any]","value":[],"id":"cb318ce2-a9a7-4eff-ae02-6dc90f7e4577","name":"sys.files","description":"","selector":["captured-start","sys.files"]}},"captured-preface":{"answer":{"value_type":"string","value":"container-before-pause","id":"eb3f467d-5a98-4a5b-8077-989aeaa06134","name":"answer","description":"","selector":["captured-preface","answer"]},"files":{"value_type":"array[file]","value":[],"id":"c59c1dfc-2d1e-492a-8f4d-9cca262a4fd1","name":"files","description":"","selector":["captured-preface","files"]}},"captured-answer":{"answer":{"value_type":"string","value":"before-pause","id":"870a51cf-4aec-4c99-808d-31d066c38e3c","name":"answer","description":"","selector":["captured-answer","answer"]},"files":{"value_type":"array[file]","value":[],"id":"b07c239e-881b-4f31-b6f8-27f3bf1a2e77","name":"files","description":"","selector":["captured-answer","files"]}},"captured-human":{},"captured-loop-start":{}}},"ready_queue":"{\"version\":\"2.0\",\"items\":[]}","graph_execution":"{\"version\":\"2.0\",\"workflow_id\":\"workflow\",\"started\":true,\"completed\":false,\"aborted\":false,\"paused\":true,\"pause_reasons\":[{\"TYPE\":\"hitl_required\",\"session_id\":\"synthetic-form\",\"node_id\":\"captured-human\",\"node_title\":\"Approve\"}],\"error\":null,\"exceptions_count\":0,\"node_executions\":[{\"frame_id\":\"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0\",\"node_id\":\"captured-answer\",\"retry_count\":0,\"execution_id\":\"56180ad8-5025-4835-8f7b-e342f582df91\"},{\"frame_id\":\"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0\",\"node_id\":\"captured-human\",\"retry_count\":0,\"execution_id\":\"7110c866-2ac2-4c39-9b81-a37c4a3b76b1\"},{\"frame_id\":\"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0\",\"node_id\":\"captured-loop-start\",\"retry_count\":0,\"execution_id\":\"9d73132b-1f29-4cb5-9c0f-1bf1f781df7a\"},{\"frame_id\":\"root\",\"node_id\":\"captured-loop\",\"retry_count\":0,\"execution_id\":\"94fa2f00-273c-4220-85d7-449b938774dc\"},{\"frame_id\":\"root\",\"node_id\":\"captured-preface\",\"retry_count\":0,\"execution_id\":\"251337e4-5d8f-4395-babd-e9530294c7b0\"},{\"frame_id\":\"root\",\"node_id\":\"captured-start\",\"retry_count\":0,\"execution_id\":\"44454c4f-e4d8-440f-bbfb-4bc17fd8fd2e\"}]}","deferred_ready_tasks":"{\"version\":\"2.0\",\"items\":[{\"kind\":\"start\",\"frame_id\":\"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0\",\"node_id\":\"captured-human\"}]}","container_runs":[{"kind":"loop","invocation_id":"d030c4a7-c859-4db4-87c0-3e0129eccd90","frame_id":"root","node_id":"captured-loop","started_at":"2026-10-15T21:03:08.626173","inputs":{"loop_count":{"value_type":"integer","value":2}},"outputs":{},"loop_count":2,"root_node_id":"captured-loop-start","loop_variable_selectors":{},"loop_node_ids":["captured-answer","captured-human","captured-loop-start"],"duration_map":{},"variable_map":{},"usage":{"prompt_tokens":0,"prompt_unit_price":"0.0","prompt_price_unit":"0.0","prompt_price":"0.0","completion_tokens":0,"completion_unit_price":"0.0","completion_price_unit":"0.0","completion_price":"0.0","total_tokens":0,"total_price":"0.0","currency":"USD","latency":0.0,"time_to_first_token":null,"time_to_generate":null},"completed_count":0,"reached_break":false}],"container_frames":[{"kind":"loop","frame_id":"d030c4a7-c859-4db4-87c0-3e0129eccd90:loop:0","parent_invocation_id":"d030c4a7-c859-4db4-87c0-3e0129eccd90","root_node_id":"captured-loop-start","index":0,"started_at":"2026-10-15T21:03:08.627218","reached_break":false,"errors":[],"runtime_data":{"variable_pool":"parent","outputs":{"answer":"before-pause","files":{"value_type":"array[file]","value":[]}},"llm_usage":{"prompt_tokens":0,"prompt_unit_price":"0.0","prompt_price_unit":"0.0","prompt_price":"0.0","completion_tokens":0,"completion_unit_price":"0.0","completion_price_unit":"0.0","completion_price":"0.0","total_tokens":0,"total_price":"0.0","currency":"USD","latency":0.0,"time_to_first_token":null,"time_to_generate":null},"node_run_steps":3,"graph_node_states":{"captured-start":"unknown","captured-preface":"unknown","captured-loop":"unknown","captured-loop-start":"taken","captured-answer":"taken","captured-human":"unknown","captured-end":"unknown"},"graph_edge_states":{"edge_0":"unknown","edge_1":"unknown","edge_2":"taken","edge_3":"taken","edge_4":"unknown"}}}],"graph_node_states":{"captured-start":"taken","captured-preface":"taken","captured-loop":"taken","captured-loop-start":"unknown","captured-answer":"unknown","captured-human":"unknown","captured-end":"unknown"},"graph_edge_states":{"edge_0":"taken","edge_1":"taken","edge_2":"unknown","edge_3":"unknown","edge_4":"unknown"}} \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.envelope.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.envelope.json new file mode 100644 index 00000000000..ab6ac3c007f --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.envelope.json @@ -0,0 +1 @@ +{"version":"1","generate_entity":{"type":"workflow","entity":{"builder_execution":null,"native_test_required":false,"native_test_ref":null,"task_id":"captured-task-root","app_config":{"tenant_id":"tenant","app_id":"app","app_mode":"workflow","additional_features":null,"variables":[],"sensitive_word_avoidance":null,"workflow_id":"workflow"},"file_upload_config":null,"inputs":{},"files":[],"user_id":"user","stream":true,"invoke_from":"debugger","call_depth":0,"extras":{},"workflow_execution_id":"captured-run-root","single_iteration_run":null,"single_loop_run":null}},"serialized_graph_runtime_state":"{\"version\":\"2.0\",\"start_at\":0.0,\"node_run_steps\":3,\"llm_usage\":{\"prompt_tokens\":0,\"prompt_unit_price\":\"0.0\",\"prompt_price_unit\":\"0.0\",\"prompt_price\":\"0.0\",\"completion_tokens\":0,\"completion_unit_price\":\"0.0\",\"completion_price_unit\":\"0.0\",\"completion_price\":\"0.0\",\"total_tokens\":0,\"total_price\":\"0.0\",\"currency\":\"USD\",\"latency\":0.0,\"time_to_first_token\":null,\"time_to_generate\":null},\"outputs\":{\"answer\":\"before-pause\",\"files\":{\"value_type\":\"array[file]\",\"value\":[]}},\"variable_pool\":{\"variable_dictionary\":{\"sys\":{\"user_id\":{\"value_type\":\"string\",\"value\":\"user\",\"id\":\"00cc23f7-96a0-4327-b234-ce32513b7abd\",\"name\":\"user_id\",\"description\":\"\",\"selector\":[\"sys\",\"user_id\"]},\"app_id\":{\"value_type\":\"string\",\"value\":\"app\",\"id\":\"1b327273-d75e-4860-ab52-18e7b508b88a\",\"name\":\"app_id\",\"description\":\"\",\"selector\":[\"sys\",\"app_id\"]},\"workflow_id\":{\"value_type\":\"string\",\"value\":\"workflow\",\"id\":\"e30168d1-920e-4203-b3ed-2cd2d6be7578\",\"name\":\"workflow_id\",\"description\":\"\",\"selector\":[\"sys\",\"workflow_id\"]},\"workflow_run_id\":{\"value_type\":\"string\",\"value\":\"captured-run-root\",\"id\":\"c2269c69-f9d0-4f8f-9e96-d493beac69f2\",\"name\":\"workflow_run_id\",\"description\":\"\",\"selector\":[\"sys\",\"workflow_run_id\"]},\"files\":{\"value_type\":\"array[any]\",\"value\":[],\"id\":\"53f8d23b-63f3-480a-9bf2-79a06e75fd39\",\"name\":\"files\",\"description\":\"\",\"selector\":[\"sys\",\"files\"]}},\"captured-start\":{\"sys.user_id\":{\"value_type\":\"string\",\"value\":\"user\",\"id\":\"80f3a1d7-ca91-46d9-afdf-fd2cb4e69413\",\"name\":\"sys.user_id\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.user_id\"]},\"sys.app_id\":{\"value_type\":\"string\",\"value\":\"app\",\"id\":\"dafa45ea-bb01-40ae-9704-feebcee9c778\",\"name\":\"sys.app_id\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.app_id\"]},\"sys.workflow_id\":{\"value_type\":\"string\",\"value\":\"workflow\",\"id\":\"9010247a-3615-40a5-8880-6f9598b1c3b3\",\"name\":\"sys.workflow_id\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.workflow_id\"]},\"sys.workflow_run_id\":{\"value_type\":\"string\",\"value\":\"captured-run-root\",\"id\":\"256af817-f207-491d-9f78-cca03530c6c1\",\"name\":\"sys.workflow_run_id\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.workflow_run_id\"]},\"sys.files\":{\"value_type\":\"array[any]\",\"value\":[],\"id\":\"bb839f28-2c07-4f3b-9a50-69adc1c89b39\",\"name\":\"sys.files\",\"description\":\"\",\"selector\":[\"captured-start\",\"sys.files\"]}},\"captured-answer\":{\"answer\":{\"value_type\":\"string\",\"value\":\"before-pause\",\"id\":\"336c3665-9169-4a4a-a413-d2285763cec7\",\"name\":\"answer\",\"description\":\"\",\"selector\":[\"captured-answer\",\"answer\"]},\"files\":{\"value_type\":\"array[file]\",\"value\":[],\"id\":\"7b06316e-3a09-4cfa-98f0-a8485d7fbf9a\",\"name\":\"files\",\"description\":\"\",\"selector\":[\"captured-answer\",\"files\"]}}}},\"ready_queue\":\"{\\\"version\\\":\\\"2.0\\\",\\\"items\\\":[]}\",\"graph_execution\":\"{\\\"version\\\":\\\"2.0\\\",\\\"workflow_id\\\":\\\"workflow\\\",\\\"started\\\":true,\\\"completed\\\":false,\\\"aborted\\\":false,\\\"paused\\\":true,\\\"pause_reasons\\\":[{\\\"TYPE\\\":\\\"hitl_required\\\",\\\"session_id\\\":\\\"synthetic-form\\\",\\\"node_id\\\":\\\"captured-human\\\",\\\"node_title\\\":\\\"Approve\\\"}],\\\"error\\\":null,\\\"exceptions_count\\\":0,\\\"node_executions\\\":[{\\\"frame_id\\\":\\\"root\\\",\\\"node_id\\\":\\\"captured-answer\\\",\\\"retry_count\\\":0,\\\"execution_id\\\":\\\"5500d81a-a7ee-4015-ba60-1b55ad9a4cfb\\\"},{\\\"frame_id\\\":\\\"root\\\",\\\"node_id\\\":\\\"captured-human\\\",\\\"retry_count\\\":0,\\\"execution_id\\\":\\\"7e790e03-3d00-4aa8-b957-2f8060894766\\\"},{\\\"frame_id\\\":\\\"root\\\",\\\"node_id\\\":\\\"captured-start\\\",\\\"retry_count\\\":0,\\\"execution_id\\\":\\\"ac207c1f-6d89-4374-88ff-3c1f2bd03bb8\\\"}]}\",\"deferred_ready_tasks\":\"{\\\"version\\\":\\\"2.0\\\",\\\"items\\\":[{\\\"kind\\\":\\\"start\\\",\\\"frame_id\\\":\\\"root\\\",\\\"node_id\\\":\\\"captured-human\\\"}]}\",\"container_runs\":[],\"container_frames\":[],\"graph_node_states\":{\"captured-start\":\"taken\",\"captured-answer\":\"taken\",\"captured-human\":\"unknown\",\"captured-end\":\"unknown\"},\"graph_edge_states\":{\"edge_0\":\"taken\",\"edge_1\":\"taken\",\"edge_2\":\"unknown\"}}","serialized_response_stream_filter_state":"{\"type\":\"ResponseStreamFilter\",\"version\":\"1.0\",\"response_nodes\":[\"captured-answer\",\"captured-end\"],\"active_session\":null,\"waiting_sessions\":[],\"pending_sessions\":[{\"node_id\":\"captured-end\",\"index\":0}],\"node_execution_ids\":{\"captured-answer\":\"5500d81a-a7ee-4015-ba60-1b55ad9a4cfb\",\"captured-human\":\"7e790e03-3d00-4aa8-b957-2f8060894766\",\"captured-start\":\"ac207c1f-6d89-4374-88ff-3c1f2bd03bb8\"},\"paths_map\":{\"captured-answer\":[[]],\"captured-end\":[[\"edge_2\"]]},\"stream_buffers\":[],\"stream_positions\":[],\"closed_streams\":[]}"} \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.events.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.events.json new file mode 100644 index 00000000000..61cb40dd691 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.events.json @@ -0,0 +1 @@ +[{"reason": "initial"}, {"id": "193fc8a2-0f15-4652-86c7-f949059f23da", "node_id": "captured-answer", "node_type": "answer", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "selector": ["captured-answer", "answer"], "chunk": "before-pause", "is_final": true}, {"id": "ac207c1f-6d89-4374-88ff-3c1f2bd03bb8", "node_id": "captured-start", "node_type": "start", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "node_title": "", "predecessor_node_id": null, "start_at": "2026-10-15T21:03:08.214125", "extras": {}, "provider_type": "", "provider_id": ""}, {"id": "ac207c1f-6d89-4374-88ff-3c1f2bd03bb8", "node_id": "captured-start", "node_type": "start", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "succeeded", "inputs": {}, "process_data": {}, "outputs": {"sys.user_id": "user", "sys.app_id": "app", "sys.workflow_id": "workflow", "sys.workflow_run_id": "captured-run-root", "sys.files": []}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "start_at": "2026-10-15T21:03:08.214125", "finished_at": "2026-10-15T21:03:08.214263"}, {"id": "5500d81a-a7ee-4015-ba60-1b55ad9a4cfb", "node_id": "captured-answer", "node_type": "answer", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "node_title": "", "predecessor_node_id": null, "start_at": "2026-10-15T21:03:08.315116", "extras": {}, "provider_type": "", "provider_id": ""}, {"id": "5500d81a-a7ee-4015-ba60-1b55ad9a4cfb", "node_id": "captured-answer", "node_type": "answer", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "succeeded", "inputs": {}, "process_data": {}, "outputs": {"answer": "before-pause", "files": {"value_type": "array[file]", "value": []}}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "start_at": "2026-10-15T21:03:08.315116", "finished_at": "2026-10-15T21:03:08.315255"}, {"id": "7e790e03-3d00-4aa8-b957-2f8060894766", "node_id": "captured-human", "node_type": "human-input", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "pending", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "node_title": "Approve", "predecessor_node_id": null, "start_at": "2026-10-15T21:03:08.315568", "extras": {}, "provider_type": "", "provider_id": ""}, {"id": "7e790e03-3d00-4aa8-b957-2f8060894766", "node_id": "captured-human", "node_type": "human-input", "in_iteration_id": null, "in_loop_id": null, "node_version": "1", "node_run_result": {"status": "paused", "inputs": {}, "process_data": {}, "outputs": {}, "metadata": {}, "llm_usage": {"prompt_tokens": 0, "prompt_unit_price": "0.0", "prompt_price_unit": "0.0", "prompt_price": "0.0", "completion_tokens": 0, "completion_unit_price": "0.0", "completion_price_unit": "0.0", "completion_price": "0.0", "total_tokens": 0, "total_price": "0.0", "currency": "USD", "latency": 0.0, "time_to_first_token": null, "time_to_generate": null}, "edge_source_handle": "source", "error": "", "error_type": "", "retry_index": 0}, "reason": {"TYPE": "hitl_required", "session_id": "synthetic-form", "node_id": "captured-human", "node_title": "Approve"}}, {"reasons": [{"TYPE": "hitl_required", "session_id": "synthetic-form", "node_id": "captured-human", "node_title": "Approve"}], "outputs": {"answer": "before-pause", "files": {"value_type": "array[file]", "value": []}}}] \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.filter.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.filter.json new file mode 100644 index 00000000000..d4ca00a2fa1 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.filter.json @@ -0,0 +1 @@ +{"type":"ResponseStreamFilter","version":"1.0","response_nodes":["captured-answer","captured-end"],"active_session":null,"waiting_sessions":[],"pending_sessions":[{"node_id":"captured-end","index":0}],"node_execution_ids":{"captured-answer":"5500d81a-a7ee-4015-ba60-1b55ad9a4cfb","captured-human":"7e790e03-3d00-4aa8-b957-2f8060894766","captured-start":"ac207c1f-6d89-4374-88ff-3c1f2bd03bb8"},"paths_map":{"captured-answer":[[]],"captured-end":[["edge_2"]]},"stream_buffers":[],"stream_positions":[],"closed_streams":[]} \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.graph.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.graph.json new file mode 100644 index 00000000000..1e0e6e62888 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.graph.json @@ -0,0 +1,55 @@ +{ + "nodes": [ + { + "id": "captured-start", + "data": { + "type": "start", + "variables": [] + } + }, + { + "id": "captured-answer", + "data": { + "type": "answer", + "answer": "before-pause" + } + }, + { + "id": "captured-human", + "data": { + "type": "human-input", + "title": "Approve" + } + }, + { + "id": "captured-end", + "data": { + "type": "end", + "outputs": [] + } + } + ], + "edges": [ + { + "id": "captured-edge-0", + "source": "captured-start", + "target": "captured-answer", + "sourceHandle": "source", + "targetHandle": "target" + }, + { + "id": "captured-edge-1", + "source": "captured-answer", + "target": "captured-human", + "sourceHandle": "source", + "targetHandle": "target" + }, + { + "id": "captured-edge-2", + "source": "captured-human", + "target": "captured-end", + "sourceHandle": "source", + "targetHandle": "target" + } + ] +} \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.runtime.json b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.runtime.json new file mode 100644 index 00000000000..ecdcea7ab2a --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/dify_v07_history/root.runtime.json @@ -0,0 +1 @@ +{"version":"2.0","start_at":0.0,"node_run_steps":3,"llm_usage":{"prompt_tokens":0,"prompt_unit_price":"0.0","prompt_price_unit":"0.0","prompt_price":"0.0","completion_tokens":0,"completion_unit_price":"0.0","completion_price_unit":"0.0","completion_price":"0.0","total_tokens":0,"total_price":"0.0","currency":"USD","latency":0.0,"time_to_first_token":null,"time_to_generate":null},"outputs":{"answer":"before-pause","files":{"value_type":"array[file]","value":[]}},"variable_pool":{"variable_dictionary":{"sys":{"user_id":{"value_type":"string","value":"user","id":"00cc23f7-96a0-4327-b234-ce32513b7abd","name":"user_id","description":"","selector":["sys","user_id"]},"app_id":{"value_type":"string","value":"app","id":"1b327273-d75e-4860-ab52-18e7b508b88a","name":"app_id","description":"","selector":["sys","app_id"]},"workflow_id":{"value_type":"string","value":"workflow","id":"e30168d1-920e-4203-b3ed-2cd2d6be7578","name":"workflow_id","description":"","selector":["sys","workflow_id"]},"workflow_run_id":{"value_type":"string","value":"captured-run-root","id":"c2269c69-f9d0-4f8f-9e96-d493beac69f2","name":"workflow_run_id","description":"","selector":["sys","workflow_run_id"]},"files":{"value_type":"array[any]","value":[],"id":"53f8d23b-63f3-480a-9bf2-79a06e75fd39","name":"files","description":"","selector":["sys","files"]}},"captured-start":{"sys.user_id":{"value_type":"string","value":"user","id":"80f3a1d7-ca91-46d9-afdf-fd2cb4e69413","name":"sys.user_id","description":"","selector":["captured-start","sys.user_id"]},"sys.app_id":{"value_type":"string","value":"app","id":"dafa45ea-bb01-40ae-9704-feebcee9c778","name":"sys.app_id","description":"","selector":["captured-start","sys.app_id"]},"sys.workflow_id":{"value_type":"string","value":"workflow","id":"9010247a-3615-40a5-8880-6f9598b1c3b3","name":"sys.workflow_id","description":"","selector":["captured-start","sys.workflow_id"]},"sys.workflow_run_id":{"value_type":"string","value":"captured-run-root","id":"256af817-f207-491d-9f78-cca03530c6c1","name":"sys.workflow_run_id","description":"","selector":["captured-start","sys.workflow_run_id"]},"sys.files":{"value_type":"array[any]","value":[],"id":"bb839f28-2c07-4f3b-9a50-69adc1c89b39","name":"sys.files","description":"","selector":["captured-start","sys.files"]}},"captured-answer":{"answer":{"value_type":"string","value":"before-pause","id":"336c3665-9169-4a4a-a413-d2285763cec7","name":"answer","description":"","selector":["captured-answer","answer"]},"files":{"value_type":"array[file]","value":[],"id":"7b06316e-3a09-4cfa-98f0-a8485d7fbf9a","name":"files","description":"","selector":["captured-answer","files"]}}}},"ready_queue":"{\"version\":\"2.0\",\"items\":[]}","graph_execution":"{\"version\":\"2.0\",\"workflow_id\":\"workflow\",\"started\":true,\"completed\":false,\"aborted\":false,\"paused\":true,\"pause_reasons\":[{\"TYPE\":\"hitl_required\",\"session_id\":\"synthetic-form\",\"node_id\":\"captured-human\",\"node_title\":\"Approve\"}],\"error\":null,\"exceptions_count\":0,\"node_executions\":[{\"frame_id\":\"root\",\"node_id\":\"captured-answer\",\"retry_count\":0,\"execution_id\":\"5500d81a-a7ee-4015-ba60-1b55ad9a4cfb\"},{\"frame_id\":\"root\",\"node_id\":\"captured-human\",\"retry_count\":0,\"execution_id\":\"7e790e03-3d00-4aa8-b957-2f8060894766\"},{\"frame_id\":\"root\",\"node_id\":\"captured-start\",\"retry_count\":0,\"execution_id\":\"ac207c1f-6d89-4374-88ff-3c1f2bd03bb8\"}]}","deferred_ready_tasks":"{\"version\":\"2.0\",\"items\":[{\"kind\":\"start\",\"frame_id\":\"root\",\"node_id\":\"captured-human\"}]}","container_runs":[],"container_frames":[],"graph_node_states":{"captured-start":"taken","captured-answer":"taken","captured-human":"unknown","captured-end":"unknown"},"graph_edge_states":{"edge_0":"taken","edge_1":"taken","edge_2":"unknown"}} \ No newline at end of file diff --git a/api/tests/unit_tests/core/workflow/fixtures/graphon_history/PROVENANCE.json b/api/tests/unit_tests/core/workflow/fixtures/graphon_history/PROVENANCE.json new file mode 100644 index 00000000000..503995d552b --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/graphon_history/PROVENANCE.json @@ -0,0 +1,17 @@ +{ + "source_commit": "4b9590652c63f444c4ba8f9ab5f477290dc58d07", + "fixtures": { + "runtime_state_v2_1d0f32c_paused_loop.json": { + "upstream_path": "tests/engine/fixtures/runtime_state_v2_1d0f32c_paused_loop.json", + "sha256": "1b0c0335145d6fe93bac3eab6e74b9bafd8d578c35ed05a083cf13837fe4423d" + }, + "response_stream_filter_v1_c3870df_simple.json": { + "upstream_path": "tests/engine/fixtures/response_stream_filter_v1_c3870df_simple.json", + "sha256": "80e6c239dc41b86ec813990c82bac37d942cda6dacfe73259777563e39af54f9" + }, + "response_stream_filter_v1_c3870df_nested.json": { + "upstream_path": "tests/engine/fixtures/response_stream_filter_v1_c3870df_nested.json", + "sha256": "f1721e2ad835924fde97537211fe33529e8fd91001eed2d2f136de5258c825b1" + } + } +} diff --git a/api/tests/unit_tests/core/workflow/fixtures/graphon_history/response_stream_filter_v1_c3870df_nested.json b/api/tests/unit_tests/core/workflow/fixtures/graphon_history/response_stream_filter_v1_c3870df_nested.json new file mode 100644 index 00000000000..11c50488d3f --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/graphon_history/response_stream_filter_v1_c3870df_nested.json @@ -0,0 +1,32 @@ +{ + "active_session": null, + "closed_streams": [], + "node_execution_ids": {}, + "paths_map": { + "answer": [ + [ + "edge_1" + ] + ], + "child-answer": [] + }, + "pending_sessions": [ + { + "index": 0, + "node_id": "answer" + }, + { + "index": 0, + "node_id": "child-answer" + } + ], + "response_nodes": [ + "answer", + "child-answer" + ], + "stream_buffers": [], + "stream_positions": [], + "type": "ResponseStreamFilter", + "version": "1.0", + "waiting_sessions": [] +} diff --git a/api/tests/unit_tests/core/workflow/fixtures/graphon_history/response_stream_filter_v1_c3870df_simple.json b/api/tests/unit_tests/core/workflow/fixtures/graphon_history/response_stream_filter_v1_c3870df_simple.json new file mode 100644 index 00000000000..a68eb137f6d --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/graphon_history/response_stream_filter_v1_c3870df_simple.json @@ -0,0 +1,26 @@ +{ + "active_session": null, + "closed_streams": [], + "node_execution_ids": {}, + "paths_map": { + "answer": [ + [ + "edge_0" + ] + ] + }, + "pending_sessions": [ + { + "index": 0, + "node_id": "answer" + } + ], + "response_nodes": [ + "answer" + ], + "stream_buffers": [], + "stream_positions": [], + "type": "ResponseStreamFilter", + "version": "1.0", + "waiting_sessions": [] +} diff --git a/api/tests/unit_tests/core/workflow/fixtures/graphon_history/runtime_state_v2_1d0f32c_paused_loop.json b/api/tests/unit_tests/core/workflow/fixtures/graphon_history/runtime_state_v2_1d0f32c_paused_loop.json new file mode 100644 index 00000000000..608e303df48 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/fixtures/graphon_history/runtime_state_v2_1d0f32c_paused_loop.json @@ -0,0 +1,197 @@ +{ + "container_frames": [ + { + "errors": [], + "frame_id": "9e82f3b8-9649-4184-ba6b-a515232b0b8d:loop:1", + "index": 1, + "kind": "loop", + "parent_invocation_id": "9e82f3b8-9649-4184-ba6b-a515232b0b8d", + "reached_break": false, + "root_node_id": "loop-start", + "runtime_data": { + "graph_edge_states": { + "edge_0": "unknown", + "edge_1": "taken", + "edge_2": "unknown" + }, + "graph_node_states": { + "end": "unknown", + "human-input": "unknown", + "loop": "unknown", + "loop-start": "taken", + "start": "unknown" + }, + "llm_usage": { + "completion_price": "0.0", + "completion_price_unit": "0.0", + "completion_tokens": 0, + "completion_unit_price": "0.0", + "currency": "USD", + "latency": 0.0, + "prompt_price": "0.0", + "prompt_price_unit": "0.0", + "prompt_tokens": 0, + "prompt_unit_price": "0.0", + "time_to_first_token": null, + "time_to_generate": null, + "total_price": "0.0", + "total_tokens": 0 + }, + "node_run_steps": 2, + "outputs": {}, + "variable_pool": "parent" + }, + "started_at": "2026-08-27T08:23:14.448861" + } + ], + "container_runs": [ + { + "completed_count": 1, + "duration_map": { + "0": 0.207418 + }, + "frame_id": "root", + "inputs": { + "loop_count": { + "value": 3, + "value_type": "integer" + }, + "seed": { + "value": "fixed", + "value_type": "string" + } + }, + "invocation_id": "9e82f3b8-9649-4184-ba6b-a515232b0b8d", + "kind": "loop", + "loop_count": 3, + "loop_node_ids": [ + "loop-start", + "human-input" + ], + "loop_variable_selectors": { + "seed": [ + "loop", + "seed" + ] + }, + "node_id": "loop", + "outputs": { + "loop_round": { + "value": 1, + "value_type": "integer" + }, + "seed": { + "value": "fixed", + "value_type": "string" + } + }, + "reached_break": false, + "root_node_id": "loop-start", + "started_at": "2026-08-27T08:23:14.236615", + "usage": { + "completion_price": "0.0", + "completion_price_unit": "0.0", + "completion_tokens": 0, + "completion_unit_price": "0.0", + "currency": "USD", + "latency": 0.0, + "prompt_price": "0.0", + "prompt_price_unit": "0.0", + "prompt_tokens": 0, + "prompt_unit_price": "0.0", + "time_to_first_token": null, + "time_to_generate": null, + "total_price": "0.0", + "total_tokens": 0 + }, + "variable_map": { + "0": { + "seed": { + "value": "fixed", + "value_type": "string" + } + } + } + } + ], + "deferred_ready_tasks": "{\"version\":\"2.0\",\"items\":[{\"kind\":\"start\",\"frame_id\":\"9e82f3b8-9649-4184-ba6b-a515232b0b8d:loop:1\",\"node_id\":\"human-input\"}]}", + "graph_edge_states": { + "edge_0": "taken", + "edge_1": "unknown", + "edge_2": "unknown" + }, + "graph_execution": "{\"version\":\"2.0\",\"workflow_id\":\"workflow\",\"started\":true,\"completed\":false,\"aborted\":false,\"paused\":true,\"pause_reasons\":[{\"TYPE\":\"hitl_required\",\"session_id\":\"session-human-input\",\"node_id\":\"human-input\",\"node_title\":\"Approve loop round\"}],\"error\":null,\"exceptions_count\":0,\"node_executions\":[{\"frame_id\":\"9e82f3b8-9649-4184-ba6b-a515232b0b8d:loop:0\",\"node_id\":\"human-input\",\"retry_count\":0,\"execution_id\":\"6823d407-1221-41f3-bfc5-152b1d31dd1a\"},{\"frame_id\":\"9e82f3b8-9649-4184-ba6b-a515232b0b8d:loop:0\",\"node_id\":\"loop-start\",\"retry_count\":0,\"execution_id\":\"61025756-664d-4eec-ac4c-2d9192bde71b\"},{\"frame_id\":\"9e82f3b8-9649-4184-ba6b-a515232b0b8d:loop:1\",\"node_id\":\"human-input\",\"retry_count\":0,\"execution_id\":\"dae5bd52-4804-4ea6-aac8-3525575c9aba\"},{\"frame_id\":\"9e82f3b8-9649-4184-ba6b-a515232b0b8d:loop:1\",\"node_id\":\"loop-start\",\"retry_count\":0,\"execution_id\":\"14382cdb-6911-41d9-868b-e4a3aa62b497\"},{\"frame_id\":\"root\",\"node_id\":\"loop\",\"retry_count\":0,\"execution_id\":\"82c1ad39-f19a-4461-9336-f7b006dc8e62\"},{\"frame_id\":\"root\",\"node_id\":\"start\",\"retry_count\":0,\"execution_id\":\"3c6d3743-0587-4436-ace2-27928843574a\"}]}", + "graph_node_states": { + "end": "unknown", + "human-input": "unknown", + "loop": "taken", + "loop-start": "unknown", + "start": "taken" + }, + "llm_usage": { + "completion_price": "0.0", + "completion_price_unit": "0.0", + "completion_tokens": 0, + "completion_unit_price": "0.0", + "currency": "USD", + "latency": 0.0, + "prompt_price": "0.0", + "prompt_price_unit": "0.0", + "prompt_tokens": 0, + "prompt_unit_price": "0.0", + "time_to_first_token": null, + "time_to_generate": null, + "total_price": "0.0", + "total_tokens": 0 + }, + "node_run_steps": 2, + "outputs": {}, + "ready_queue": "{\"version\":\"2.0\",\"items\":[]}", + "start_at": 0.0, + "variable_pool": { + "variable_dictionary": { + "human-input": {}, + "loop": { + "seed": { + "description": "", + "id": "6d80870e-f604-498b-9b0e-868304832a2d", + "name": "seed", + "selector": [ + "loop", + "seed" + ], + "value": "fixed", + "value_type": "string" + } + }, + "loop-start": {}, + "start": { + "sys.workflow_execution_id": { + "description": "", + "id": "635d8f1f-4fb5-435b-9f49-c3081f3249c6", + "name": "sys.workflow_execution_id", + "selector": [ + "start", + "sys.workflow_execution_id" + ], + "value": "workflow-execution", + "value_type": "string" + } + }, + "sys": { + "workflow_execution_id": { + "description": "", + "id": "6bb2de5b-8cdd-4397-957c-b7eafaf87543", + "name": "workflow_execution_id", + "selector": [ + "sys", + "workflow_execution_id" + ], + "value": "workflow-execution", + "value_type": "string" + } + } + } + }, + "version": "2.0" +} diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py index 025d79b25df..58e6f46c125 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/conftest.py @@ -117,7 +117,7 @@ def mock_result_event(): """Create a mock result event with NodeRunResult.""" from datetime import datetime - from graphon.graph_events import NodeRunSucceededEvent + from graphon.engine_events import NodeRunSucceededEvent from graphon.node_events import NodeRunResult node_run_result = NodeRunResult( diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py index ccb0cdff7a0..11692ea2ac1 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py @@ -15,8 +15,8 @@ from opentelemetry.trace import StatusCode from core.app.workflow.layers.observability import ObservabilityLayer from extensions.otel.semconv import DifySpanAttributes +from graphon.engine_events import GraphRunAbortedEvent from graphon.enums import BuiltinNodeTypes -from graphon.graph_events import GraphRunAbortedEvent @pytest.fixture(autouse=True) @@ -280,10 +280,10 @@ class TestObservabilityLayerGraphLifecycle: ): layer = ObservabilityLayer() layer.on_graph_start() - layer.on_node_run_start(mock_start_node) - - layer.on_event(GraphRunAbortedEvent(reason="worker shutdown", outputs={})) - layer.on_node_run_end(mock_start_node, None) + with layer.node_run_context(mock_start_node): + layer.on_node_run_start(mock_start_node) + layer.on_event(GraphRunAbortedEvent(reason="worker shutdown", outputs={})) + layer.on_node_run_end(mock_start_node, None) spans = memory_span_exporter.get_finished_spans() assert len(spans) == 1 @@ -323,3 +323,21 @@ class TestObservabilityLayerDisabledMode: spans = memory_span_exporter.get_finished_spans() assert len(spans) == 0 + + +@pytest.mark.usefixtures("tracer_provider_with_memory_exporter") +def test_context_detaches_between_container_segments(mock_llm_node): + """Suspended containers keep their span, but never a worker's context token.""" + from opentelemetry.trace import get_current_span + + layer = ObservabilityLayer() + original = get_current_span() + with layer.node_run_context(mock_llm_node): + layer.on_node_run_start(mock_llm_node) + span = get_current_span() + assert span is not original + assert get_current_span() is original + with layer.node_run_context(mock_llm_node): + assert get_current_span() is span + layer.on_node_run_end(mock_llm_node, None) + assert get_current_span() is original diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_builder_restricted_native.py b/api/tests/unit_tests/core/workflow/graph_engine/test_builder_restricted_native.py index c67434513ca..dc3af715a4e 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_builder_restricted_native.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_builder_restricted_native.py @@ -21,23 +21,22 @@ from core.dify_builder.execution_policy import BuilderExecutionPolicyError, cano from core.workflow.node_factory import DifyNodeFactory from core.workflow.variable_pool_initializer import add_node_inputs_to_pool from core.workflow.workflow_entry import iter_dify_graph_engine_events -from graphon.entities import GraphInitParams -from graphon.entities.base_node_data import DefaultValue, DefaultValueType -from graphon.enums import ErrorStrategy -from graphon.graph import Graph -from graphon.graph_engine import GraphEngine, GraphEngineConfig -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_engine.layers.base import GraphEngineLayer -from graphon.graph_events import ( +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine.layer.base import Layer +from graphon.engine_events import ( GraphRunFailedEvent, GraphRunPartialSucceededEvent, GraphRunSucceededEvent, NodeRunSucceededEvent, ) +from graphon.entities.base_node_data import DefaultValue, DefaultValueType +from graphon.enums import ErrorStrategy +from graphon.graph import Graph from graphon.nodes.end.end_node import EndNode from graphon.nodes.http_request.node import HttpRequestNode from graphon.nodes.start.start_node import StartNode -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import InitParams, RuntimeState, VariablePool from models.dify_builder import DifyBuilderExecutionRequest, DifyBuilderTestInput from models.workflow import Workflow from services.dify_builder.execution_policy_service import BuilderExecutionPolicyService @@ -48,7 +47,7 @@ from tests.unit_tests.services.dify_builder.test_execution_policy_service import FIXTURES = Path(__file__).parents[4] / "fixtures/workflow/builder_restricted" -class ThrowingLayer(GraphEngineLayer): +class ThrowingLayer(Layer): def on_graph_start(self): pass @@ -93,7 +92,7 @@ def prepare_native(owned, name, *, samples=(), inputs=None, http_data=None, grap ) service.claim_launch(context=context, native_run_id="native-run", task_id="native-task") recorder = service.recorder(context=context, native_run_id="native-run", task_id="native-task") - params = GraphInitParams( + params = InitParams( workflow_id=workflow.id, graph_config=graph_config, call_depth=0, @@ -108,8 +107,8 @@ def prepare_native(owned, name, *, samples=(), inputs=None, http_data=None, grap ) pool = VariablePool() add_node_inputs_to_pool(pool, node_id="start", inputs=inputs) - state = GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()) - factory = DifyNodeFactory(graph_init_params=params, graph_runtime_state=state, execution_recorder=recorder) + state = RuntimeState(workflow_id="test-workflow", variable_pool=pool, start_at=time.perf_counter()) + factory = DifyNodeFactory(init_params=params, runtime_state=state, execution_recorder=recorder) return factory, context, recorder @@ -117,8 +116,8 @@ def init_runner_graph(owned, factory, context, recorder): _, actor, app, workflow, _, _ = owned runner = WorkflowBasedAppRunner(queue_manager=Mock(spec=AppQueueManager), app_id=app.id) return runner._init_graph( - graph_config=factory.graph_init_params.graph_config, - graph_runtime_state=factory.graph_runtime_state, + graph_config=factory.init_params.graph_config, + runtime_state=factory.runtime_state, user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, workflow_id=workflow.id, @@ -131,18 +130,15 @@ def init_runner_graph(owned, factory, context, recorder): def execute_native(factory, *, throwing=False, graph=None): if graph is None: - graph = Graph.init( - graph_config=factory.graph_init_params.graph_config, node_factory=factory, root_node_id="start" - ) - engine = GraphEngine( - workflow_id=factory.graph_init_params.workflow_id, + graph = Graph.init(graph_config=factory.init_params.graph_config, node_factory=factory, root_node_id="start") + engine = Engine( graph=graph, - graph_runtime_state=factory.graph_runtime_state, + runtime_state=factory.runtime_state, command_channel=InMemoryChannel(), - config=GraphEngineConfig(min_workers=1, max_workers=1), + workers=1, ) if throwing: - engine.layer(ThrowingLayer()) + engine.add_layer(ThrowingLayer()) events = [] error = None try: @@ -192,7 +188,7 @@ def forbid_live(monkeypatch): monkeypatch.setattr(module, name, forbidden) for name in ("get", "head", "post", "put", "patch", "delete"): monkeypatch.setattr(ssrf_proxy.graphon_ssrf_proxy, name, forbidden) - monkeypatch.setattr(http_module, "get_http_client", forbidden) + monkeypatch.setattr(http_module, "HttpxHttpClient", forbidden) monkeypatch.setattr(CodeExecutor, "execute_workflow_code_template", forbidden) monkeypatch.setattr(ToolManager, "get_workflow_tool_runtime", forbidden) monkeypatch.setattr(oauth, "OAuthHandler", forbidden) @@ -321,14 +317,20 @@ def test_runner_default_output_executes_native_partial_and_preserves_raw_graph(o for target in (graph_config, graph_config["nodes"][1], graph_config["nodes"][1]["data"], graph_config["edges"][0]): target["undeclared_metadata"] = {"nested": ["preserved", 1]} factory, context, recorder = prepare_native(owned, "http_default_output.yml", graph_config=graph_config) - raw_graph = factory.graph_init_params.graph_config + raw_graph = factory.init_params.graph_config original = deepcopy(raw_graph) graph = init_runner_graph(owned, factory, context, recorder) _, events, error = execute_native(factory, graph=graph) assert error is None assert type(graph.nodes["http"]) is HttpRequestNode assert type(graph.nodes["end"]) is EndNode - assert graph.nodes["http"].graph_init_params.graph_config == original + # 0.8 nodes receive a copied, scoped graph with canonical owners/edge IDs. + # The admitted source and every caller-owned extra remain unchanged. + assert factory.init_params.graph_config == original + scoped = graph.nodes["http"].init_params.graph_config + assert scoped["undeclared_metadata"] == original["undeclared_metadata"] + assert scoped["nodes"][1]["data"]["undeclared_metadata"] == original["nodes"][1]["data"]["undeclared_metadata"] + assert scoped["edges"][0]["undeclared_metadata"] == original["edges"][0]["undeclared_metadata"] assert raw_graph == original assert type(raw_graph["nodes"][1]["data"]["error_strategy"]) is str assert type(raw_graph["nodes"][1]["data"]["default_value"][0]["type"]) is str @@ -349,7 +351,7 @@ def test_runner_default_output_executes_native_partial_and_preserves_raw_graph(o def test_runner_rejects_changed_raw_node_data_extras_before_execution(owned, forbid_live): factory, context, recorder = prepare_native(owned, "http_default_output.yml") - raw_graph = factory.graph_init_params.graph_config + raw_graph = factory.init_params.graph_config raw_graph["nodes"][1]["data"]["undeclared_metadata"] = {"nested": ["changed", 1]} changed = deepcopy(raw_graph) with pytest.raises(BuilderExecutionPolicyError, match="execution_binding_mismatch"): @@ -363,7 +365,7 @@ def test_runner_rejects_changed_raw_node_data_extras_before_execution(owned, for @pytest.mark.parametrize("value", ["strategy_enum", "default_type_enum", "default_model"]) def test_runner_refuses_non_json_objects_even_when_json_serialization_is_equivalent(owned, forbid_live, value): factory, context, recorder = prepare_native(owned, "http_default_output.yml") - data = factory.graph_init_params.graph_config["nodes"][1]["data"] + data = factory.init_params.graph_config["nodes"][1]["data"] if value == "strategy_enum": data["error_strategy"] = ErrorStrategy.DEFAULT_VALUE elif value == "default_type_enum": @@ -396,7 +398,7 @@ def test_recorder_db_failure_prevents_fixture_return_and_poisons_shared_latch(ow from core.dify_builder.execution_policy import BuilderExecutionPolicyError with pytest.raises(BuilderExecutionPolicyError, match="missing_execution_recorder"): - factory.with_runtime_state(factory.graph_runtime_state) + factory.with_runtime_state(factory.runtime_state) finally: event.remove(engine, "before_cursor_execute", fail_append) diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py index 9c507bb1619..c3114fb47b9 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py @@ -29,8 +29,7 @@ from .test_mock_nodes import ( ) if TYPE_CHECKING: - from graphon.entities import GraphInitParams - from graphon.runtime import GraphRuntimeState + from graphon.runtime import InitParams, RuntimeState from .test_mock_config import MockConfig @@ -46,18 +45,18 @@ class MockNodeFactory(DifyNodeFactory): def __init__( self, - graph_init_params: "GraphInitParams", - graph_runtime_state: "GraphRuntimeState", + init_params: "InitParams", + runtime_state: "RuntimeState", mock_config: "MockConfig | None" = None, ) -> None: """ Initialize the mock node factory. - :param graph_init_params: Graph initialization parameters - :param graph_runtime_state: Graph runtime state + :param init_params: Graph initialization parameters + :param runtime_state: Graph runtime state :param mock_config: Optional mock configuration for customizing mock behavior """ - super().__init__(graph_init_params, graph_runtime_state) + super().__init__(init_params, runtime_state) self.mock_config = mock_config # Map of node types that should be mocked @@ -77,10 +76,10 @@ class MockNodeFactory(DifyNodeFactory): } @override - def with_runtime_state(self, graph_runtime_state: "GraphRuntimeState") -> "MockNodeFactory": + def with_runtime_state(self, runtime_state: "RuntimeState") -> "MockNodeFactory": return MockNodeFactory( - graph_init_params=self.graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=self.init_params, + runtime_state=runtime_state, mock_config=self.mock_config, ) @@ -105,8 +104,8 @@ class MockNodeFactory(DifyNodeFactory): mock_instance = mock_class( node_id=node_id, data=resolved_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, mock_config=self.mock_config, code_executor=self._code_executor, code_limits=self._code_limits, @@ -115,8 +114,8 @@ class MockNodeFactory(DifyNodeFactory): mock_instance = mock_class( node_id=node_id, data=resolved_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, mock_config=self.mock_config, http_request_config=self._http_request_config, http_client=self._http_request_http_client, @@ -131,8 +130,8 @@ class MockNodeFactory(DifyNodeFactory): mock_instance = mock_class( node_id=node_id, data=resolved_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, mock_config=self.mock_config, credentials_provider=self._llm_credentials_provider, model_factory=self._llm_model_factory, @@ -141,8 +140,8 @@ class MockNodeFactory(DifyNodeFactory): mock_instance = mock_class( node_id=node_id, data=resolved_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, mock_config=self.mock_config, ) diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py index a4ca24803ce..3522dee029c 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py @@ -38,8 +38,7 @@ from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderErr from tests.unit_tests.core.model_fixtures import make_model_instance if TYPE_CHECKING: - from graphon.entities import GraphInitParams - from graphon.runtime import GraphRuntimeState + from graphon.runtime import InitParams, RuntimeState from .test_mock_config import MockConfig @@ -64,12 +63,12 @@ class MockNodeMixin: node_id: str, data: Any, *, - graph_init_params: "GraphInitParams", - graph_runtime_state: "GraphRuntimeState", + init_params: "InitParams", + runtime_state: "RuntimeState", mock_config: Optional["MockConfig"] = None, **kwargs: Any, ) -> None: - run_context = resolve_dify_run_context(graph_init_params.run_context) + run_context = resolve_dify_run_context(init_params.run_context) if isinstance(self, (LLMNode, QuestionClassifierNode, ParameterExtractorNode)): kwargs.setdefault("credentials_provider", DifyCredentialsProvider(run_context=run_context)) kwargs.setdefault("model_factory", DifyModelFactory(run_context=run_context)) @@ -97,7 +96,7 @@ class MockNodeMixin: if isinstance(self, _ToolNode): kwargs.setdefault("tool_file_manager", DifyToolFileManager(run_context)) - kwargs.setdefault("runtime", DifyToolNodeRuntime(graph_init_params.run_context)) + kwargs.setdefault("runtime", DifyToolNodeRuntime(init_params.run_context)) if isinstance(self, AgentNode): presentation_provider = MagicMock() @@ -110,8 +109,8 @@ class MockNodeMixin: 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, **kwargs, ) self.mock_config = mock_config @@ -664,7 +663,7 @@ class MockTemplateTransformNode(MockNodeMixin, TemplateTransformNode): if hasattr(self._node_data, "variables"): for variable_selector in self._node_data.variables: variable_name = variable_selector.variable - value = self.graph_runtime_state.variable_pool.get(variable_selector.value_selector) + value = self.runtime_state.variable_pool.get(variable_selector.value_selector) variables[variable_name] = value.to_object() if value else None # Check if we have custom mock outputs configured diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index 86960db44c1..3079dcd46b7 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -23,44 +23,44 @@ from core.workflow.nodes.human_input.entities import ( ) from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType from core.workflow.system_variables import build_system_variables -from graphon.entities import WorkflowStartReason -from graphon.file import File, FileTransferMethod, FileType -from graphon.graph import Graph -from graphon.graph_engine import GraphEngine, GraphEngineConfig -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import ( +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine_events import ( GraphRunPausedEvent, GraphRunStartedEvent, GraphRunSucceededEvent, NodeRunSucceededEvent, ) +from graphon.entities import WorkflowStartReason +from graphon.file import File, FileTransferMethod, FileType +from graphon.graph import Graph from graphon.nodes.base.entities import OutputVariableEntity 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 tests.workflow_test_utils import build_test_graph_init_params class PauseStateStore(Protocol): - def save(self, runtime_state: GraphRuntimeState) -> None: ... + def save(self, runtime_state: RuntimeState) -> None: ... - def load(self) -> GraphRuntimeState: ... + def load(self) -> RuntimeState: ... class InMemoryPauseStore: def __init__(self) -> None: self._snapshot: str | None = None - def save(self, runtime_state: GraphRuntimeState) -> None: + def save(self, runtime_state: RuntimeState) -> None: self._snapshot = runtime_state.dumps() - def load(self) -> GraphRuntimeState: + def load(self) -> RuntimeState: assert self._snapshot is not None - return GraphRuntimeState.from_snapshot(self._snapshot) + return RuntimeState.from_snapshot(self._snapshot) class _TestFileReferenceFactory: @@ -144,7 +144,7 @@ class StaticRepo(HumanInputFormRepository): raise AssertionError("create_form should not be called in resume scenario") -def _build_runtime_state() -> GraphRuntimeState: +def _build_runtime_state() -> RuntimeState: variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", @@ -155,12 +155,12 @@ def _build_runtime_state() -> GraphRuntimeState: 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, repo: HumanInputFormRepository) -> Graph: +def _build_graph(runtime_state: RuntimeState, repo: HumanInputFormRepository) -> Graph: graph_config: dict[str, object] = {"nodes": [], "edges": []} - graph_init_params = build_test_graph_init_params( + init_params = build_test_graph_init_params( workflow_id="workflow", graph_config=graph_config, tenant_id="tenant", @@ -175,8 +175,8 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor start_node = StartNode( node_id=start_config["id"], data=StartNodeData(title="Start", variables=[]), - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) human_data = HumanInputNodeData( @@ -194,7 +194,7 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor ) human_a_config = {"id": "human_a", "data": human_data.model_dump()} - human_a_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) + human_a_runtime = DifyHumanInputNodeRuntime(init_params.run_context) human_a_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] human_a_callback = DifyHITLCallback( form_repository=repo, @@ -204,13 +204,13 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor human_a = HumanInputNode( node_id=human_a_config["id"], data=human_data, - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, hitl_callback=human_a_callback, ) human_b_config = {"id": "human_b", "data": human_data.model_dump()} - human_b_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) + human_b_runtime = DifyHumanInputNodeRuntime(init_params.run_context) human_b_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] human_b_callback = DifyHITLCallback( form_repository=repo, @@ -220,8 +220,8 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor human_b = HumanInputNode( node_id=human_b_config["id"], data=human_data, - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, hitl_callback=human_b_callback, ) @@ -241,8 +241,8 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor end_node = EndNode( node_id=end_config["id"], data=end_data, - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) builder = ( @@ -255,18 +255,12 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor return builder.connect(tail="human_b", head="end", source_handle="approve").build() -def _run_graph(graph: Graph, runtime_state: GraphRuntimeState) -> list[object]: - engine = GraphEngine( - workflow_id="workflow", +def _run_graph(graph: Graph, runtime_state: RuntimeState) -> list[object]: + engine = Engine( graph=graph, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, command_channel=InMemoryChannel(), - config=GraphEngineConfig( - min_workers=2, - max_workers=2, - scale_up_threshold=1, - scale_down_idle_time=30.0, - ), + workers=2, ) return list(engine.run()) diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py index 175b5da8a1b..b7bbba07567 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py @@ -1,5 +1,5 @@ """ -Table-driven test framework for GraphEngine workflows. +Table-driven test framework for Engine workflows. This module provides a robust table-driven testing framework with support for: - Parallel test execution @@ -25,16 +25,15 @@ from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id 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 iter_dify_graph_engine_events -from graphon.entities import GraphInitParams -from graphon.graph import Graph -from graphon.graph_engine import GraphEngine, GraphEngineConfig -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import ( - GraphEngineEvent, +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine_events import ( + EngineEvent, GraphRunStartedEvent, GraphRunSucceededEvent, ) -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.graph import Graph +from graphon.runtime import InitParams, RuntimeState, VariablePool from graphon.variables import ( ArrayNumberVariable, ArrayObjectVariable, @@ -63,8 +62,8 @@ class WorkflowTestCase: timeout: float = 30.0 mock_config: MockConfig | None = None use_auto_mock: bool = False - expected_event_sequence: Sequence[type[GraphEngineEvent]] | None = None - graph_factory: Callable[[], tuple[Graph, GraphRuntimeState]] | None = None + expected_event_sequence: Sequence[type[EngineEvent]] | None = None + graph_factory: Callable[[], tuple[Graph, RuntimeState]] | None = None @dataclass @@ -78,9 +77,9 @@ class WorkflowTestResult: execution_time: float = 0.0 event_sequence_match: bool | None = None event_mismatch_details: str | None = None - events: list[GraphEngineEvent] = field(default_factory=list) + events: list[EngineEvent] = field(default_factory=list) graph: Graph | None = None - graph_runtime_state: GraphRuntimeState | None = None + runtime_state: RuntimeState | None = None validation_details: str | None = None @@ -143,7 +142,7 @@ class WorkflowRunner: inputs: dict[str, Any] | None = None, use_mock_factory: bool = False, mock_config: MockConfig | None = None, - ) -> tuple[Graph, GraphRuntimeState]: + ) -> tuple[Graph, RuntimeState]: """Create a Graph instance from fixture data.""" workflow_config = fixture_data.get("workflow", {}) graph_config = workflow_config.get("graph", {}) @@ -151,7 +150,7 @@ class WorkflowRunner: if not graph_config: raise ValueError("Fixture missing workflow.graph configuration") - graph_init_params = GraphInitParams( + init_params = InitParams( workflow_id="test_workflow", graph_config=graph_config, run_context={ @@ -169,7 +168,7 @@ class WorkflowRunner: system_variables = build_system_variables( user_id="test_user", app_id="test_app", - workflow_id=graph_init_params.workflow_id, + workflow_id=init_params.workflow_id, files=[], query=query, ) @@ -214,14 +213,16 @@ class WorkflowRunner: ) add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=root_node_inputs) - 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() + ) if use_mock_factory: node_factory = MockNodeFactory( - graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, mock_config=mock_config + init_params=init_params, runtime_state=runtime_state, mock_config=mock_config ) else: - node_factory = DifyNodeFactory(graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state) + node_factory = DifyNodeFactory(init_params=init_params, runtime_state=runtime_state) graph = Graph.init( graph_config=graph_config, @@ -229,7 +230,7 @@ class WorkflowRunner: root_node_id=root_node_id, ) - return graph, graph_runtime_state + return graph, runtime_state class TableTestRunner: @@ -264,15 +265,15 @@ class TableTestRunner: max_workers: Maximum number of parallel workers for test execution enable_logging: Enable detailed logging log_level: Logging level (DEBUG, INFO, WARNING, ERROR) - graph_engine_min_workers: Minimum workers for GraphEngine (default: 3) - graph_engine_max_workers: Maximum workers for GraphEngine (default: 1) + graph_engine_min_workers: Minimum workers for Engine (default: 3) + graph_engine_max_workers: Maximum workers for Engine (default: 1) graph_engine_scale_up_threshold: Queue depth to trigger scale up graph_engine_scale_down_idle_time: Idle time before scaling down """ self.workflow_runner = WorkflowRunner(fixtures_dir) self.max_workers = max_workers - # Store GraphEngine worker configuration + # Store Engine worker configuration self.graph_engine_min_workers = graph_engine_min_workers self.graph_engine_max_workers = graph_engine_max_workers self.graph_engine_scale_up_threshold = graph_engine_scale_up_threshold @@ -318,24 +319,18 @@ class TableTestRunner: start_time = time.perf_counter() try: - graph, graph_runtime_state = self._create_graph_runtime_state(test_case) + graph, runtime_state = self._create_graph_runtime_state(test_case) # Create and run the engine with configured worker settings - engine = GraphEngine( - workflow_id="test_workflow", + engine = Engine( graph=graph, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=InMemoryChannel(), - config=GraphEngineConfig( - min_workers=self.graph_engine_min_workers, - max_workers=self.graph_engine_max_workers, - scale_up_threshold=self.graph_engine_scale_up_threshold, - scale_down_idle_time=self.graph_engine_scale_down_idle_time, - ), + workers=self.graph_engine_max_workers, ) # Execute and collect events - events: list[GraphEngineEvent] = [] + events: list[EngineEvent] = [] for event in iter_dify_graph_engine_events(engine): events.append(event) @@ -368,7 +363,7 @@ class TableTestRunner: event_sequence_match=event_sequence_match, event_mismatch_details=event_mismatch_details, graph=graph, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, ) # Get actual outputs @@ -392,7 +387,7 @@ class TableTestRunner: validation_details=validation_details, error=None if success else Exception(validation_details or event_mismatch_details or "Test failed"), graph=graph, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, ) except Exception as e: @@ -403,10 +398,10 @@ class TableTestRunner: error=e, execution_time=time.perf_counter() - start_time, graph=graph if "graph" in locals() else None, - graph_runtime_state=graph_runtime_state if "graph_runtime_state" in locals() else None, + runtime_state=runtime_state if "runtime_state" in locals() else None, ) - def _create_graph_runtime_state(self, test_case: WorkflowTestCase) -> tuple[Graph, GraphRuntimeState]: + def _create_graph_runtime_state(self, test_case: WorkflowTestCase) -> tuple[Graph, RuntimeState]: """Create or retrieve graph/runtime state according to test configuration.""" if test_case.graph_factory is not None: @@ -469,7 +464,7 @@ class TableTestRunner: return True, None def _validate_event_sequence( - self, expected_sequence: list[type[GraphEngineEvent]], actual_events: list[GraphEngineEvent] + self, expected_sequence: list[type[EngineEvent]], actual_events: list[EngineEvent] ) -> tuple[bool, str | None]: """ Validate that actual events match the expected event sequence. diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py index 33e8f869a5a..f5cf597a454 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py @@ -1,7 +1,7 @@ from core.workflow.workflow_entry import iter_dify_graph_engine_events -from graphon.graph_engine import GraphEngine, GraphEngineConfig -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import ( +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine_events import ( GraphRunSucceededEvent, NodeRunStreamChunkEvent, ) @@ -17,19 +17,18 @@ def test_tool_in_chatflow(): fixture_data = runner.workflow_runner.load_fixture("chatflow_time_tool_static_output_workflow") # Create graph from fixture with auto-mock enabled - graph, graph_runtime_state = runner.workflow_runner.create_graph_from_fixture( + graph, runtime_state = runner.workflow_runner.create_graph_from_fixture( fixture_data=fixture_data, query="1", use_mock_factory=True, ) # Create and run the engine - engine = GraphEngine( - workflow_id="test_workflow", + engine = Engine( graph=graph, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=InMemoryChannel(), - config=GraphEngineConfig(), + workers=5, ) events = list(iter_dify_graph_engine_events(engine)) @@ -71,19 +70,18 @@ def test_answer_can_render_llm_structured_output_in_chatflow(): .build() ) - graph, graph_runtime_state = runner.workflow_runner.create_graph_from_fixture( + graph, runtime_state = runner.workflow_runner.create_graph_from_fixture( fixture_data=fixture_data, query="hello", use_mock_factory=True, mock_config=mock_config, ) - engine = GraphEngine( - workflow_id="test_workflow", + engine = Engine( graph=graph, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=InMemoryChannel(), - config=GraphEngineConfig(), + workers=5, ) events = list(iter_dify_graph_engine_events(engine)) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_entities.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_entities.py index e94a728b68c..cab4169e82a 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_entities.py @@ -1,11 +1,10 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context from core.workflow.node_factory import DifyNodeFactory -from graphon.entities import GraphInitParams +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine_events import GraphRunSucceededEvent, NodeEvent from graphon.graph import Graph -from graphon.graph_engine import GraphEngine, GraphEngineConfig -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import GraphNodeEventBase, GraphRunSucceededEvent -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import InitParams, RuntimeState, VariablePool def test_unconfigured_disconnected_agent_does_not_block_workflow() -> None: @@ -16,8 +15,8 @@ def test_unconfigured_disconnected_agent_does_not_block_workflow() -> None: ], "edges": [], } - graph_runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0) - graph_init_params = GraphInitParams( + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0) + init_params = InitParams( workflow_id="workflow", graph_config=graph_config, run_context=build_dify_run_context( @@ -31,18 +30,17 @@ def test_unconfigured_disconnected_agent_does_not_block_workflow() -> None: ) graph = Graph.init( graph_config=graph_config, - node_factory=DifyNodeFactory(graph_init_params, graph_runtime_state), + node_factory=DifyNodeFactory(init_params, runtime_state), root_node_id="start", ) - engine = GraphEngine( - workflow_id="workflow", + engine = Engine( graph=graph, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=InMemoryChannel(), - config=GraphEngineConfig(min_workers=1, max_workers=1), + workers=1, ) events = list(engine.run()) assert isinstance(events[-1], GraphRunSucceededEvent) - assert not any(isinstance(event, GraphNodeEventBase) and event.node_id == "agent" for event in events) + assert not any(isinstance(event, NodeEvent) and event.node_id == "agent" for event in events) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py index b1596fecd1d..2ed54e5fadc 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py @@ -3,24 +3,23 @@ from unittest.mock import MagicMock from core.workflow.nodes.agent.agent_node import AgentNode from core.workflow.nodes.agent.entities import AgentNodeData from core.workflow.nodes.agent.events import AgentLogEvent, NodeRunAgentLogEvent -from graphon.entities import GraphInitParams +from graphon.engine_events import NodeRunStreamChunkEvent from graphon.enums import BuiltinNodeTypes -from graphon.graph_events import NodeRunStreamChunkEvent from graphon.node_events import StreamChunkEvent -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import InitParams, RuntimeState, VariablePool def test_dispatch_converts_agent_events_and_delegates_other_events() -> None: node = AgentNode( node_id="node-id", data=AgentNodeData(title="Agent"), - graph_init_params=GraphInitParams( + init_params=InitParams( workflow_id="workflow-id", graph_config={}, run_context={}, call_depth=0, ), - graph_runtime_state=GraphRuntimeState(variable_pool=VariablePool(), start_at=0), + runtime_state=RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0), strategy_resolver=MagicMock(), presentation_provider=MagicMock(), runtime_support=MagicMock(), diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index ddf64c31d6b..5634272b2a0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -47,7 +47,6 @@ from core.workflow.nodes.agent_v2.session_store import ( WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore, ) -from graphon.entities import GraphInitParams from graphon.enums import ( BuiltinNodeTypes, ErrorStrategy, @@ -57,7 +56,7 @@ from graphon.enums import ( ) from graphon.file import File, FileTransferMethod, FileType from graphon.node_events import StreamCompletedEvent -from graphon.runtime import GraphRuntimeState +from graphon.runtime import InitParams, RuntimeState from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( @@ -403,7 +402,7 @@ def _node( runtime_request_builder: WorkflowAgentRuntimeRequestBuilder | None = None, error_strategy: ErrorStrategy | None = None, ) -> DifyAgentNode: - graph_init_params = GraphInitParams( + init_params = InitParams( workflow_id="workflow-1", graph_config={"nodes": [], "edges": []}, run_context={ @@ -449,9 +448,9 @@ def _node( else [], } ), - graph_init_params=graph_init_params, - graph_runtime_state=cast( - GraphRuntimeState, + init_params=init_params, + runtime_state=cast( + RuntimeState, SimpleNamespace( variable_pool=FakeVariablePool(), graph_execution=SimpleNamespace(aborted=False), @@ -926,7 +925,7 @@ def test_agent_node_cancels_backend_run_when_stream_raises_unexpected_error(): def test_agent_node_uses_graph_abort_reason_when_cancel_request_fails(caplog): client = CancelFailingStreamBackendClient() node = _node(agent_backend_client=client) - node.graph_runtime_state.graph_execution = SimpleNamespace(aborted=True) + node.runtime_state.graph_execution = SimpleNamespace(aborted=True) terminal, failure = node._consume_event_stream( "run-1", @@ -1030,7 +1029,7 @@ def test_agent_routes_initialize_through_workflow_node_factory( config_overrides(AGENT_BACKEND_USE_FAKE=True) template = _node() - factory = DifyNodeFactory(template.graph_init_params, template.graph_runtime_state) + factory = DifyNodeFactory(template.init_params, template.runtime_state) node_data = template.node_data.model_dump(mode="python", by_alias=True) node_data.update( agent_output_routes={ @@ -1053,13 +1052,11 @@ def test_agent_routes_initialize_through_workflow_node_factory( def test_agent_routes_use_the_successful_retry_to_fan_out_and_skip_other_branches(): + from graphon.engine.scheduler import Scheduler + from graphon.engine_events.traversal import GraphEdgeSkippedEvent, GraphEdgeTakenEvent from graphon.enums import NodeState from graphon.graph.edge import Edge from graphon.graph.graph import Graph - from graphon.graph_engine.graph_state_manager import GraphStateManager - from graphon.graph_engine.graph_traversal.edge_processor import EdgeProcessor - from graphon.graph_engine.graph_traversal.skip_propagator import SkipPropagator - from graphon.graph_events.traversal import GraphEdgeSkippedEvent, GraphEdgeTakenEvent from graphon.nodes.end.end_node import EndNode from graphon.nodes.end.entities import EndNodeData @@ -1099,8 +1096,8 @@ def test_agent_routes_use_the_successful_retry_to_fan_out_and_skip_other_branche target: EndNode( node_id=target, data=EndNodeData(title=target, outputs=[]), - graph_init_params=node.graph_init_params, - graph_runtime_state=node.graph_runtime_state, + init_params=node.init_params, + runtime_state=node.runtime_state, ) for target in edges } @@ -1111,8 +1108,7 @@ def test_agent_routes_use_the_successful_retry_to_fan_out_and_skip_other_branche out_edges={"agent-node": list(edges)}, in_edges={target: [target] for target in edges}, ) - state = GraphStateManager(graph, node.graph_runtime_state, "root") - ready, traversed = EdgeProcessor(graph, state, SkipPropagator(graph, state)).process_node_success( + ready, traversed = Scheduler(graph, node.runtime_state, "root").process_node_success( "agent-node", result.edge_source_handle ) assert ready == ["b-end-1", "b-end-2"] @@ -1167,16 +1163,34 @@ def test_agent_route_conditions_load_variables_only_when_routing_is_enabled(enab def test_disabled_routes_continue_through_graphon_default_values(): + from graphon.engine.event.node_failure import NodeFailureHandler + from graphon.engine.scheduler import Scheduler + from graphon.engine_events.node import NodeRunExceptionEvent, NodeRunFailedEvent from graphon.graph.edge import Edge from graphon.graph.graph import Graph - from graphon.graph_engine.error_handler import ErrorHandler - from graphon.graph_engine.graph_traversal.edge_processor import EdgeProcessor - from graphon.graph_events.node import NodeRunExceptionEvent, NodeRunFailedEvent from graphon.node_events import NodeRunResult node = _node(error_strategy=ErrorStrategy.DEFAULT_VALUE) edges = {target: Edge(id=target, tail="agent-node", head=target) for target in ["next-a", "next-b"]} - graph = Graph(root_node=node, nodes={"agent-node": node}, edges=edges, out_edges={"agent-node": list(edges)}) + from graphon.nodes.end.end_node import EndNode + from graphon.nodes.end.entities import EndNodeData + + leaves = { + target: EndNode( + node_id=target, + data=EndNodeData(title=target, outputs=[]), + init_params=node.init_params, + runtime_state=node.runtime_state, + ) + for target in edges + } + graph = Graph( + root_node=node, + nodes={"agent-node": node, **leaves}, + edges=edges, + out_edges={"agent-node": list(edges)}, + in_edges={target: [target] for target in edges}, + ) failure = NodeRunFailedEvent( id="execution", node_id="agent-node", @@ -1186,10 +1200,12 @@ def test_disabled_routes_continue_through_graphon_default_values(): error="backend failed", node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.FAILED, error="backend failed"), ) - result = ErrorHandler(graph, MagicMock()).handle_node_failure(frame_id="root", event=failure) + from graphon.runtime.execution import GraphExecution + + result = NodeFailureHandler(graph, GraphExecution(workflow_id="workflow")).handle(frame_id="root", event=failure) assert isinstance(result, NodeRunExceptionEvent) assert result.node_run_result.outputs["text"] == "fallback" - ready, traversed = EdgeProcessor(graph, MagicMock(), MagicMock()).process_node_success( + ready, traversed = Scheduler(graph, node.runtime_state, "root").process_node_success( "agent-node", result.node_run_result.edge_source_handle ) assert ready == ["next-a", "next-b"] diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_workspace_retirement_layer.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_workspace_retirement_layer.py index 889ad056f37..6e452863ac7 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_workspace_retirement_layer.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_workspace_retirement_layer.py @@ -6,10 +6,10 @@ from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, Us from core.workflow.nodes.agent_v2 import workspace_retirement_layer as layer_module from core.workflow.nodes.agent_v2.workspace_retirement_layer import WorkflowAgentWorkspaceRetirementLayer from core.workflow.system_variables import build_system_variables +from graphon.engine.command import InMemoryChannel +from graphon.engine_events import GraphRunSucceededEvent, NodeRunStartedEvent from graphon.enums import BuiltinNodeTypes -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import GraphRunSucceededEvent, NodeRunStartedEvent -from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool +from graphon.runtime import ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool from libs.datetime_utils import naive_utc_now @@ -24,9 +24,10 @@ def _run_context() -> DifyRunContext: @pytest.fixture -def runtime_state() -> ReadOnlyGraphRuntimeStateWrapper: - return ReadOnlyGraphRuntimeStateWrapper( - GraphRuntimeState( +def runtime_state() -> ReadOnlyRuntimeStateWrapper: + return ReadOnlyRuntimeStateWrapper( + RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap( system_variables=build_system_variables(workflow_execution_id="workflow-run-1") ), @@ -36,7 +37,7 @@ def runtime_state() -> ReadOnlyGraphRuntimeStateWrapper: def test_terminal_event_retires_workflow_workspace( - monkeypatch: pytest.MonkeyPatch, runtime_state: ReadOnlyGraphRuntimeStateWrapper + monkeypatch: pytest.MonkeyPatch, runtime_state: ReadOnlyRuntimeStateWrapper ) -> None: store = MagicMock() events: list[str] = [] @@ -77,7 +78,7 @@ def test_non_terminal_event_does_not_retire_workspace(monkeypatch: pytest.Monkey def test_terminal_retirement_failure_does_not_replace_terminal_event( - monkeypatch: pytest.MonkeyPatch, runtime_state: ReadOnlyGraphRuntimeStateWrapper + monkeypatch: pytest.MonkeyPatch, runtime_state: ReadOnlyRuntimeStateWrapper ) -> None: store = MagicMock() store.retire_workflow_run.side_effect = RuntimeError("database unavailable") diff --git a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py index 2603e29be6d..fea97bda874 100644 --- a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py +++ b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py @@ -6,7 +6,7 @@ from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.nodes.answer.answer_node import AnswerNode from graphon.nodes.answer.entities import AnswerNodeData -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params @@ -41,14 +41,15 @@ def _build_answer_node(*, answer: str, variable_pool: VariablePool) -> AnswerNod invoke_from=InvokeFrom.DEBUGGER, call_depth=0, ) - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=variable_pool, start_at=time.perf_counter(), ) return AnswerNode( node_id=str(uuid.uuid4()), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, data=AnswerNodeData( title="Answer", type="answer", diff --git a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py index d6987f0d49a..595b81ab397 100644 --- a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py @@ -90,8 +90,8 @@ def test_datasource_node_delegates_to_manager_stream(mocker: MockerFixture): plugin_id="plug", datasource_name="ds", ), - graph_init_params=gp, - graph_runtime_state=gs, + init_params=gp, + runtime_state=gs, datasource_credentials=lambda _tenant, _provider, _plugin, _credential: {}, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py index 08acd9ab747..93bea6c321d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py +++ b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py @@ -17,7 +17,7 @@ from core.workflow.nodes.datasource.entities import DatasourceNodeData from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.node_events import NodeRunResult, StreamCompletedEvent -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params @@ -33,7 +33,7 @@ def test_node_integration_minimal_stream(mocker: MockerFixture, sqlite_engine: E }, ) ) - graph_init_params = build_test_graph_init_params( + init_params = build_test_graph_init_params( workflow_id="wf-1", tenant_id="t1", app_id="app-1", @@ -41,7 +41,7 @@ def test_node_integration_minimal_stream(mocker: MockerFixture, sqlite_engine: E user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, ) - 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()) class _Mgr: @classmethod @@ -78,7 +78,7 @@ def test_node_integration_minimal_stream(mocker: MockerFixture, sqlite_engine: E "core.workflow.node_factory.build_data_source_credentials", return_value=SimpleNamespace(providers=SimpleNamespace(get_datasource_credentials=load_credentials)), ) - factory = DifyNodeFactory(graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state) + factory = DifyNodeFactory(init_params=init_params, runtime_state=runtime_state) node = factory.create_node({"id": "n", "data": node_data.model_dump()}) build_credentials.assert_called_once_with(database_client=database_client) assert isinstance(node, DatasourceNode) @@ -86,8 +86,8 @@ def test_node_integration_minimal_stream(mocker: MockerFixture, sqlite_engine: E node = DatasourceNode( node_id="n", data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, datasource_credentials=load_credentials, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py index afde541beb3..430e6a15537 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py @@ -13,7 +13,7 @@ from graphon.enums import WorkflowNodeExecutionStatus from graphon.file.file_manager import file_manager from graphon.nodes.http_request import HTTP_REQUEST_CONFIG_FILTER_KEY, HttpRequestNode, HttpRequestNodeConfig from graphon.nodes.http_request.entities import HttpRequestNodeData, HttpRequestNodeTimeout, Response -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params HTTP_REQUEST_CONFIG = HttpRequestNodeConfig( @@ -99,7 +99,7 @@ def _build_http_node( ], "edges": [], } - graph_init_params = build_test_graph_init_params( + init_params = build_test_graph_init_params( workflow_id="workflow", graph_config=graph_config, tenant_id="tenant", @@ -109,7 +109,8 @@ def _build_http_node( invoke_from=InvokeFrom.DEBUGGER, call_depth=0, ) - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="user", files=[]), user_inputs={}, @@ -119,13 +120,13 @@ def _build_http_node( return HttpRequestNode( node_id="http-node", data=HttpRequestNodeData.model_validate(node_data), - graph_init_params=graph_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, file_manager=file_manager, - file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), + file_reference_factory=DifyFileReferenceFactory(init_params.run_context), ) diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index feb04a4a38a..813e70dc853 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -53,13 +53,12 @@ from core.workflow.nodes.human_input.enums import ( ValueSourceType, ) from core.workflow.system_variables import build_system_variables -from graphon.entities import GraphInitParams from graphon.file import File, FileTransferMethod, FileType from graphon.node_events import PauseRequestedEvent from graphon.node_events.node import StreamCompletedEvent from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.protocols import FileReferenceFactoryProtocol -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import InitParams, RuntimeState, VariablePool from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from libs.datetime_utils import naive_utc_now from models.account import TenantAccountJoin, TenantAccountRole @@ -175,8 +174,8 @@ def _build_human_input_node( *, node_id: str, node_data: HumanInputNodeData | Mapping[str, Any], - graph_init_params: GraphInitParams, - graph_runtime_state: GraphRuntimeState, + init_params: InitParams, + runtime_state: RuntimeState, runtime: DifyHumanInputNodeRuntime, ) -> HumanInputNode: typed_node_data = ( @@ -193,8 +192,8 @@ def _build_human_input_node( return HumanInputNode( node_id=node_id, data=typed_node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, hitl_callback=callback, ) @@ -497,8 +496,8 @@ class TestHumanInputNodeVariableResolution: conversation_variables=[], ) variable_pool.add(("start", "name"), "Jane Doe") - runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) - graph_init_params = GraphInitParams( + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0) + init_params = InitParams( workflow_id="workflow", graph_config={"nodes": [], "edges": []}, run_context={ @@ -531,7 +530,7 @@ class TestHumanInputNodeVariableResolution: config = {"id": "human", "data": node_data.model_dump()} runtime = DifyHumanInputNodeRuntime( - graph_init_params.run_context, + init_params.run_context, workflow_execution_id_getter=lambda: "exec-1", ) repository = runtime.build_form_repository() @@ -540,8 +539,8 @@ class TestHumanInputNodeVariableResolution: node = _build_human_input_node( node_id=config["id"], node_data=config["data"], - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, runtime=runtime, ) @@ -567,8 +566,8 @@ class TestHumanInputNodeVariableResolution: user_inputs={}, conversation_variables=[], ) - runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) - graph_init_params = GraphInitParams( + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0) + init_params = InitParams( workflow_id="workflow", graph_config={"nodes": [], "edges": []}, run_context={ @@ -592,7 +591,7 @@ class TestHumanInputNodeVariableResolution: config = {"id": "human", "data": node_data.model_dump()} runtime = DifyHumanInputNodeRuntime( - graph_init_params.run_context, + init_params.run_context, workflow_execution_id_getter=lambda: "exec-2", ) repository = runtime.build_form_repository() @@ -601,8 +600,8 @@ class TestHumanInputNodeVariableResolution: node = _build_human_input_node( node_id=config["id"], node_data=config["data"], - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, runtime=runtime, ) @@ -623,8 +622,8 @@ class TestHumanInputNodeVariableResolution: user_inputs={}, conversation_variables=[], ) - runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) - graph_init_params = GraphInitParams( + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0) + init_params = InitParams( workflow_id="workflow", graph_config={"nodes": [], "edges": []}, run_context={ @@ -652,7 +651,7 @@ class TestHumanInputNodeVariableResolution: } runtime = DifyHumanInputNodeRuntime( - graph_init_params.run_context, + init_params.run_context, workflow_execution_id_getter=lambda: "exec-4", ) repository = runtime.build_form_repository() @@ -661,8 +660,8 @@ class TestHumanInputNodeVariableResolution: node = _build_human_input_node( node_id=config["id"], node_data=config["data"], - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, runtime=runtime, ) @@ -684,8 +683,8 @@ class TestHumanInputNodeVariableResolution: user_inputs={}, conversation_variables=[], ) - runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) - graph_init_params = GraphInitParams( + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0) + init_params = InitParams( workflow_id="workflow", graph_config={"nodes": [], "edges": []}, run_context={ @@ -723,7 +722,7 @@ class TestHumanInputNodeVariableResolution: config = {"id": "human", "data": node_data.model_dump()} runtime = DifyHumanInputNodeRuntime( - graph_init_params.run_context, + init_params.run_context, workflow_execution_id_getter=lambda: "exec-3", ) repository = runtime.build_form_repository() @@ -732,8 +731,8 @@ class TestHumanInputNodeVariableResolution: node = _build_human_input_node( node_id=config["id"], node_data=config["data"], - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, runtime=runtime, ) @@ -798,8 +797,8 @@ class TestHumanInputNodeRenderedContent: user_inputs={}, conversation_variables=[], ) - runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) - graph_init_params = GraphInitParams( + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0) + init_params = InitParams( workflow_id="workflow", graph_config={"nodes": [], "edges": []}, run_context={ @@ -823,13 +822,13 @@ class TestHumanInputNodeRenderedContent: config = {"id": "human", "data": node_data.model_dump()} form_repository = InMemoryHumanInputFormRepository() - runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) + runtime = DifyHumanInputNodeRuntime(init_params.run_context) runtime._build_form_repository = MagicMock(return_value=form_repository) # type: ignore[attr-defined] node = _build_human_input_node( node_id=config["id"], node_data=config["data"], - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, runtime=runtime, ) @@ -860,8 +859,8 @@ class TestHumanInputNodeRenderedContent: user_inputs={}, conversation_variables=[], ) - runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) - graph_init_params = GraphInitParams( + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0) + init_params = InitParams( workflow_id="workflow", graph_config={"nodes": [], "edges": []}, run_context={ @@ -896,13 +895,13 @@ class TestHumanInputNodeRenderedContent: config = {"id": "human", "data": node_data.model_dump()} form_repository = InMemoryHumanInputFormRepository() - runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) + runtime = DifyHumanInputNodeRuntime(init_params.run_context) runtime._build_form_repository = MagicMock(return_value=form_repository) # type: ignore[attr-defined] node = _build_human_input_node( node_id=config["id"], node_data=config["data"], - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, runtime=runtime, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index 11857203f88..22eb42c1c78 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -56,18 +56,18 @@ from core.workflow.nodes.human_input.entities import ( from core.workflow.nodes.human_input.enums import HumanInputFormStatus from core.workflow.system_variables import build_system_variables from core.workflow.workflow_entry import WorkflowEntry, iter_dify_graph_engine_events -from graphon.entities import GraphInitParams, WorkflowStartReason -from graphon.enums import BuiltinNodeTypes -from graphon.file import File, FileTransferMethod, FileType -from graphon.filters import GraphEventFilterContext, filter_graph_events -from graphon.graph import Graph -from graphon.graph_engine import GraphEngine, GraphEngineConfig -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import ( +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine.filter import EngineEventFilterContext, filter_engine_events +from graphon.engine_events import ( + EngineEvent, GraphEdgeSkippedEvent, GraphEdgeTakenEvent, - GraphEngineEvent, ) +from graphon.entities.workflow_start_reason import WorkflowStartReason +from graphon.enums import BuiltinNodeTypes +from graphon.file import File, FileTransferMethod, FileType +from graphon.graph import Graph from graphon.nodes.answer.answer_node import AnswerNode from graphon.nodes.answer.entities import AnswerNodeData from graphon.nodes.end.end_node import EndNode @@ -76,7 +76,7 @@ from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.protocols import FileReferenceFactoryProtocol from graphon.nodes.start.entities import StartNodeData from graphon.nodes.start.start_node import StartNode -from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool +from graphon.runtime import InitParams, ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool from graphon.variables.segments import StringSegment from libs.datetime_utils import naive_utc_now from libs.helper import compact_generate_response @@ -138,8 +138,8 @@ class _TestFileReferenceFactory(FileReferenceFactoryProtocol): def _create_human_input_node( *, config: dict, - graph_init_params: GraphInitParams, - graph_runtime_state: GraphRuntimeState, + init_params: InitParams, + runtime_state: RuntimeState, repo: _FakeFormRepository, ) -> HumanInputNode: node_data = ( @@ -155,8 +155,8 @@ def _create_human_input_node( node = HumanInputNode( node_id=config["id"], data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, hitl_callback=callback, ) node.bind_execution_id("00000000-0000-4000-8000-000000000001") @@ -175,7 +175,8 @@ def _build_node( node_id: str = "node-1", ) -> HumanInputNode: system_variables = build_system_variables(app_id="app", workflow_execution_id="run-1") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap( system_variables=system_variables, user_inputs={}, @@ -183,7 +184,7 @@ def _build_node( ), start_at=0.0, ) - graph_init_params = GraphInitParams( + init_params = InitParams( workflow_id="workflow", graph_config={"nodes": [], "edges": []}, run_context={ @@ -255,8 +256,8 @@ def _build_node( repo = _FakeFormRepository(fake_form) return _create_human_input_node( config=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, repo=repo, ) @@ -268,7 +269,8 @@ def _build_timeout_node( node_id: str = "node-1", ) -> HumanInputNode: system_variables = build_system_variables(app_id="app", workflow_execution_id="run-1") - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap( system_variables=system_variables, user_inputs={}, @@ -276,7 +278,7 @@ def _build_timeout_node( ), start_at=0.0, ) - graph_init_params = GraphInitParams( + init_params = InitParams( workflow_id="workflow", graph_config={"nodes": [], "edges": []}, run_context={ @@ -316,8 +318,8 @@ def _build_timeout_node( repo = _FakeFormRepository(fake_form) return _create_human_input_node( config=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, repo=repo, ) @@ -326,13 +328,13 @@ def _publish_node_events(node: HumanInputNode) -> list[AppQueueEvent]: return _publish_graph_events(_filter_human_input_events(node.run(), node=node)) -def _filter_human_input_events(events: Iterable[GraphEngineEvent], *, node: HumanInputNode | None = None): +def _filter_human_input_events(events: Iterable[EngineEvent], *, node: HumanInputNode | None = None): node = node or _build_node() - return filter_graph_events( + return filter_engine_events( events, - context=GraphEventFilterContext( + context=EngineEventFilterContext( graph=Graph(root_node=node), - runtime_state=ReadOnlyGraphRuntimeStateWrapper(node.graph_runtime_state), + runtime_state=ReadOnlyRuntimeStateWrapper(node.runtime_state), ), filters=[HumanInputFormEventFilter(form_repository=HumanInputFormSubmissionRepository())], ) @@ -341,10 +343,10 @@ def _filter_human_input_events(events: Iterable[GraphEngineEvent], *, node: Huma @pytest.mark.parametrize("event_type", [GraphEdgeTakenEvent, GraphEdgeSkippedEvent]) def test_human_input_filter_forwards_traversals_without_waiting_for_completion(event_type): started, _ = list(_build_node().run()) - edge = event_type(edge_id="human-answer", source_node_id="node-1", target_node_id="answer") + edge = event_type(frame_id="root", edge_id="human-answer", source_node_id="node-1", target_node_id="answer") edge_forwarded = False - def source() -> Generator[GraphEngineEvent]: + def source() -> Generator[EngineEvent]: yield started yield edge assert edge_forwarded, "The traversal must be forwarded before consuming more upstream events" @@ -361,8 +363,8 @@ def test_form_events_keep_titles_for_interleaved_executions_of_one_node(): started, succeeded = list(node.run()) first_start = started.model_copy(update={"id": "first", "node_title": "First approval"}) second_start = started.model_copy(update={"id": "second", "node_title": "Second approval"}) - first_result = succeeded.model_copy(update={"id": "first", "in_loop_id": "loop-1"}) - second_result = succeeded.model_copy(update={"id": "second", "in_iteration_id": "iteration-2"}) + first_result = succeeded.model_copy(update={"id": "first", "container_id": "loop-1"}) + second_result = succeeded.model_copy(update={"id": "second", "container_id": "iteration-2"}) events = _publish_graph_events( _filter_human_input_events([first_start, second_start, second_result, first_result], node=node) @@ -383,9 +385,9 @@ def test_form_events_keep_titles_for_interleaved_executions_of_one_node(): class _EventQueue(AppQueueManager): """Record events and replay a finite stream without starting a watchdog.""" - def __init__(self, invoke_from: InvokeFrom, runtime_state: GraphRuntimeState) -> None: + def __init__(self, invoke_from: InvokeFrom, runtime_state: RuntimeState) -> None: self.invoke_from = invoke_from - self.graph_runtime_state = runtime_state + self.runtime_state = runtime_state self.events: list[AppQueueEvent] = [] def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None: @@ -397,23 +399,28 @@ class _EventQueue(AppQueueManager): yield WorkflowQueueMessage(task_id="task-1", app_mode=AppMode.ADVANCED_CHAT, event=event) -def _publish_graph_events(events: Iterable[GraphEngineEvent]) -> list[AppQueueEvent]: +def _publish_graph_events(events: Iterable[EngineEvent]) -> list[AppQueueEvent]: node = _build_node() - runtime_state = node.graph_runtime_state + runtime_state = node.runtime_state queue_manager = _EventQueue(InvokeFrom.WEB_APP, runtime_state) runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app") workflow_entry = WorkflowEntry( tenant_id="tenant", app_id="app", workflow_id="workflow", - graph_config={}, + graph_config={ + "nodes": [ + {"id": "loop-1", "data": {"type": "loop", "container_id": ""}}, + {"id": "iteration-2", "data": {"type": "iteration", "container_id": ""}}, + ] + }, graph=Graph(root_node=node), user_id="user", user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.WEB_APP, call_depth=0, variable_pool=runtime_state.variable_pool, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, ) for event in events: runner._handle_event(workflow_entry, event) @@ -425,7 +432,7 @@ def _sse_payloads( invoke_from: InvokeFrom, app: Flask, sessions: sessionmaker[Session], - runtime_state: GraphRuntimeState | None = None, + runtime_state: RuntimeState | None = None, ) -> list[dict[str, Any]]: generate_entity = AdvancedChatAppGenerateEntity( task_id="task-1", @@ -444,7 +451,8 @@ def _sse_payloads( workflow_run_id="run-1", ) if runtime_state is None: - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap( system_variables=build_system_variables(workflow_execution_id="run-1") ), @@ -606,28 +614,28 @@ def test_human_input_completion_and_referenced_answer_reach_response_stream( if timed_out else _build_node("Approve?", with_inputs=False, node_id="human") ) - runtime_state = node.graph_runtime_state + runtime_state = node.runtime_state runtime_state.variable_pool.add(["sys", "workflow_execution_id"], StringSegment(value="run-1")) - init_params = GraphInitParams(workflow_id="workflow", graph_config={}, run_context={}, call_depth=0) + init_params = InitParams(workflow_id="workflow", graph_config={}, run_context={}, call_depth=0) start = StartNode( node_id="start", data=StartNodeData(title="Start", variables=[]), - graph_init_params=init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) if terminal == "answer": terminal_node = AnswerNode( node_id="answer", data=AnswerNodeData(title="Answer", answer="Action: {{#human.__action_id#}}"), - graph_init_params=init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) else: terminal_node = EndNode( node_id="end", data=EndNodeData(title="End", outputs=[]), - graph_init_params=init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) graph = ( Graph.new() @@ -636,12 +644,11 @@ def test_human_input_completion_and_referenced_answer_reach_response_stream( .add_node(terminal_node, from_node_id=node.id, source_handle="__timeout" if timed_out else "Accept") .build() ) - engine = GraphEngine( - workflow_id="workflow", + engine = Engine( graph=graph, - graph_runtime_state=runtime_state, + runtime_state=runtime_state, command_channel=InMemoryChannel(), - config=GraphEngineConfig(min_workers=1, max_workers=1), + workers=1, ) bind_execution_id = node.bind_execution_id diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index 41d9af344c2..f279c25b7b9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -19,14 +19,14 @@ from core.workflow.nodes.knowledge_index.protocols import ( ) from core.workflow.system_variables import SystemVariableKey, build_system_variables from graphon.enums import WorkflowNodeExecutionStatus -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from graphon.variables.segments import StringSegment from tests.workflow_test_utils import build_test_graph_init_params @pytest.fixture def mock_graph_init_params(): - """Create mock GraphInitParams.""" + """Create mock InitParams.""" return build_test_graph_init_params( workflow_id=str(uuid.uuid4()), graph_config={}, @@ -41,14 +41,14 @@ def mock_graph_init_params(): @pytest.fixture def mock_graph_runtime_state(): - """Create mock GraphRuntimeState.""" + """Create mock RuntimeState.""" variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id=str(uuid.uuid4()), files=[]), user_inputs={}, environment_variables=[], 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()) @pytest.fixture @@ -102,8 +102,8 @@ def _build_node( *, node_id: str, node_data: KnowledgeIndexNodeData | dict[str, object], - graph_init_params, - graph_runtime_state, + init_params, + runtime_state, ) -> KnowledgeIndexNode: return KnowledgeIndexNode( node_id=node_id, @@ -112,8 +112,8 @@ def _build_node( if isinstance(node_data, KnowledgeIndexNodeData) else KnowledgeIndexNodeData.model_validate(node_data) ), - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) @@ -141,8 +141,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act & Assert @@ -174,8 +174,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act & Assert @@ -210,8 +210,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -266,8 +266,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -329,8 +329,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -380,8 +380,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -437,8 +437,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -495,8 +495,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -534,8 +534,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -578,8 +578,8 @@ class TestKnowledgeIndexNode: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -619,8 +619,8 @@ class TestInvokeKnowledgeIndex: node = _build_node( node_id=node_id, node_data=config["data"], - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py index a5ceeb46459..c016fb269fb 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py @@ -25,7 +25,7 @@ from core.workflow.nodes.knowledge_retrieval.retrieval import Source, SourceMeta from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.model_runtime.entities.llm_entities import LLMUsage -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from graphon.variables import ArrayObjectSegment, StringSegment from tests.workflow_test_utils import build_test_graph_init_params @@ -46,7 +46,7 @@ def _source() -> Source: @pytest.fixture def mock_graph_init_params(): - """Create mock GraphInitParams.""" + """Create mock InitParams.""" return build_test_graph_init_params( workflow_id=str(uuid.uuid4()), graph_config={}, @@ -61,14 +61,14 @@ def mock_graph_init_params(): @pytest.fixture def mock_graph_runtime_state(): - """Create mock GraphRuntimeState.""" + """Create mock RuntimeState.""" variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id=str(uuid.uuid4()), files=[]), user_inputs={}, environment_variables=[], 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()) @pytest.fixture @@ -136,8 +136,8 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -193,8 +193,8 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -235,8 +235,8 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -271,8 +271,8 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -306,8 +306,8 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -346,8 +346,8 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -384,8 +384,8 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -464,8 +464,8 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -500,8 +500,8 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -554,8 +554,8 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) # Act @@ -594,8 +594,8 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) conditions = MetadataFilteringCondition( @@ -655,8 +655,8 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), - graph_init_params=mock_graph_init_params, - graph_runtime_state=mock_graph_runtime_state, + init_params=mock_graph_init_params, + runtime_state=mock_graph_runtime_state, ) mock_rag_retrieval.knowledge_retrieval.return_value = [] diff --git a/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py b/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py index 198ee553d9a..17cebfccf91 100644 --- a/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py +++ b/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py @@ -1,11 +1,10 @@ import pytest from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY -from graphon.entities import GraphInitParams from graphon.enums import WorkflowNodeExecutionStatus from graphon.nodes.list_operator.entities import ListOperatorNodeData from graphon.nodes.list_operator.node import ListOperatorNode -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import InitParams, RuntimeState, VariablePool from graphon.variables import ArrayNumberSegment, ArrayStringSegment @@ -13,12 +12,12 @@ class TestListOperatorNode: """Comprehensive tests for ListOperatorNode.""" @staticmethod - def _build_node(*, data, graph_init_params, graph_runtime_state): + def _build_node(*, data, init_params, runtime_state): return ListOperatorNode( node_id="test", data=data if isinstance(data, ListOperatorNodeData) else ListOperatorNodeData.model_validate(data), - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) @staticmethod @@ -29,14 +28,14 @@ class TestListOperatorNode: } @pytest.fixture - def graph_runtime_state(self): + def runtime_state(self): """Create graph state backed by the real variable pool.""" - return GraphRuntimeState(variable_pool=VariablePool(), start_at=0) + return RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0) @pytest.fixture - def graph_init_params(self): - """Create GraphInitParams fixture.""" - return GraphInitParams( + def init_params(self): + """Create InitParams fixture.""" + return InitParams( workflow_id="test", graph_config={}, run_context={ @@ -52,15 +51,15 @@ class TestListOperatorNode: ) @pytest.fixture - def list_operator_node_factory(self, graph_init_params, graph_runtime_state): + def list_operator_node_factory(self, init_params, runtime_state): """Factory fixture for creating ListOperatorNode instances.""" def _create_node(config, variable): - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) return self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) return _create_node @@ -83,7 +82,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == ["apple", "banana", "cherry"] - def test_run_with_empty_array(self, graph_runtime_state, graph_init_params): + def test_run_with_empty_array(self, runtime_state, init_params): """Test with empty array.""" config = { "title": "Test", @@ -94,12 +93,12 @@ class TestListOperatorNode: } variable = ArrayStringSegment(value=[]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -109,7 +108,7 @@ class TestListOperatorNode: assert result.outputs["first_record"] is None assert result.outputs["last_record"] is None - def test_run_with_filter_contains(self, graph_runtime_state, graph_init_params): + def test_run_with_filter_contains(self, runtime_state, init_params): """Test filter with contains condition.""" config = { "title": "Test", @@ -120,12 +119,12 @@ class TestListOperatorNode: } variable = ArrayStringSegment(value=["apple", "banana", "pineapple", "cherry"]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -133,7 +132,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == ["apple", "pineapple"] - def test_run_with_filter_not_contains(self, graph_runtime_state, graph_init_params): + def test_run_with_filter_not_contains(self, runtime_state, init_params): """Test filter with not contains condition.""" config = { "title": "Test", @@ -144,12 +143,12 @@ class TestListOperatorNode: } variable = ArrayStringSegment(value=["apple", "banana", "pineapple", "cherry"]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -157,7 +156,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == ["banana", "cherry"] - def test_run_with_number_filter_greater_than(self, graph_runtime_state, graph_init_params): + def test_run_with_number_filter_greater_than(self, runtime_state, init_params): """Test filter with greater than condition on numbers.""" config = { "title": "Test", @@ -168,12 +167,12 @@ class TestListOperatorNode: } variable = ArrayNumberSegment(value=[1, 3, 5, 7, 9, 11]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -181,7 +180,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == [7, 9, 11] - def test_run_with_order_ascending(self, graph_runtime_state, graph_init_params): + def test_run_with_order_ascending(self, runtime_state, init_params): """Test ordering in ascending order.""" config = { "title": "Test", @@ -195,12 +194,12 @@ class TestListOperatorNode: } variable = ArrayStringSegment(value=["cherry", "apple", "banana"]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -208,7 +207,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == ["apple", "banana", "cherry"] - def test_run_with_order_descending(self, graph_runtime_state, graph_init_params): + def test_run_with_order_descending(self, runtime_state, init_params): """Test ordering in descending order.""" config = { "title": "Test", @@ -222,12 +221,12 @@ class TestListOperatorNode: } variable = ArrayStringSegment(value=["cherry", "apple", "banana"]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -235,7 +234,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == ["cherry", "banana", "apple"] - def test_run_with_limit(self, graph_runtime_state, graph_init_params): + def test_run_with_limit(self, runtime_state, init_params): """Test with limit enabled.""" config = { "title": "Test", @@ -249,12 +248,12 @@ class TestListOperatorNode: } variable = ArrayStringSegment(value=["apple", "banana", "cherry", "date"]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -262,7 +261,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == ["apple", "banana"] - def test_run_with_filter_order_and_limit(self, graph_runtime_state, graph_init_params): + def test_run_with_filter_order_and_limit(self, runtime_state, init_params): """Test with filter, order, and limit combined.""" config = { "title": "Test", @@ -279,12 +278,12 @@ class TestListOperatorNode: } variable = ArrayNumberSegment(value=[1, 2, 3, 4, 5, 6, 7, 8, 9]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -292,7 +291,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == [9, 8, 7] - def test_run_with_variable_not_found(self, graph_runtime_state, graph_init_params): + def test_run_with_variable_not_found(self, runtime_state, init_params): """Test when variable is not found.""" config = { "title": "Test", @@ -304,8 +303,8 @@ class TestListOperatorNode: node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -313,7 +312,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.FAILED assert "Variable not found" in result.error - def test_run_with_first_and_last_record(self, graph_runtime_state, graph_init_params): + def test_run_with_first_and_last_record(self, runtime_state, init_params): """Test first_record and last_record outputs.""" config = { "title": "Test", @@ -324,12 +323,12 @@ class TestListOperatorNode: } variable = ArrayStringSegment(value=["first", "middle", "last"]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -338,7 +337,7 @@ class TestListOperatorNode: assert result.outputs["first_record"] == "first" assert result.outputs["last_record"] == "last" - def test_run_with_filter_startswith(self, graph_runtime_state, graph_init_params): + def test_run_with_filter_startswith(self, runtime_state, init_params): """Test filter with startswith condition.""" config = { "title": "Test", @@ -349,12 +348,12 @@ class TestListOperatorNode: } variable = ArrayStringSegment(value=["apple", "application", "banana", "apricot"]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -362,7 +361,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == ["apple", "application"] - def test_run_with_filter_endswith(self, graph_runtime_state, graph_init_params): + def test_run_with_filter_endswith(self, runtime_state, init_params): """Test filter with endswith condition.""" config = { "title": "Test", @@ -373,12 +372,12 @@ class TestListOperatorNode: } variable = ArrayStringSegment(value=["apple", "banana", "pineapple", "table"]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -386,7 +385,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == ["apple", "pineapple", "table"] - def test_run_with_number_filter_equals(self, graph_runtime_state, graph_init_params): + def test_run_with_number_filter_equals(self, runtime_state, init_params): """Test number filter with equals condition.""" config = { "title": "Test", @@ -397,12 +396,12 @@ class TestListOperatorNode: } variable = ArrayNumberSegment(value=[1, 3, 5, 5, 7, 9]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -410,7 +409,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == [5, 5] - def test_run_with_number_filter_not_equals(self, graph_runtime_state, graph_init_params): + def test_run_with_number_filter_not_equals(self, runtime_state, init_params): """Test number filter with not equals condition.""" config = { "title": "Test", @@ -421,12 +420,12 @@ class TestListOperatorNode: } variable = ArrayNumberSegment(value=[1, 3, 5, 7, 9]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() @@ -434,7 +433,7 @@ class TestListOperatorNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["result"].value == [1, 3, 7, 9] - def test_run_with_number_order_ascending(self, graph_runtime_state, graph_init_params): + def test_run_with_number_order_ascending(self, runtime_state, init_params): """Test number ordering in ascending order.""" config = { "title": "Test", @@ -448,12 +447,12 @@ class TestListOperatorNode: } variable = ArrayNumberSegment(value=[9, 3, 7, 1, 5]) - graph_runtime_state.variable_pool.add(config["variable"], variable) + runtime_state.variable_pool.add(config["variable"], variable) node = self._build_node( data=config, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_node_execution_behavior.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_node_execution_behavior.py index 32334e561fd..fa58045996d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_node_execution_behavior.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_node_execution_behavior.py @@ -23,9 +23,10 @@ from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus from graphon.model_runtime.entities.message_entities import PromptMessage from graphon.node_events import StreamCompletedEvent +from graphon.nodes.llm import llm_utils from graphon.nodes.llm.entities import LLMNodeData from graphon.nodes.llm.node import LLMNode -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from tests.unit_tests.core.model_fixtures import make_model_config, make_model_instance from tests.workflow_test_utils import build_test_graph_init_params @@ -77,14 +78,14 @@ def init_llm_node(config: dict[str, object]) -> LLMNode: ) variable_pool.add(["abc", "output"], "sunny") - 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()) run_context = resolve_dify_run_context(init_params.run_context) node = LLMNode( node_id=str(uuid.uuid4()), data=LLMNodeData.model_validate(config["data"]), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, credentials_provider=DifyCredentialsProvider(run_context=run_context), model_factory=DifyModelFactory(run_context=run_context), model_instance=DifyPreparedLLM(make_model_instance(provider="openai", model="gpt-3.5-turbo")), @@ -138,7 +139,8 @@ def test_execute_llm(monkeypatch: pytest.MonkeyPatch) -> None: model_instance = make_model_instance(provider="openai", model="gpt-3.5-turbo") schema = make_model_config(provider="openai", model="gpt-3.5-turbo", mode="chat").model_schema - monkeypatch.setattr(model_instance, "get_model_schema", MagicMock(return_value=schema)) + monkeypatch.setattr(model_instance.model_type_instance, "get_model_schema", MagicMock(return_value=schema)) + monkeypatch.setattr(model_instance, "get_llm_num_tokens", MagicMock(return_value=0)) mock_usage = LLMUsage( prompt_tokens=30, prompt_unit_price=Decimal("0.001"), @@ -174,7 +176,7 @@ def test_execute_llm(monkeypatch: pytest.MonkeyPatch) -> None: node._model_instance = build_model_instance() - with patch.object(LLMNode, "fetch_prompt_messages", mock_fetch_prompt_messages_1): + with patch.object(llm_utils, "fetch_prompt_messages", mock_fetch_prompt_messages_1): # execute node result = node._run() assert isinstance(result, Generator) @@ -239,7 +241,8 @@ def test_execute_llm_with_jinja2(monkeypatch: pytest.MonkeyPatch) -> None: model_instance = make_model_instance(provider="openai", model="gpt-3.5-turbo") schema = make_model_config(provider="openai", model="gpt-3.5-turbo", mode="chat").model_schema - monkeypatch.setattr(model_instance, "get_model_schema", MagicMock(return_value=schema)) + monkeypatch.setattr(model_instance.model_type_instance, "get_model_schema", MagicMock(return_value=schema)) + monkeypatch.setattr(model_instance, "get_llm_num_tokens", MagicMock(return_value=0)) mock_usage = LLMUsage( prompt_tokens=30, prompt_unit_price=Decimal("0.001"), @@ -275,7 +278,7 @@ def test_execute_llm_with_jinja2(monkeypatch: pytest.MonkeyPatch) -> None: node._model_instance = build_model_instance() - with patch.object(LLMNode, "fetch_prompt_messages", mock_fetch_prompt_messages_2): + with patch.object(llm_utils, "fetch_prompt_messages", mock_fetch_prompt_messages_2): # execute node result = node._run() diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py index 3d6f14fbb11..3e3fcdc109a 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py @@ -135,7 +135,7 @@ def _fetch_prompt_messages_with_mocked_content(content): vision_detail=ImagePromptMessageContent.DETAIL.HIGH, variable_pool=variable_pool, jinja2_variables=[], - template_renderer=None, + jinja2_template_renderer=None, ) @@ -433,7 +433,7 @@ def test_handle_list_messages_renders_jinja2_messages(): jinja2_variables=[VariableSelector(variable="name", value_selector=["input", "name"])], variable_pool=variable_pool, vision_detail_config=ImagePromptMessageContent.DETAIL.HIGH, - template_renderer=renderer, + jinja2_template_renderer=renderer, ) assert prompt_messages == [SystemPromptMessage(content=[TextPromptMessageContent(data="Hello Dify")])] @@ -542,17 +542,17 @@ def test_render_jinja2_message_handles_empty_template_success_and_missing_render template="", jinja2_variables=variables, variable_pool=variable_pool, - template_renderer=None, + jinja2_template_renderer=None, ) == "" ) - with pytest.raises(ValueError, match="template_renderer is required"): + with pytest.raises(ValueError, match="LLM prompts require an injected jinja2_template_renderer"): llm_utils.render_jinja2_message( template="Hello {{ name }}", jinja2_variables=variables, variable_pool=variable_pool, - template_renderer=None, + jinja2_template_renderer=None, ) renderer = mock.MagicMock() @@ -562,7 +562,7 @@ def test_render_jinja2_message_handles_empty_template_success_and_missing_render template="Hello {{ name }}", jinja2_variables=variables, variable_pool=variable_pool, - template_renderer=renderer, + jinja2_template_renderer=renderer, ) == "Hello Dify" ) @@ -592,7 +592,7 @@ def test_handle_completion_template_supports_basic_and_jinja2_templates(): context="", jinja2_variables=[VariableSelector(variable="name", value_selector=["input", "name"])], variable_pool=variable_pool, - template_renderer=renderer, + jinja2_template_renderer=renderer, ) assert basic_messages == [ diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py index 090155bc2da..5245e6b1927 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py @@ -21,7 +21,6 @@ from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly from core.prompt.entities.advanced_prompt_entities import MemoryConfig from core.workflow.node_runtime import DifyFileReferenceFactory, DifyPromptMessageSerializer, DifyToolFileManager from core.workflow.system_variables import default_system_variables -from graphon.entities import GraphInitParams from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.llm_entities import ( @@ -75,16 +74,16 @@ from graphon.nodes.llm.exc import ( VariableNotFoundError, ) from graphon.nodes.llm.file_saver import FileSaverImpl, LLMFileSaver -from graphon.nodes.llm.node import ( - LLMNode, - _calculate_rest_token, - _handle_completion_template, - _handle_memory_chat_mode, - _handle_memory_completion_mode, - _render_jinja2_message, +from graphon.nodes.llm.llm_utils import ( + calculate_rest_token, + handle_completion_template, + handle_memory_chat_mode, + handle_memory_completion_mode, + render_jinja2_message, ) +from graphon.nodes.llm.node import LLMNode from graphon.nodes.llm.reasoning import split_reasoning -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import InitParams, RuntimeState, VariablePool from graphon.template_rendering import TemplateRenderError from graphon.variables import ArrayAnySegment, ArrayFileSegment, NoneSegment from models.provider import ProviderType @@ -219,7 +218,7 @@ def llm_node_data() -> LLMNodeData: @pytest.fixture -def graph_init_params() -> GraphInitParams: +def init_params() -> InitParams: return build_test_graph_init_params( workflow_id="1", graph_config={}, @@ -233,21 +232,20 @@ def graph_init_params() -> GraphInitParams: @pytest.fixture -def graph_runtime_state() -> GraphRuntimeState: +def runtime_state() -> RuntimeState: variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) - return GraphRuntimeState( + return RuntimeState( + workflow_id="test-workflow", variable_pool=variable_pool, start_at=0, ) @pytest.fixture -def llm_node( - llm_node_data: LLMNodeData, graph_init_params: GraphInitParams, graph_runtime_state: GraphRuntimeState -) -> LLMNode: +def llm_node(llm_node_data: LLMNodeData, init_params: InitParams, runtime_state: RuntimeState) -> LLMNode: file_saver = _build_file_saver() credentials_provider = DifyCredentialsProvider(run_context=_run_context()) model_factory = DifyModelFactory(run_context=_run_context()) @@ -256,8 +254,8 @@ def llm_node( node = LLMNode( node_id="1", data=llm_node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, credentials_provider=credentials_provider, model_factory=model_factory, model_instance=_build_prepared_llm_mock(), @@ -626,7 +624,7 @@ def test_fetch_files_with_non_existent_variable(): # memory_config=None, # vision_enabled=False, # vision_detail=fake_vision_detail, -# variable_pool=llm_node.graph_runtime_state.variable_pool, +# variable_pool=llm_node.runtime_state.variable_pool, # jinja2_variables=[], # ) @@ -802,7 +800,7 @@ def test_fetch_files_with_non_existent_variable(): # for k, v in scenario.file_variables.items(): # selector = k.split(".") -# llm_node.graph_runtime_state.variable_pool.add(selector, v) +# llm_node.runtime_state.variable_pool.add(selector, v) # # Call the method under test # prompt_messages, _ = llm_node._fetch_prompt_messages( @@ -815,7 +813,7 @@ def test_fetch_files_with_non_existent_variable(): # memory_config=memory_config, # vision_enabled=scenario.vision_enabled, # vision_detail=scenario.vision_detail, -# variable_pool=llm_node.graph_runtime_state.variable_pool, +# variable_pool=llm_node.runtime_state.variable_pool, # jinja2_variables=[], # ) @@ -836,10 +834,10 @@ def test_handle_list_messages_basic(llm_node): ] context = "world" jinja2_variables = [] - variable_pool = llm_node.graph_runtime_state.variable_pool + variable_pool = llm_node.runtime_state.variable_pool vision_detail_config = ImagePromptMessageContent.DETAIL.HIGH - result = llm_node.handle_list_messages( + result = llm_utils.handle_list_messages( messages=messages, context=context, jinja2_variables=jinja2_variables, @@ -862,11 +860,11 @@ def test_handle_list_messages_replaces_double_brace_context_placeholder(llm_node ] context = "## Overview\nSends a JSON request." - result = llm_node.handle_list_messages( + result = llm_utils.handle_list_messages( messages=messages, context=context, jinja2_variables=[], - variable_pool=llm_node.graph_runtime_state.variable_pool, + variable_pool=llm_node.runtime_state.variable_pool, vision_detail_config=ImagePromptMessageContent.DETAIL.HIGH, ) @@ -880,11 +878,11 @@ def test_handle_list_messages_replaces_double_brace_context_placeholder(llm_node def test_handle_list_messages_renders_jinja2_messages(llm_node): - llm_node.graph_runtime_state.variable_pool.add(["input", "name"], "Dify") + llm_node.runtime_state.variable_pool.add(["input", "name"], "Dify") renderer = mock.MagicMock() renderer.render_template.return_value = "Hello Dify" - prompt_messages = llm_node.handle_list_messages( + prompt_messages = llm_utils.handle_list_messages( messages=[ LLMNodeChatModelMessage( text="ignored", @@ -895,7 +893,7 @@ def test_handle_list_messages_renders_jinja2_messages(llm_node): ], context="", jinja2_variables=[VariableSelector(variable="name", value_selector=["input", "name"])], - variable_pool=llm_node.graph_runtime_state.variable_pool, + variable_pool=llm_node.runtime_state.variable_pool, vision_detail_config=ImagePromptMessageContent.DETAIL.HIGH, jinja2_template_renderer=renderer, ) @@ -935,15 +933,15 @@ def test_transform_chat_messages_prefers_jinja2_text(llm_node): def test_fetch_jinja_inputs_serializes_supported_segment_types(llm_node): - llm_node.graph_runtime_state.variable_pool.add( + llm_node.runtime_state.variable_pool.add( ["input", "items"], ["alpha", {"metadata": {"_source": "knowledge"}, "content": "beta"}, 3], ) - llm_node.graph_runtime_state.variable_pool.add( + llm_node.runtime_state.variable_pool.add( ["input", "context_doc"], {"metadata": {"_source": "knowledge"}, "content": "context body"}, ) - llm_node.graph_runtime_state.variable_pool.add(["input", "payload"], {"a": 1}) + llm_node.runtime_state.variable_pool.add(["input", "payload"], {"a": 1}) node_data = llm_node.node_data.model_copy( update={ @@ -978,8 +976,8 @@ def test_fetch_jinja_inputs_raises_for_missing_variable(llm_node): def test_fetch_inputs_collects_prompt_and_memory_variables(llm_node): - llm_node.graph_runtime_state.variable_pool.add(["input", "name"], "Dify") - llm_node.graph_runtime_state.variable_pool.add(["input", "payload"], {"active": True}) + llm_node.runtime_state.variable_pool.add(["input", "name"], "Dify") + llm_node.runtime_state.variable_pool.add(["input", "payload"], {"active": True}) node_data = llm_node.node_data.model_copy( update={ @@ -1005,7 +1003,7 @@ def test_fetch_inputs_collects_prompt_and_memory_variables(llm_node): def test_fetch_context_emits_string_context_event(llm_node): - llm_node.graph_runtime_state.variable_pool.add(["context", "value"], "retrieved context") + llm_node.runtime_state.variable_pool.add(["context", "value"], "retrieved context") node_data = llm_node.node_data.model_copy( update={"context": ContextConfig(enabled=True, variable_selector=["context", "value"])} ) @@ -1026,7 +1024,7 @@ def test_fetch_context_collects_retriever_resources_and_attachments(llm_node): llm_node._retriever_attachment_loader = mock.MagicMock() llm_node._retriever_attachment_loader.load.return_value = [attachment] - llm_node.graph_runtime_state.variable_pool.add( + llm_node.runtime_state.variable_pool.add( ["context", "value"], [ { @@ -1079,7 +1077,7 @@ def test_fetch_context_collects_retriever_resources_and_attachments(llm_node): def test_fetch_context_rejects_invalid_context_structure(llm_node): - llm_node.graph_runtime_state.variable_pool.add(["context", "value"], [{"summary": "missing content"}]) + llm_node.runtime_state.variable_pool.add(["context", "value"], [{"summary": "missing content"}]) node_data = llm_node.node_data.model_copy( update={"context": ContextConfig(enabled=True, variable_selector=["context", "value"])} ) @@ -1117,7 +1115,7 @@ def test_fetch_prompt_messages_chat_mode_appends_memory_query_and_files(mocker: ), ] - with mock.patch("graphon.nodes.llm.node.file_manager.to_prompt_message_content") as mock_to_prompt: + with mock.patch("graphon.nodes.llm.llm_utils.file_manager.to_prompt_message_content") as mock_to_prompt: mock_to_prompt.side_effect = prompt_content_side_effect prompt_messages, stop = LLMNode.fetch_prompt_messages( sys_query="current question", @@ -1209,7 +1207,7 @@ def test_fetch_prompt_messages_raises_when_only_unsupported_content_remains(): with ( mock.patch( - "graphon.nodes.llm.node.file_manager.to_prompt_message_content", + "graphon.nodes.llm.llm_utils.file_manager.to_prompt_message_content", return_value=ImagePromptMessageContent( url="https://example.com/file.png", format="png", @@ -1241,15 +1239,15 @@ def test_fetch_prompt_messages_raises_when_only_unsupported_content_remains(): ) -def test_handle_completion_template_replaces_double_brace_context_placeholder(llm_node): - prompt_messages = _handle_completion_template( +def testhandle_completion_template_replaces_double_brace_context_placeholder(llm_node): + prompt_messages = handle_completion_template( template=LLMNodeCompletionModelPromptTemplate( text="Summarize the following context:\n{{#context#}}", edition_type="basic", ), context="## Overview\nSends a JSON request.", jinja2_variables=[], - variable_pool=llm_node.graph_runtime_state.variable_pool, + variable_pool=llm_node.runtime_state.variable_pool, jinja2_template_renderer=None, ) @@ -1287,8 +1285,8 @@ def test_handle_memory_completion_mode_uses_prompt_message_interface(mocker: Moc window=MemoryConfig.WindowConfig(enabled=True, size=3), ) - with mock.patch("graphon.nodes.llm.node._calculate_rest_token", return_value=2000) as mock_rest_token: - memory_text = _handle_memory_completion_mode( + with mock.patch("graphon.nodes.llm.llm_utils.calculate_rest_token", return_value=2000) as mock_rest_token: + memory_text = handle_memory_completion_mode( memory=memory, memory_config=memory_config, model_instance=model_instance, @@ -1300,9 +1298,7 @@ def test_handle_memory_completion_mode_uses_prompt_message_interface(mocker: Moc @pytest.fixture -def llm_node_for_multimodal( - llm_node_data, graph_init_params, graph_runtime_state -) -> tuple[LLMNode, _RecordingFileSaver]: +def llm_node_for_multimodal(llm_node_data, init_params, runtime_state) -> tuple[LLMNode, _RecordingFileSaver]: file_saver = _RecordingFileSaver(saved_file=None) credentials_provider = DifyCredentialsProvider(run_context=_run_context()) model_factory = DifyModelFactory(run_context=_run_context()) @@ -1311,8 +1307,8 @@ def llm_node_for_multimodal( node = LLMNode( node_id="1", data=llm_node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, credentials_provider=credentials_provider, model_factory=model_factory, model_instance=_build_prepared_llm_mock(), @@ -1326,6 +1322,7 @@ def llm_node_for_multimodal( class TestLLMNodeSaveMultiModalImageOutput: def test_llm_node_save_inline_output(self, llm_node_for_multimodal: tuple[LLMNode, _RecordingFileSaver]): llm_node, file_saver = llm_node_for_multimodal + file_outputs = [] content = ImagePromptMessageContent( format="png", base64_data=base64.b64encode(b"test-data").decode(), @@ -1347,13 +1344,14 @@ class TestLLMNodeSaveMultiModalImageOutput: file_saver=file_saver, ) # Manually append to _file_outputs since the static method doesn't do it - llm_node._file_outputs.append(file) - assert llm_node._file_outputs == [mock_file] + file_outputs.append(file) + assert file_outputs == [mock_file] assert file == mock_file assert file_saver.binary_calls == [(b"test-data", "image/png", FileType.IMAGE, ".png")] def test_llm_node_save_url_output(self, llm_node_for_multimodal: tuple[LLMNode, _RecordingFileSaver]): llm_node, file_saver = llm_node_for_multimodal + file_outputs = [] content = ImagePromptMessageContent( format="png", url="https://example.com/image.png", @@ -1375,8 +1373,8 @@ class TestLLMNodeSaveMultiModalImageOutput: file_saver=file_saver, ) # Manually append to _file_outputs since the static method doesn't do it - llm_node._file_outputs.append(file) - assert llm_node._file_outputs == [mock_file] + file_outputs.append(file) + assert file_outputs == [mock_file] assert file == mock_file assert file_saver.remote_calls == [(content.url, FileType.IMAGE)] @@ -1396,6 +1394,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: def test_str_content(self, llm_node_for_multimodal): llm_node, file_saver = llm_node_for_multimodal + file_outputs = [] gen = llm_node._save_multimodal_output_and_convert_result_to_markdown( contents="hello world", file_saver=file_saver, file_outputs=[] ) @@ -1405,6 +1404,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: def test_text_prompt_message_content(self, llm_node_for_multimodal): llm_node, file_saver = llm_node_for_multimodal + file_outputs = [] gen = llm_node._save_multimodal_output_and_convert_result_to_markdown( contents=[TextPromptMessageContent(data="hello world")], file_saver=file_saver, file_outputs=[] ) @@ -1414,6 +1414,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: def test_image_content_with_inline_data(self, llm_node_for_multimodal, monkeypatch: pytest.MonkeyPatch): llm_node, file_saver = llm_node_for_multimodal + file_outputs = [] image_raw_data = b"PNG_DATA" image_b64_data = base64.b64encode(image_raw_data).decode() @@ -1439,7 +1440,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: ) ], file_saver=file_saver, - file_outputs=llm_node._file_outputs, + file_outputs=file_outputs, ) yielded_strs = list(gen) assert len(yielded_strs) == 1 @@ -1454,10 +1455,11 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: assert expected_file_url_path in yielded_strs[0] assert yielded_strs[0].endswith(")") assert file_saver.binary_calls == [(image_raw_data, "image/png", FileType.IMAGE, ".png")] - assert mock_saved_file in llm_node._file_outputs + assert mock_saved_file in file_outputs def test_unknown_content_type(self, llm_node_for_multimodal): llm_node, file_saver = llm_node_for_multimodal + file_outputs = [] gen = llm_node._save_multimodal_output_and_convert_result_to_markdown( contents=frozenset(("hello world",)), file_saver=file_saver, file_outputs=[] ) @@ -1467,6 +1469,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: def test_unknown_item_type(self, llm_node_for_multimodal, caplog: pytest.LogCaptureFixture): llm_node, file_saver = llm_node_for_multimodal + file_outputs = [] unknown_item = self._UnknownItem() with caplog.at_level(logging.WARNING, logger="graphon.nodes.llm.node"): @@ -1481,6 +1484,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: def test_none_content(self, llm_node_for_multimodal): llm_node, file_saver = llm_node_for_multimodal + file_outputs = [] gen = llm_node._save_multimodal_output_and_convert_result_to_markdown( contents=None, file_saver=file_saver, file_outputs=[] ) @@ -1671,7 +1675,7 @@ def test_handle_invoke_result_wraps_structured_output_parse_errors(): raise ValueError("bad json") yield - with pytest.raises(LLMNodeError, match="Failed to parse structured output: bad json"): + with pytest.raises(LLMNodeError, match=r"Failed to parse structured output \(stage=result, path=\$\): bad json"): list( LLMNode.handle_invoke_result( invoke_result=broken_stream(), @@ -1711,10 +1715,10 @@ def test_fetch_structured_output_schema_validates_payload(): "type": "object" } - with pytest.raises(LLMNodeError, match="Please provide a valid structured output schema"): + with pytest.raises(LLMNodeError, match="Invalid structured output schema"): LLMNode.fetch_structured_output_schema(structured_output={}) - with pytest.raises(LLMNodeError, match="structured_output_schema must be a JSON object"): + with pytest.raises(LLMNodeError, match="expected a JSON object"): LLMNode.fetch_structured_output_schema(structured_output={"schema": ["not", "an", "object"]}) @@ -1768,9 +1772,9 @@ def test_render_jinja2_message_requires_renderer_and_passes_inputs(): with pytest.raises( TemplateRenderError, - match="LLMNode requires an injected jinja2_template_renderer for jinja2 prompts", + match="LLM prompts require an injected jinja2_template_renderer", ): - _render_jinja2_message( + render_jinja2_message( template="Hello {{ name }}", jinja2_variables=variables, variable_pool=variable_pool, @@ -1781,7 +1785,7 @@ def test_render_jinja2_message_requires_renderer_and_passes_inputs(): renderer.render_template.return_value = "Hello Dify" assert ( - _render_jinja2_message( + render_jinja2_message( template="Hello {{ name }}", jinja2_variables=variables, variable_pool=variable_pool, @@ -1808,7 +1812,7 @@ def test_calculate_rest_token_uses_context_size_and_max_tokens(): model_instance.get_llm_num_tokens.return_value = 1000 assert ( - _calculate_rest_token( + calculate_rest_token( prompt_messages=[UserPromptMessage(content="hello")], model_instance=model_instance, ) @@ -1821,8 +1825,8 @@ def test_handle_memory_chat_mode_uses_calculated_token_budget(mocker: MockerFixt memory = MockTokenBufferMemory(history) history_lookup = mocker.spy(memory, "get_history_prompt_messages") - with mock.patch("graphon.nodes.llm.node._calculate_rest_token", return_value=321) as mock_rest_token: - result = _handle_memory_chat_mode( + with mock.patch("graphon.nodes.llm.llm_utils.calculate_rest_token", return_value=321) as mock_rest_token: + result = handle_memory_chat_mode( memory=memory, memory_config=MemoryConfig(window=MemoryConfig.WindowConfig(enabled=True, size=2)), model_instance=_build_prepared_llm_mock(), diff --git a/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_execution_behavior.py b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_execution_behavior.py index 14fe53758a4..b4f003af522 100644 --- a/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_execution_behavior.py +++ b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_execution_behavior.py @@ -19,7 +19,7 @@ from graphon.model_runtime.entities.message_entities import PromptMessage from graphon.model_runtime.memory.prompt_message_memory import PromptMessageMemory from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData from graphon.nodes.parameter_extractor.parameter_extractor_node import ParameterExtractorNode -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from tests.unit_tests.core.model_fixtures import make_model_instance from tests.unit_tests.core.workflow.nodes.parameter_extractor.fixtures.model import get_mocked_fetch_model_instance from tests.workflow_test_utils import build_test_graph_init_params @@ -77,14 +77,14 @@ def init_parameter_extractor_node( 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()) run_context = resolve_dify_run_context(init_params.run_context) node = ParameterExtractorNode( node_id=str(uuid.uuid4()), data=ParameterExtractorNodeData.model_validate(config["data"]), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, credentials_provider=DifyCredentialsProvider(run_context=run_context), model_factory=DifyModelFactory(run_context=run_context), model_instance=DifyPreparedLLM(make_model_instance(provider="openai", model="gpt-3.5-turbo")), diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py index dbb4ec86950..470d9c95807 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py @@ -7,7 +7,7 @@ from graphon.enums import WorkflowNodeExecutionStatus from graphon.nodes.base.entities import VariableSelector from graphon.nodes.template_transform.entities import TemplateTransformNodeData from graphon.nodes.template_transform.template_transform_node import TemplateTransformNode -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from graphon.template_rendering import TemplateRenderError from tests.workflow_test_utils import build_test_graph_init_params @@ -15,8 +15,8 @@ from tests.workflow_test_utils import build_test_graph_init_params def _build_template_transform_node( *, node_data, - graph_init_params, - graph_runtime_state, + init_params, + runtime_state, node_id: str = "test_node", **kwargs, ) -> TemplateTransformNode: @@ -28,8 +28,8 @@ def _build_template_transform_node( return TemplateTransformNode( node_id=node_id, data=typed_node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, **kwargs, ) @@ -38,12 +38,12 @@ class TestTemplateTransformNode: """Comprehensive test suite for TemplateTransformNode.""" @pytest.fixture - def graph_runtime_state(self) -> GraphRuntimeState: + def runtime_state(self) -> RuntimeState: """Create graph state with real variable storage and conversion.""" - return GraphRuntimeState(variable_pool=VariablePool(), start_at=0) + return RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0) @pytest.fixture - def graph_init_params(self): + def init_params(self): """Create validated graph initialization parameters.""" return build_test_graph_init_params( workflow_id="test_workflow", @@ -73,8 +73,8 @@ class TestTemplateTransformNode: def test_node_initialization_rejects_non_positive_max_output_length( self, basic_node_data, - graph_runtime_state, - graph_init_params, + runtime_state, + init_params, max_output_length, ): mock_renderer = MagicMock() @@ -82,13 +82,13 @@ class TestTemplateTransformNode: with pytest.raises(ValueError, match="max_output_length must be a positive integer"): _build_template_transform_node( node_data=basic_node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, max_output_length=max_output_length, ) - def test_run_simple_template(self, basic_node_data, graph_runtime_state, graph_init_params): + def test_run_simple_template(self, basic_node_data, runtime_state, init_params): """Test _run with simple template transformation using injected renderer.""" name_value = "Alice" age_value = 30 @@ -98,7 +98,7 @@ class TestTemplateTransformNode: ("sys", "user_age"): age_value, } for selector, value in variable_map.items(): - graph_runtime_state.variable_pool.add(selector, value) + runtime_state.variable_pool.add(selector, value) # Setup mock renderer mock_renderer = MagicMock() @@ -106,8 +106,8 @@ class TestTemplateTransformNode: node = _build_template_transform_node( node_data=basic_node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, ) @@ -118,7 +118,7 @@ class TestTemplateTransformNode: assert result.inputs["name"] == "Alice" assert result.inputs["age"] == 30 - def test_run_with_none_values(self, graph_runtime_state, graph_init_params): + def test_run_with_none_values(self, runtime_state, init_params): """Test _run with None variable values.""" node_data = { "title": "Test", @@ -131,8 +131,8 @@ class TestTemplateTransformNode: node = _build_template_transform_node( node_data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, ) @@ -141,18 +141,18 @@ class TestTemplateTransformNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.inputs["value"] is None - def test_run_with_render_error(self, basic_node_data, graph_runtime_state, graph_init_params): + def test_run_with_render_error(self, basic_node_data, runtime_state, init_params): """Test _run when template rendering fails.""" - graph_runtime_state.variable_pool.add(["sys", "user_name"], "Alice") - graph_runtime_state.variable_pool.add(["sys", "user_age"], 30) + runtime_state.variable_pool.add(["sys", "user_name"], "Alice") + runtime_state.variable_pool.add(["sys", "user_age"], 30) mock_renderer = MagicMock() mock_renderer.render_template.side_effect = TemplateRenderError("Template syntax error") node = _build_template_transform_node( node_data=basic_node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, ) @@ -161,18 +161,18 @@ class TestTemplateTransformNode: assert result.status == WorkflowNodeExecutionStatus.FAILED assert "Template syntax error" in result.error - def test_run_output_length_exceeds_limit(self, basic_node_data, graph_runtime_state, graph_init_params): + def test_run_output_length_exceeds_limit(self, basic_node_data, runtime_state, init_params): """Test _run when output exceeds maximum length.""" - graph_runtime_state.variable_pool.add(["sys", "user_name"], "Alice") - graph_runtime_state.variable_pool.add(["sys", "user_age"], 30) + runtime_state.variable_pool.add(["sys", "user_name"], "Alice") + runtime_state.variable_pool.add(["sys", "user_age"], 30) mock_renderer = MagicMock() mock_renderer.render_template.return_value = "This is a very long output that exceeds the limit" node = _build_template_transform_node( node_data=basic_node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, max_output_length=10, ) @@ -182,17 +182,17 @@ class TestTemplateTransformNode: assert result.status == WorkflowNodeExecutionStatus.FAILED assert "Output length exceeds" in result.error - def test_run_output_length_equal_to_limit_succeeds(self, basic_node_data, graph_runtime_state, graph_init_params): - graph_runtime_state.variable_pool.add(["sys", "user_name"], "Alice") - graph_runtime_state.variable_pool.add(["sys", "user_age"], 30) + def test_run_output_length_equal_to_limit_succeeds(self, basic_node_data, runtime_state, init_params): + runtime_state.variable_pool.add(["sys", "user_name"], "Alice") + runtime_state.variable_pool.add(["sys", "user_age"], 30) mock_renderer = MagicMock() mock_renderer.render_template.return_value = "1234567890" node = _build_template_transform_node( node_data=basic_node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, max_output_length=10, ) @@ -202,7 +202,7 @@ class TestTemplateTransformNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["output"] == "1234567890" - def test_run_with_complex_jinja2_template(self, graph_runtime_state, graph_init_params): + def test_run_with_complex_jinja2_template(self, runtime_state, init_params): """Test _run with complex Jinja2 template including loops and conditions.""" node_data = { "title": "Complex Template", @@ -224,15 +224,15 @@ class TestTemplateTransformNode: ("sys", "show_total"): show_total, } for selector, value in variable_map.items(): - graph_runtime_state.variable_pool.add(selector, value) + runtime_state.variable_pool.add(selector, value) mock_renderer = MagicMock() mock_renderer.render_template.return_value = "apple, banana, orange (Total: 3)" node = _build_template_transform_node( node_data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, ) @@ -324,7 +324,7 @@ class TestTemplateTransformNode: assert mapping == {"node_123.var1": ["sys", "input1"]} - def test_run_with_empty_variables(self, graph_runtime_state, graph_init_params): + def test_run_with_empty_variables(self, runtime_state, init_params): """Test _run with no variables (static template).""" node_data = { "title": "Static Template", @@ -337,8 +337,8 @@ class TestTemplateTransformNode: node = _build_template_transform_node( node_data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, ) @@ -348,7 +348,7 @@ class TestTemplateTransformNode: assert result.outputs["output"] == "This is a static message." assert result.inputs == {} - def test_run_with_numeric_values(self, graph_runtime_state, graph_init_params): + def test_run_with_numeric_values(self, runtime_state, init_params): """Test _run with numeric variable values.""" node_data = { "title": "Numeric Template", @@ -367,15 +367,15 @@ class TestTemplateTransformNode: ("sys", "quantity"): quantity, } for selector, value in variable_map.items(): - graph_runtime_state.variable_pool.add(selector, value) + runtime_state.variable_pool.add(selector, value) mock_renderer = MagicMock() mock_renderer.render_template.return_value = "Total: $31.5" node = _build_template_transform_node( node_data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, ) @@ -384,7 +384,7 @@ class TestTemplateTransformNode: assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["output"] == "Total: $31.5" - def test_run_with_dict_values(self, graph_runtime_state, graph_init_params): + def test_run_with_dict_values(self, runtime_state, init_params): """Test _run with dictionary variable values.""" node_data = { "title": "Dict Template", @@ -394,15 +394,15 @@ class TestTemplateTransformNode: user = {"name": "John Doe", "email": "john@example.com"} - graph_runtime_state.variable_pool.add(["sys", "user_data"], user) + runtime_state.variable_pool.add(["sys", "user_data"], user) mock_renderer = MagicMock() mock_renderer.render_template.return_value = "Name: John Doe, Email: john@example.com" node = _build_template_transform_node( node_data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, ) @@ -412,7 +412,7 @@ class TestTemplateTransformNode: assert "John Doe" in result.outputs["output"] assert "john@example.com" in result.outputs["output"] - def test_run_with_list_values(self, graph_runtime_state, graph_init_params): + def test_run_with_list_values(self, runtime_state, init_params): """Test _run with list variable values.""" node_data = { "title": "List Template", @@ -422,15 +422,15 @@ class TestTemplateTransformNode: tags = ["python", "ai", "workflow"] - graph_runtime_state.variable_pool.add(["sys", "tags"], tags) + runtime_state.variable_pool.add(["sys", "tags"], tags) mock_renderer = MagicMock() mock_renderer.render_template.return_value = "Tags: #python #ai #workflow " node = _build_template_transform_node( node_data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=mock_renderer, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_execution.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_execution.py index 086ca44ee99..c8b681a2457 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_execution.py +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_execution.py @@ -11,7 +11,7 @@ from graphon.enums import WorkflowNodeExecutionStatus from graphon.graph import Graph from graphon.nodes.template_transform.entities import TemplateTransformNodeData from graphon.nodes.template_transform.template_transform_node import TemplateTransformNode -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderError from tests.workflow_test_utils import build_test_graph_init_params @@ -79,12 +79,12 @@ def test_execute_template_transform() -> None: variable_pool.add(["1", "args1"], 1) variable_pool.add(["1", "args2"], 3) - 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 (graph init path still works regardless of renderer choice below) 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") @@ -93,8 +93,8 @@ def test_execute_template_transform() -> None: node = TemplateTransformNode( node_id=str(uuid.uuid4()), data=TemplateTransformNodeData.model_validate(config["data"]), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, jinja2_template_renderer=cast(Jinja2TemplateRenderer, _SimpleJinja2Renderer()), ) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_base_node.py b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py index a05151f79b0..57720851cc3 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_base_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py @@ -5,11 +5,10 @@ import pytest from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.workflow.node_runtime import resolve_dify_run_context from core.workflow.system_variables import build_system_variables -from graphon.entities import GraphInitParams from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes from graphon.nodes.base.node import Node -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import InitParams, RuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params @@ -28,13 +27,14 @@ class _SampleNode(Node[_SampleNodeData]): raise NotImplementedError -def _build_context(graph_config: Mapping[str, object]) -> tuple[GraphInitParams, GraphRuntimeState]: +def _build_context(graph_config: Mapping[str, object]) -> tuple[InitParams, RuntimeState]: init_params = build_test_graph_init_params( graph_config=graph_config, user_from="account", invoke_from="debugger", ) - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="user", files=[]), user_inputs={}, @@ -66,8 +66,8 @@ def test_node_hydrates_data_during_initialization(): node = _SampleNode( node_id="node-1", data=_build_node_data(), - graph_init_params=init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) assert node.node_data.foo == "bar" @@ -84,7 +84,8 @@ def test_node_accepts_invoke_from_enum(): user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, ) - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="user", files=[]), user_inputs={}, @@ -95,8 +96,8 @@ def test_node_accepts_invoke_from_enum(): node = _SampleNode( node_id="node-1", data=_build_node_data(), - graph_init_params=init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) dify_ctx = resolve_dify_run_context(node.run_context) @@ -147,8 +148,8 @@ def test_node_hydration_preserves_compatibility_extra_fields(): node = _SampleNode( node_id="node-1", data=node_config["data"], - graph_init_params=init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) assert node.node_data.foo == "bar" diff --git a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py index d8bc859dae6..33a505b5c6c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py @@ -6,7 +6,6 @@ import pytest from docx import Document from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from graphon.entities import GraphInitParams from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType from graphon.node_events import NodeRunResult @@ -20,6 +19,7 @@ from graphon.nodes.document_extractor.node import ( _extract_text_from_plain_text, _normalize_docx_zip, ) +from graphon.runtime import InitParams from graphon.variables import ArrayFileSegment, FileSegment from graphon.variables.segments import ArrayStringSegment from graphon.variables.variables import StringVariable @@ -38,7 +38,7 @@ def make_file(extension: str | None, mime_type: str | None) -> File: @pytest.fixture -def graph_init_params() -> GraphInitParams: +def init_params() -> InitParams: return build_test_graph_init_params( workflow_id="test_workflow", graph_config={}, @@ -52,7 +52,7 @@ def graph_init_params() -> GraphInitParams: @pytest.fixture -def document_extractor_node(graph_init_params): +def document_extractor_node(init_params): node_data = DocumentExtractorNodeData( title="Test Document Extractor", variable_selector=["node_id", "variable_name"], @@ -61,8 +61,8 @@ def document_extractor_node(graph_init_params): node = DocumentExtractorNode( node_id="test_node_id", data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=Mock(), + init_params=init_params, + runtime_state=Mock(), http_client=http_client, ) return node @@ -74,7 +74,7 @@ def mock_graph_runtime_state(): def test_run_variable_not_found(document_extractor_node, mock_graph_runtime_state): - document_extractor_node.graph_runtime_state = mock_graph_runtime_state + document_extractor_node.runtime_state = mock_graph_runtime_state mock_graph_runtime_state.variable_pool.get.return_value = None result = document_extractor_node._run() @@ -86,7 +86,7 @@ def test_run_variable_not_found(document_extractor_node, mock_graph_runtime_stat def test_run_invalid_variable_type(document_extractor_node, mock_graph_runtime_state): - document_extractor_node.graph_runtime_state = mock_graph_runtime_state + document_extractor_node.runtime_state = mock_graph_runtime_state mock_graph_runtime_state.variable_pool.get.return_value = StringVariable( value="Not an ArrayFileSegment", name="test" ) @@ -101,7 +101,7 @@ def test_run_invalid_variable_type(document_extractor_node, mock_graph_runtime_s def test_run_empty_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state): """Empty file list should return SUCCEEDED with empty documents and ArrayStringSegment([]).""" - document_extractor_node.graph_runtime_state = mock_graph_runtime_state + document_extractor_node.runtime_state = mock_graph_runtime_state # Provide an actual ArrayFileSegment with an empty list mock_graph_runtime_state.variable_pool.get.return_value = ArrayFileSegment(value=[]) @@ -116,7 +116,7 @@ def test_run_empty_file_list_returns_succeeded(document_extractor_node, mock_gra def test_run_none_only_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state): """A file list containing only None (e.g., [None]) should be filtered to [] and succeed.""" - document_extractor_node.graph_runtime_state = mock_graph_runtime_state + document_extractor_node.runtime_state = mock_graph_runtime_state # Preserve the legacy null-entry payload handled by the node filtering branch. afs = ArrayFileSegment.model_construct(value=[None]) @@ -173,7 +173,7 @@ def test_run_extract_text( extension, monkeypatch, ): - document_extractor_node.graph_runtime_state = mock_graph_runtime_state + document_extractor_node.runtime_state = mock_graph_runtime_state file = File( file_type=FileType.DOCUMENT, @@ -460,27 +460,21 @@ def test_extract_text_from_file_routes_odt_inputs_to_graphon_odt_extractor( ): file = make_file(extension, mime_type) - def fake_partition(file_content, *, suffix, unstructured_api_config, load_local_partition, render_element): - assert file_content == b"odt content" - assert suffix == ".odt" - assert unstructured_api_config == document_extractor_node._unstructured_api_config - assert load_local_partition.__name__ == "_load_partition_odt" - assert render_element is not None - return f"extracted through {route_label}" - with ( patch( "graphon.nodes.document_extractor.node._download_file_content", return_value=b"odt content", ) as mock_download, - patch("graphon.nodes.document_extractor.node._partition_unstructured_file", side_effect=fake_partition), + patch("graphon.nodes.document_extractor.node.OdfDocument") as document, ): + document.return_value.get_formatted_text.return_value = f"extracted through {route_label}" text = _extract_text_from_file( document_extractor_node.http_client, file, unstructured_api_config=document_extractor_node._unstructured_api_config, ) + document.assert_called_once() assert text == f"extracted through {route_label}" mock_download.assert_called_once_with(document_extractor_node.http_client, file) @@ -549,7 +543,7 @@ def test_extract_text_from_file_rejects_missing_extension_and_mime_type(document def test_run_list_file_extraction_error_returns_failed(document_extractor_node, mock_graph_runtime_state): - document_extractor_node.graph_runtime_state = mock_graph_runtime_state + document_extractor_node.runtime_state = mock_graph_runtime_state file_list = ArrayFileSegment(value=[make_file(".txt", "text/plain")]) mock_graph_runtime_state.variable_pool.get.return_value = file_list @@ -564,7 +558,7 @@ def test_run_list_file_extraction_error_returns_failed(document_extractor_node, def test_run_single_file_segment_extraction_error_returns_failed(document_extractor_node, mock_graph_runtime_state): - document_extractor_node.graph_runtime_state = mock_graph_runtime_state + document_extractor_node.runtime_state = mock_graph_runtime_state file_segment = FileSegment(value=make_file(".txt", "text/plain")) mock_graph_runtime_state.variable_pool.get.return_value = file_segment @@ -579,7 +573,7 @@ def test_run_single_file_segment_extraction_error_returns_failed(document_extrac def test_run_single_file_segment_returns_string_output(document_extractor_node, mock_graph_runtime_state): - document_extractor_node.graph_runtime_state = mock_graph_runtime_state + document_extractor_node.runtime_state = mock_graph_runtime_state file_segment = FileSegment(value=make_file(".txt", "text/plain")) mock_graph_runtime_state.variable_pool.get.return_value = file_segment diff --git a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py index 38d1e94bbc9..e9c9ee663ed 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py @@ -12,7 +12,7 @@ from graphon.file import File, FileTransferMethod, FileType from graphon.graph import Graph from graphon.nodes.if_else.entities import IfElseNodeData from graphon.nodes.if_else.if_else_node import IfElseNode -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from graphon.utils.condition.entities import Condition, SubCondition, SubVariableCondition from graphon.variables import ArrayFileSegment from tests.workflow_test_utils import build_test_graph_init_params @@ -22,12 +22,12 @@ def _build_if_else_node( *, node_data: IfElseNodeData | dict[str, object], init_params, - graph_runtime_state, + runtime_state, ) -> IfElseNode: return IfElseNode( node_id=str(uuid.uuid4()), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, data=node_data if isinstance(node_data, IfElseNodeData) else IfElseNodeData.model_validate(node_data), ) @@ -70,10 +70,10 @@ def test_execute_if_else_result_true(): pool.add(["start", "null"], None) pool.add(["start", "not_null"], "1212") - graph_runtime_state = GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=pool, start_at=time.perf_counter()) 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") @@ -120,7 +120,7 @@ def test_execute_if_else_result_true(): ], }, init_params=init_params, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, ) # execute node @@ -155,10 +155,10 @@ def test_execute_if_else_result_false(): pool.add(["start", "array_contains"], ["1ab", "def"]) pool.add(["start", "array_not_contains"], ["ab", "def"]) - graph_runtime_state = GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=pool, start_at=time.perf_counter()) 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") @@ -181,7 +181,7 @@ def test_execute_if_else_result_false(): ], }, init_params=init_params, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, ) # execute node @@ -220,12 +220,12 @@ def test_array_file_contains_file_name(): ], ) - # Create properly configured mock for graph_init_params - graph_init_params = Mock() - graph_init_params.workflow_id = "test_workflow" - graph_init_params.graph_config = {} - graph_init_params.call_depth = 0 - graph_init_params.run_context = { + # Create properly configured mock for init_params + init_params = Mock() + init_params.workflow_id = "test_workflow" + init_params.graph_config = {} + init_params.call_depth = 0 + init_params.run_context = { DIFY_RUN_CONTEXT_KEY: { "tenant_id": "test_tenant", "app_id": "test_app", @@ -235,9 +235,9 @@ def test_array_file_contains_file_name(): } } - node = _build_if_else_node(node_data=node_data, init_params=graph_init_params, graph_runtime_state=Mock()) + node = _build_if_else_node(node_data=node_data, init_params=init_params, runtime_state=Mock()) - node.graph_runtime_state.variable_pool.get.return_value = ArrayFileSegment( + node.runtime_state.variable_pool.get.return_value = ArrayFileSegment( value=[ File( file_type=FileType.IMAGE, @@ -309,10 +309,10 @@ def test_execute_if_else_boolean_conditions(condition: Condition): pool.add(["start", "bool_array"], [True, False, True]) pool.add(["start", "mixed_array"], [True, "false", 1, 0]) - graph_runtime_state = GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=pool, start_at=time.perf_counter()) 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") @@ -325,7 +325,7 @@ def test_execute_if_else_boolean_conditions(condition: Condition): node = _build_if_else_node( node_data=node_data, init_params=init_params, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, ) # execute node @@ -359,10 +359,10 @@ def test_execute_if_else_boolean_false_conditions(): pool.add(["start", "bool_false"], False) pool.add(["start", "bool_array"], [True, False, True]) - graph_runtime_state = GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=pool, start_at=time.perf_counter()) 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") @@ -387,7 +387,7 @@ def test_execute_if_else_boolean_false_conditions(): node = _build_if_else_node( node_data=node_data, init_params=init_params, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, ) # execute node @@ -420,10 +420,10 @@ def test_execute_if_else_boolean_cases_structure(): pool.add(["start", "bool_true"], True) pool.add(["start", "bool_false"], False) - graph_runtime_state = GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=pool, start_at=time.perf_counter()) 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") @@ -452,7 +452,7 @@ def test_execute_if_else_boolean_cases_structure(): node = _build_if_else_node( node_data=node_data, init_params=init_params, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, ) # execute node diff --git a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py index 1b4cecc757d..353b24f92e0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py @@ -19,12 +19,12 @@ from graphon.nodes.list_operator.node import ListOperatorNode, _get_file_extract from graphon.variables import ArrayFileSegment -def _build_list_operator_node(node_data: ListOperatorNodeData, graph_init_params) -> ListOperatorNode: +def _build_list_operator_node(node_data: ListOperatorNodeData, init_params) -> ListOperatorNode: return ListOperatorNode( node_id="test_node_id", data=node_data, - graph_init_params=graph_init_params, - graph_runtime_state=MagicMock(), + init_params=init_params, + runtime_state=MagicMock(), ) @@ -44,12 +44,12 @@ def list_operator_node(): "title": "Test Title", } node_data = ListOperatorNodeData.model_validate(config) - # Create properly configured mock for graph_init_params - graph_init_params = MagicMock() - graph_init_params.workflow_id = "test_workflow" - graph_init_params.graph_config = {} - graph_init_params.call_depth = 0 - graph_init_params.run_context = { + # Create properly configured mock for init_params + init_params = MagicMock() + init_params.workflow_id = "test_workflow" + init_params.graph_config = {} + init_params.call_depth = 0 + init_params.run_context = { DIFY_RUN_CONTEXT_KEY: { "tenant_id": "test_tenant", "app_id": "test_app", @@ -59,9 +59,9 @@ def list_operator_node(): } } - node = _build_list_operator_node(node_data, graph_init_params) - node.graph_runtime_state = MagicMock() - node.graph_runtime_state.variable_pool = MagicMock() + node = _build_list_operator_node(node_data, init_params) + node.runtime_state = MagicMock() + node.runtime_state.variable_pool = MagicMock() return node @@ -98,7 +98,7 @@ def test_filter_files_by_type(list_operator_node): ), ] variable = ArrayFileSegment(value=files) - list_operator_node.graph_runtime_state.variable_pool.get.return_value = variable + list_operator_node.runtime_state.variable_pool.get.return_value = variable # Run the node result = list_operator_node._run() diff --git a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py index f890f795118..ca2cbad0342 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py @@ -8,7 +8,7 @@ from core.workflow.system_variables import build_system_variables from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID from graphon.nodes.start.entities import StartNodeData from graphon.nodes.start.start_node import StartNode -from graphon.runtime import GraphRuntimeState +from graphon.runtime import RuntimeState from graphon.variables import build_segment, segment_to_variable from graphon.variables.input_entities import VariableEntity, VariableEntityType from graphon.variables.variables import Variable @@ -24,7 +24,8 @@ def make_start_node(user_inputs, variables): node_data = StartNodeData(title="Start", variables=variables) - graph_runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=variable_pool, start_at=time.perf_counter(), ) @@ -32,7 +33,7 @@ def make_start_node(user_inputs, variables): return StartNode( node_id="start", data=node_data, - graph_init_params=build_test_graph_init_params( + init_params=build_test_graph_init_params( workflow_id="wf", graph_config={}, tenant_id="tenant", @@ -42,7 +43,7 @@ def make_start_node(user_inputs, variables): invoke_from="debugger", call_depth=0, ), - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, ) @@ -257,11 +258,11 @@ def test_start_node_outputs_full_variable_pool_snapshot(): ], ) - 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()) node = StartNode( node_id="start", data=node_data, - graph_init_params=build_test_graph_init_params( + init_params=build_test_graph_init_params( workflow_id="wf", graph_config={}, tenant_id="tenant", @@ -271,7 +272,7 @@ def test_start_node_outputs_full_variable_pool_snapshot(): invoke_from="debugger", call_depth=0, ), - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, ) result = node._run() diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py index 2e316a6e436..d7cb0098246 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py @@ -18,7 +18,7 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import StreamChunkEvent, StreamCompletedEvent from graphon.nodes.tool.entities import ToolNodeData from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage -from graphon.runtime import GraphRuntimeState +from graphon.runtime import RuntimeState from graphon.variables.segments import ArrayFileSegment from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool @@ -109,7 +109,7 @@ def tool_node(monkeypatch) -> ToolNode: ) variable_pool = build_test_variable_pool(variables=build_system_variables(user_id="user-id")) - graph_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) config = graph_config["nodes"][0] @@ -119,8 +119,8 @@ def tool_node(monkeypatch) -> ToolNode: node = ToolNode( node_id="node-instance", data=ToolNodeData.model_validate(config["data"]), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, tool_file_manager=tool_file_manager, runtime=runtime, ) @@ -243,7 +243,7 @@ def test_tool_node_passes_node_execution_id_when_runtime_accepts_it(tool_node: T tool_node._runtime.get_runtime = MagicMock(return_value=runtime_handle) result = tool_node._get_tool_runtime( - variable_pool=tool_node.graph_runtime_state.variable_pool, + variable_pool=tool_node.runtime_state.variable_pool, node_execution_id="node-execution-id", ) @@ -251,6 +251,6 @@ def test_tool_node_passes_node_execution_id_when_runtime_accepts_it(tool_node: T tool_node._runtime.get_runtime.assert_called_once_with( node_id="node-instance", node_data=tool_node.node_data, - variable_pool=tool_node.graph_runtime_state.variable_pool, + variable_pool=tool_node.runtime_state.variable_pool, node_execution_id="node-execution-id", ) diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_invocation_behavior.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_invocation_behavior.py index 911bfd7fa00..6b617246e2c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_invocation_behavior.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_invocation_behavior.py @@ -14,7 +14,7 @@ from graphon.graph import Graph from graphon.node_events import StreamCompletedEvent from graphon.nodes.tool.entities import ToolNodeData from graphon.nodes.tool.tool_node import ToolNode -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params @@ -49,12 +49,12 @@ def init_tool_node(config: dict[str, object]) -> ToolNode: conversation_variables=[], ) - 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") @@ -64,8 +64,8 @@ def init_tool_node(config: dict[str, object]) -> ToolNode: node = ToolNode( node_id=str(uuid.uuid4()), data=ToolNodeData.model_validate(config["data"]), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, + init_params=init_params, + runtime_state=runtime_state, tool_file_manager=tool_file_manager, runtime=DifyToolNodeRuntime(init_params.run_context), ) @@ -97,7 +97,7 @@ def test_tool_variable_invoke() -> None: "decrypt_tool_parameters", return_value={"format": "%Y-%m-%d %H:%M:%S"}, ): - node.graph_runtime_state.variable_pool.add(["1", "args1"], "1+1") + node.runtime_state.variable_pool.add(["1", "args1"], "1+1") # execute node result = node._run() diff --git a/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py index c5ac8d2ce2d..68f7f30b58e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py @@ -4,19 +4,19 @@ from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData from core.workflow.nodes.trigger_plugin.trigger_event_node import TriggerEventNode from core.workflow.system_variables import build_system_variables -from graphon.entities import GraphInitParams from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus -from graphon.runtime import GraphRuntimeState +from graphon.runtime import InitParams, RuntimeState from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool -def _build_context(graph_config: Mapping[str, object]) -> tuple[GraphInitParams, GraphRuntimeState]: +def _build_context(graph_config: Mapping[str, object]) -> tuple[InitParams, RuntimeState]: init_params = build_test_graph_init_params( graph_config=graph_config, user_from="account", invoke_from="debugger", ) - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=build_test_variable_pool( variables=build_system_variables(user_id="user", files=[]), node_id="node-1", @@ -45,8 +45,8 @@ def test_trigger_event_node_run_populates_trigger_info_metadata() -> None: node = TriggerEventNode( node_id="node-1", data=_build_node_data(), - graph_init_params=init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) result = node._run() diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py index fccb5ab1c3b..42a69f6aa95 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py @@ -18,9 +18,8 @@ from core.workflow.nodes.trigger_webhook.entities import ( ) from core.workflow.nodes.trigger_webhook.node import TriggerWebhookNode from core.workflow.system_variables import default_system_variables -from graphon.entities import GraphInitParams from graphon.enums import WorkflowNodeExecutionStatus -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import InitParams, RuntimeState, VariablePool from tests.workflow_test_utils import build_test_variable_pool @@ -30,7 +29,7 @@ def create_webhook_node( tenant_id: str = "test-tenant", ) -> TriggerWebhookNode: """Helper function to create a webhook node with proper initialization.""" - graph_init_params = GraphInitParams( + init_params = InitParams( workflow_id="test-workflow", graph_config={}, run_context={ @@ -45,7 +44,8 @@ def create_webhook_node( call_depth=0, ) - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=variable_pool, start_at=0, ) @@ -53,8 +53,8 @@ def create_webhook_node( node = TriggerWebhookNode( node_id="webhook-node-1", data=webhook_data, - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) # Attach a lightweight app_config onto runtime state for tenant lookups diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py index d99189c446c..cd0f889650b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py @@ -13,17 +13,16 @@ from core.workflow.nodes.trigger_webhook.entities import ( ) from core.workflow.nodes.trigger_webhook.node import TriggerWebhookNode from core.workflow.system_variables import default_system_variables -from graphon.entities import GraphInitParams from graphon.enums import WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import InitParams, RuntimeState, VariablePool from graphon.variables import FileVariable, StringVariable from tests.workflow_test_utils import build_test_variable_pool def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) -> TriggerWebhookNode: """Helper function to create a webhook node with proper initialization.""" - graph_init_params = GraphInitParams( + init_params = InitParams( workflow_id="1", graph_config={}, run_context={ @@ -37,15 +36,16 @@ def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) }, call_depth=0, ) - runtime_state = GraphRuntimeState( + runtime_state = RuntimeState( + workflow_id="test-workflow", variable_pool=variable_pool, start_at=0, ) node = TriggerWebhookNode( node_id="1", data=webhook_data, - graph_init_params=graph_init_params, - graph_runtime_state=runtime_state, + init_params=init_params, + runtime_state=runtime_state, ) # Provide tenant_id for conversion path diff --git a/api/tests/unit_tests/core/workflow/test_graphon_v08_contract.py b/api/tests/unit_tests/core/workflow/test_graphon_v08_contract.py new file mode 100644 index 00000000000..1e48f274493 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_graphon_v08_contract.py @@ -0,0 +1,578 @@ +"""Release migration behavior: real frame tasks and pure scoped factory validation.""" + +from copy import deepcopy +from unittest.mock import Mock + +import pytest + +from graphon.enums import NodeExecutionType +from graphon.nodes.container_effects import ContainerExecutionResult, ContainerNodeRunResult +from graphon.runtime import InitParams, RuntimeState, VariablePool +from graphon.runtime.execution import GraphExecution +from graphon.runtime.ready_queue import InMemoryReadyQueue, ResumeTask, StartTask + + +@pytest.fixture(autouse=True) +def no_network_io(monkeypatch): + import socket + + connect = Mock(side_effect=AssertionError("network I/O is forbidden in migration contracts")) + monkeypatch.setattr(socket.socket, "connect", connect) + yield + connect.assert_not_called() + + +def test_stop_queue_preserves_frame_and_invocation_tasks(monkeypatch): + from core.app.apps.workflow.stop_aware_ready_queue import StopAwareReadyQueue + + monkeypatch.setattr("core.app.apps.workflow.stop_aware_ready_queue.is_app_task_stop_flag_set", lambda _: False) + queue = StopAwareReadyQueue(InMemoryReadyQueue(), task_id="task", graph_execution=GraphExecution(workflow_id="wf")) + tasks = [ + StartTask(node_id="child", frame_id="frame"), + ResumeTask( + invocation_id="invocation", + result=ContainerExecutionResult(metadata={}, steps=1, node_run_result=ContainerNodeRunResult()), + ), + ] + for task in tasks: + queue.put(task) + assert queue.take_all() == tasks + + +def test_scoped_factory_validation_never_initializes_collaborators(monkeypatch): + from core.workflow.node_factory import DifyNodeFactory + + # Construct the owner once, then prohibit all constructor re-entry during scoping. + factory = object.__new__(DifyNodeFactory) + factory.init_params = InitParams( + workflow_id="wf", graph_config={"nodes": [], "edges": []}, run_context={}, call_depth=0 + ) + factory.runtime_state = RuntimeState(variable_pool=VariablePool(), start_at=0, workflow_id="wf") + factory._dify_context = Mock(builder_execution=None) + factory._native_runtime = None + factory.execution_recorder = object() + factory._request_hmac_key = b"host-owned-secret" + monkeypatch.setattr(DifyNodeFactory, "__init__", Mock(side_effect=AssertionError("runtime initialized"))) + original = deepcopy(factory.init_params.graph_config) + config = {"nodes": [{"id": "start", "data": {"type": "start", "title": "Start", "variables": []}}], "edges": []} + scoped = factory.with_graph_config(config) + assert scoped is not factory + assert scoped.init_params is not factory.init_params + assert factory.init_params.graph_config == original + assert scoped.init_params.graph_config == config + assert scoped.execution_recorder is factory.execution_recorder + assert scoped._request_hmac_key is factory._request_hmac_key + assert scoped.validate_node(config["nodes"][0]) == NodeExecutionType.ROOT + with pytest.raises(ValueError): + scoped.validate_node({"id": "bad", "data": {"type": "unknown-node", "title": "Bad"}}) + + +def test_pause_event_defers_snapshot_until_outer_filter_flush(): + from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity + from core.app.layers.pause_state_persist_layer import PauseStatePersistenceLayer + from graphon.engine.command import InMemoryChannel + from graphon.engine.filter import ResponseStreamFilter + from graphon.engine_events import GraphRunPausedEvent + + state = Mock() + state.dumps.side_effect = AssertionError("snapshot while engine is active") + layer = PauseStatePersistenceLayer( + session_factory=Mock(), + generate_entity=WorkflowAppGenerateEntity.model_construct(), + state_owner_user_id="user", + response_stream_filter=ResponseStreamFilter(), + ) + layer.initialize(state, InMemoryChannel()) + layer.on_event(GraphRunPausedEvent()) + layer.on_graph_end(None) + state.dumps.assert_not_called() + + +def test_ordinary_workflow_uses_official_engine_without_external_io(): + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom + from core.workflow.node_factory import DifyNodeFactory + from core.workflow.workflow_entry import WorkflowEntry + from graphon.engine_events import GraphRunSucceededEvent + from graphon.graph import Graph + from tests.workflow_test_utils import build_test_graph_init_params + + config = { + "nodes": [ + {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}}, + {"id": "end", "data": {"type": "end", "title": "End", "outputs": []}}, + ], + "edges": [ + {"id": "start-end", "source": "start", "target": "end", "sourceHandle": "source", "targetHandle": "target"} + ], + } + state = RuntimeState(variable_pool=VariablePool(), start_at=0, workflow_id="wf") + factory = DifyNodeFactory( + init_params=build_test_graph_init_params(workflow_id="wf", graph_config=config), runtime_state=state + ) + graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="start") + assert graph is not None + entry = WorkflowEntry( + tenant_id="tenant", + app_id="app", + workflow_id="wf", + graph_config=config, + graph=graph, + user_id="user", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + call_depth=0, + variable_pool=state.variable_pool, + runtime_state=state, + ) + assert any(isinstance(event, GraphRunSucceededEvent) for event in entry.run()) + assert state.graph_execution.workflow_id == "wf" + + +def test_explicit_file_scope_is_reentrant_and_restores_previous_adapter(): + from core.app.workflow.file_runtime import DifyWorkflowFileRuntime, bind_dify_workflow_file_runtime + from graphon.file.runtime import peek_workflow_file_runtime + + previous = peek_workflow_file_runtime() + with bind_dify_workflow_file_runtime(): + first = peek_workflow_file_runtime() + assert isinstance(first, DifyWorkflowFileRuntime) + with bind_dify_workflow_file_runtime(): + assert peek_workflow_file_runtime() is not first + assert peek_workflow_file_runtime() is first + assert peek_workflow_file_runtime() is previous + + +def test_pause_barrier_flushes_before_persisting_and_exposing_pause(): + from graphon.engine_events import GraphRunPausedEvent, GraphRunStartedEvent + from services.workflow.execution_lifecycle import settled_workflow_events + + order = [] + + def filtered_events(): + yield GraphRunStartedEvent() + yield GraphRunPausedEvent() + order.append("flush") + + stream = settled_workflow_events(filtered_events(), pause_callbacks=[lambda: order.append("persist")]) + next(stream) + assert order == [] + assert isinstance(next(stream), GraphRunPausedEvent) + assert order == ["flush", "persist"] + + +def test_pause_barrier_storage_failure_never_exposes_resumable_pause(): + from graphon.engine_events import GraphRunPausedEvent + from services.workflow.execution_lifecycle import settled_workflow_events + + def failure(): + raise OSError("storage unavailable") + + with pytest.raises(OSError, match="storage unavailable"): + list(settled_workflow_events([GraphRunPausedEvent()], pause_callbacks=[failure])) + + +def test_nested_ancestry_uses_actual_direct_container_and_retains_occurrence_id(): + from datetime import UTC, datetime + + from graphon.engine_events import NodeRunSucceededEvent + from services.workflow.event_ancestry import WorkflowEventAncestry + + ancestry = WorkflowEventAncestry( + { + "nodes": [ + {"id": "outer", "data": {"type": "iteration", "container_id": ""}}, + {"id": "inner", "data": {"type": "loop", "container_id": "outer"}}, + {"id": "child", "data": {"type": "end", "container_id": "inner"}}, + ] + } + ) + first = NodeRunSucceededEvent( + id="occurrence-a", node_id="child", node_type="end", container_id="inner", start_at=datetime.now(UTC) + ) + second = first.model_copy(update={"id": "occurrence-b"}) + projected = ancestry.resolve(first.container_id) + assert projected.iteration_id == "outer" + assert projected.loop_id == "inner" + assert ancestry.resolve(second.container_id) == projected + assert first.id != second.id + assert ancestry.resolve("").iteration_id is None + + +def test_real_engine_pause_snapshot_follows_quiescence_and_outer_filter_flush(): + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom + from core.app.layers.pause_state_persist_layer import PauseStatePersistenceLayer + from core.workflow.node_factory import DifyNodeFactory + from core.workflow.workflow_entry import WorkflowEntry + from graphon.engine.command import PauseCommand + from graphon.engine.filter import ResponseStreamFilter + from graphon.engine.layer import Layer + from graphon.engine_events import GraphRunPausedEvent + from graphon.graph import Graph + from tests.workflow_test_utils import build_test_graph_init_params + + config = { + "nodes": [ + {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}}, + {"id": "end", "data": {"type": "end", "title": "End", "outputs": []}}, + ], + "edges": [ + {"id": "start-end", "source": "start", "target": "end", "sourceHandle": "source", "targetHandle": "target"} + ], + } + order = [] + + class PauseBeforeWorkers(Layer): + def on_graph_start(self): + self.command_channel.send_command(PauseCommand(reason="test")) + + def on_graph_end(self, _error): + order.append("quiescent") + + class FlushRecorder(ResponseStreamFilter): + def flush(self): + result = list(super().flush()) + order.append("flush") + return result + + state = RuntimeState(variable_pool=VariablePool(), start_at=0, workflow_id="wf") + factory = DifyNodeFactory( + init_params=build_test_graph_init_params(workflow_id="wf", graph_config=config), runtime_state=state + ) + graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="start") + response_filter = FlushRecorder() + entry = WorkflowEntry( + tenant_id="tenant", + app_id="app", + workflow_id="wf", + graph_config=config, + graph=graph, + user_id="user", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + call_depth=0, + variable_pool=state.variable_pool, + runtime_state=state, + response_stream_filter=response_filter, + ) + entry.add_layer(PauseBeforeWorkers()) + snapshots = [] + + class SnapshotProbe(PauseStatePersistenceLayer): + def persist_paused_state(self): + assert order == ["quiescent", "flush"] + snapshots.append((self.runtime_state.dumps(), response_filter.dumps())) + order.append("persist") + + probe = object.__new__(SnapshotProbe) + Layer.__init__(probe) + probe._paused_event = None + entry.add_layer(probe) + events = list(entry.run()) + assert isinstance(events[-1], GraphRunPausedEvent) + assert order == ["quiescent", "flush", "persist"] + assert len(snapshots) == 1 + assert entry.graph_engine.file_runtime is not None + + # Rebuild from the exact emitted snapshot and bind a fresh host adapter on resume. + restored = RuntimeState.from_snapshot(snapshots[0][0]) + restored_filter = ResponseStreamFilter() + restored_filter.loads(snapshots[0][1]) + restored_factory = DifyNodeFactory( + init_params=build_test_graph_init_params(workflow_id="wf", graph_config=config), runtime_state=restored + ) + restored_graph = Graph.init(graph_config=config, node_factory=restored_factory, root_node_id="start") + resumed = WorkflowEntry( + tenant_id="tenant", + app_id="app", + workflow_id="wf", + graph_config=config, + graph=restored_graph, + user_id="user", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + call_depth=0, + variable_pool=restored.variable_pool, + runtime_state=restored, + response_stream_filter=restored_filter, + ) + seen_adapters = [] + + class ObserveFiles(Layer): + def on_node_run_start(self, _node): + from graphon.file.runtime import peek_workflow_file_runtime + + seen_adapters.append(peek_workflow_file_runtime()) + + resumed.add_layer(ObserveFiles()) + from graphon.engine_events import GraphRunSucceededEvent + + assert isinstance(list(resumed.run())[-1], GraphRunSucceededEvent) + assert resumed.graph_engine.file_runtime is not entry.graph_engine.file_runtime + assert seen_adapters + assert all(adapter is resumed.graph_engine.file_runtime for adapter in seen_adapters) + + +def test_host_context_does_not_replace_engine_file_adapter_on_any_segment(): + from core.app.workflow.file_runtime import DifyWorkflowFileRuntime + from graphon.file.runtime import peek_workflow_file_runtime, use_workflow_file_runtime + from services.workflow.execution_lifecycle import WorkflowExecutionContextLayer + + # Request context is captured before the engine (or resumed engine) chooses its adapter. + layer = WorkflowExecutionContextLayer() + for _ in range(2): + adapter = DifyWorkflowFileRuntime(file_access_controller=Mock()) + with use_workflow_file_runtime(adapter): + with layer.node_run_context(Mock()): + assert peek_workflow_file_runtime() is adapter + + +@pytest.mark.parametrize("flask_capture", [False, True]) +def test_historical_v2_loop_snapshot_resumes_with_dify_scoped_factory(monkeypatch, flask_capture): + import json + from pathlib import Path + + from core.workflow.node_factory import DifyNodeFactory + from graphon.engine import Engine + from graphon.engine_events import GraphRunSucceededEvent, NodeRunSucceededEvent + from graphon.graph import Graph + from graphon.nodes.human_input.entities import Completed + from graphon.variables.segments import StringSegment + from tests.workflow_test_utils import build_test_graph_init_params + + snapshot = ( + Path(__file__) + .with_name("fixtures") + .joinpath("graphon_history/runtime_state_v2_1d0f32c_paused_loop.json") + .read_text() + ) + assert json.loads(snapshot)["container_frames"] + config = { + "nodes": [ + {"id": "start", "data": {"type": "start", "variables": []}}, + { + "id": "loop", + "data": { + "type": "loop", + "title": "Three rounds", + "loop_count": 3, + "start_node_id": "loop-start", + "break_conditions": [], + "logical_operator": "and", + "loop_variables": [ + {"label": "seed", "var_type": "string", "value_type": "constant", "value": "fixed"} + ], + }, + }, + {"id": "loop-start", "data": {"type": "loop-start", "loop_id": "loop"}}, + {"id": "human-input", "data": {"type": "human-input", "title": "Approve loop round", "loop_id": "loop"}}, + { + "id": "end", + "data": { + "type": "end", + "outputs": [ + {"variable": "rounds", "value_selector": ["loop", "loop_round"]}, + {"variable": "seed", "value_selector": ["loop", "seed"]}, + ], + }, + }, + ], + "edges": [ + {"id": "start-to-loop", "source": "start", "target": "loop"}, + {"id": "loop-start-to-human-input", "source": "loop-start", "target": "human-input"}, + {"id": "loop-to-end", "source": "loop", "target": "end"}, + ], + } + observed = [] + + def complete(context): + observed.append(context.node_id) + return Completed(selected_handle="source", inputs={}, outputs={"answer": StringSegment(value="approved")}) + + monkeypatch.setattr(DifyNodeFactory, "_build_human_input_callback", lambda *_args, **_kwargs: complete) + state = RuntimeState.from_snapshot(snapshot) + factory = DifyNodeFactory( + init_params=build_test_graph_init_params(workflow_id="workflow", graph_config=config), runtime_state=state + ) + graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="start") + import contextvars + + from flask import Flask + + from context.execution_context import ExecutionContext + from context.flask_app_context import FlaskExecutionContext + from core.app.workflow.file_runtime import DifyWorkflowFileRuntime + from graphon.engine.layer import Layer + from graphon.file.runtime import peek_workflow_file_runtime, use_workflow_file_runtime + from services.workflow.execution_lifecycle import WorkflowExecutionContextLayer + + host = DifyWorkflowFileRuntime(file_access_controller=Mock()) + fresh = DifyWorkflowFileRuntime(file_access_controller=Mock()) + with use_workflow_file_runtime(host): + captured = contextvars.copy_context() + capture = ( + FlaskExecutionContext(Flask(__name__), captured) if flask_capture else ExecutionContext(context_vars=captured) + ) + monkeypatch.setattr("services.workflow.execution_lifecycle.capture_current_context", lambda: capture) + seen = [] + + class Observe(Layer): + def on_node_run_start(self, node): + seen.append((node.id, peek_workflow_file_runtime() is fresh)) + + engine = Engine(graph=graph, runtime_state=state, workers=1, file_runtime=fresh) + engine.add_layer(WorkflowExecutionContextLayer()) + engine.add_layer(Observe()) + with use_workflow_file_runtime(None): + events = list(engine.run()) + assert peek_workflow_file_runtime() is None + assert sum(node_id == "human-input" for node_id, _ in seen) == 2 + assert all(is_fresh for _, is_fresh in seen) + assert isinstance(events[-1], GraphRunSucceededEvent) + assert events[-1].outputs == {"rounds": 3, "seed": "fixed"} + children = [ + event for event in events if isinstance(event, NodeRunSucceededEvent) and event.node_id == "human-input" + ] + assert observed == ["human-input", "human-input"] + assert [event.container_id for event in children] == ["loop", "loop"] + assert len({event.id for event in children}) == 2 + assert json.loads(state.dumps())["version"] == "3.0" + + +def test_historical_v1_response_filter_bytes_are_accepted_without_rewriting(): + import hashlib + import json + from pathlib import Path + + from graphon.engine.filter import ResponseStreamFilter + + directory = Path(__file__).with_name("fixtures") / "graphon_history" + manifest = json.loads((directory / "PROVENANCE.json").read_text()) + for name, receipt in manifest["fixtures"].items(): + data = (directory / name).read_bytes() + assert hashlib.sha256(data).hexdigest() == receipt["sha256"] + if name.startswith("response_stream_filter"): + response_filter = ResponseStreamFilter() + response_filter.loads(data.decode()) + with pytest.raises(RuntimeError, match="must be initialized before serialization"): + response_filter.dumps() + + +def test_historical_v1_filter_binds_original_edge_position_to_public_id(): + import json + from pathlib import Path + + from core.workflow.node_factory import DifyNodeFactory + from graphon.engine import Engine + from graphon.engine.filter import EngineEventFilterContext, ResponseStreamFilter + from graphon.graph import Graph + from tests.workflow_test_utils import build_test_graph_init_params + + config = { + "nodes": [ + {"id": "source", "data": {"type": "start", "variables": []}}, + {"id": "answer", "data": {"type": "answer", "answer": "{{#source.answer#}}"}}, + ], + "edges": [{"id": "public-response-edge", "source": "source", "target": "answer"}], + } + state = RuntimeState(variable_pool=VariablePool(), start_at=0, workflow_id="wf") + factory = DifyNodeFactory( + init_params=build_test_graph_init_params(workflow_id="wf", graph_config=config), runtime_state=state + ) + graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="source") + response_filter = ResponseStreamFilter() + response_filter.loads( + Path(__file__) + .with_name("fixtures") + .joinpath("graphon_history/response_stream_filter_v1_c3870df_simple.json") + .read_text() + ) + response_filter.initialize(EngineEventFilterContext.from_engine(Engine(graph=graph, runtime_state=state))) + migrated = json.loads(response_filter.dumps()) + assert migrated["version"] == "2.0" + assert migrated["paths_map"] == {"answer": [["public-response-edge"]]} + + +def test_legacy_v1_child_tasks_fail_closed_without_fabricating_frames(): + """Explicit v1 format contract; this constructed payload is not a historical fixture.""" + import json + + from core.workflow.node_factory import DifyNodeFactory + from graphon.engine import Engine + from graphon.graph import Graph + from graphon.model_runtime.entities.llm_entities import LLMUsage + from tests.workflow_test_utils import build_test_graph_init_params + + payload = { + "version": "1.0", + "start_at": 0, + "total_tokens": 0, + "node_run_steps": 0, + "llm_usage": LLMUsage.empty_usage().model_dump(mode="json"), + "outputs": {}, + "variable_pool": {"variable_dictionary": {}}, + "ready_queue": json.dumps({"type": "InMemoryReadyQueue", "version": "1.0", "items": []}), + "graph_execution": json.dumps( + { + "type": "GraphExecution", + "version": "1.0", + "workflow_id": "wf", + "started": True, + "completed": False, + "aborted": False, + "paused": True, + "pause_reasons": [], + "error": None, + "exceptions_count": 0, + "node_executions": [], + } + ), + "paused_nodes": ["child-without-frame"], + "deferred_nodes": [], + "graph_state": { + "nodes": {"start": "unknown", "end": "unknown", "child-without-frame": "unknown"}, + "edges": {"edge_0": "unknown"}, + }, + } + config = { + "nodes": [ + {"id": "start", "data": {"type": "start", "variables": []}}, + {"id": "end", "data": {"type": "end", "outputs": []}}, + ], + "edges": [{"id": "public-edge", "source": "start", "target": "end"}], + } + state = RuntimeState.from_snapshot(json.dumps(payload)) + factory = DifyNodeFactory( + init_params=build_test_graph_init_params(workflow_id="wf", graph_config=config), runtime_state=state + ) + graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="start") + with pytest.raises(RuntimeError, match="child-frame tasks that cannot be restored without frame state"): + Engine(graph=graph, runtime_state=state) + assert state.take_deferred_ready_tasks() == [StartTask(frame_id="root", node_id="child-without-frame")] + + +def test_single_node_execution_binds_an_explicit_file_adapter(monkeypatch): + from core.app.workflow.file_runtime import DifyWorkflowFileRuntime + from core.workflow.node_factory import DifyNodeFactory + from core.workflow.workflow_entry import WorkflowEntry + from graphon.file.runtime import peek_workflow_file_runtime, use_workflow_file_runtime + from graphon.node_events import NodeRunResult + from tests.workflow_test_utils import build_test_graph_init_params + + config = {"nodes": [{"id": "start", "data": {"type": "start", "variables": []}}], "edges": []} + state = RuntimeState(variable_pool=VariablePool(), start_at=0, workflow_id="wf") + factory = DifyNodeFactory( + init_params=build_test_graph_init_params(workflow_id="wf", graph_config=config), runtime_state=state + ) + node = factory.create_node(config["nodes"][0]) + adapters = [] + + def run(): + adapters.append(peek_workflow_file_runtime()) + return NodeRunResult() + + monkeypatch.setattr(node, "_run", run) + with use_workflow_file_runtime(None): + list(WorkflowEntry._run_node_with_layers(node, tenant_id="tenant")) + assert peek_workflow_file_runtime() is None + assert len(adapters) == 1 + assert isinstance(adapters[0], DifyWorkflowFileRuntime) diff --git a/api/tests/unit_tests/core/workflow/test_graphon_v08_scope_regressions.py b/api/tests/unit_tests/core/workflow/test_graphon_v08_scope_regressions.py new file mode 100644 index 00000000000..631a80acec9 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_graphon_v08_scope_regressions.py @@ -0,0 +1,550 @@ +"""Production boundary regressions; an outer None defeats the legacy autouse adapter.""" + +import contextvars +import socket +from unittest.mock import Mock + +import pytest +from flask import Flask + +from context.execution_context import ExecutionContext +from context.flask_app_context import FlaskExecutionContext +from core.app.workflow.file_runtime import DifyWorkflowFileRuntime +from graphon.file.runtime import peek_workflow_file_runtime, use_workflow_file_runtime + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch): + connect = Mock(side_effect=AssertionError("unexpected network")) + monkeypatch.setattr(socket.socket, "connect", connect) + yield + connect.assert_not_called() + + +@pytest.mark.parametrize("flask", [False, True]) +@pytest.mark.parametrize("failure", [False, True]) +@pytest.mark.parametrize("direct", [False, True]) +def test_capture_restores_worker_context_and_adapter(flask, failure, direct): + marker = contextvars.ContextVar("segment_marker", default="worker") + captured = contextvars.Context() + captured.run(marker.set, "host") + host_adapter = DifyWorkflowFileRuntime(file_access_controller=Mock()) + with use_workflow_file_runtime(host_adapter): + captured = contextvars.copy_context() + captured.run(marker.set, "host") + capture = FlaskExecutionContext(Flask(__name__), captured) if flask else ExecutionContext(context_vars=captured) + + def run_segment(): + with capture if direct else capture.enter(): + assert marker.get() == "host" + assert peek_workflow_file_runtime() is host_adapter + if failure: + raise ValueError("segment failed") + + with use_workflow_file_runtime(None): + for _ in range(2): + if failure: + with pytest.raises(ValueError, match="segment failed"): + run_segment() + else: + run_segment() + assert marker.get() == "worker" + assert peek_workflow_file_runtime() is None + + +@pytest.mark.parametrize("flask", [False, True]) +def test_actual_single_worker_keeps_engine_adapter_for_second_node(monkeypatch, flask): + from core.workflow.node_factory import DifyNodeFactory + from graphon.engine import Engine + from graphon.engine.layer import Layer + from graphon.engine_events import GraphRunSucceededEvent + from graphon.graph import Graph + from graphon.runtime import RuntimeState, VariablePool + from services.workflow.execution_lifecycle import WorkflowExecutionContextLayer + from tests.workflow_test_utils import build_test_graph_init_params + + host = DifyWorkflowFileRuntime(file_access_controller=Mock()) + adapter = DifyWorkflowFileRuntime(file_access_controller=Mock()) + with use_workflow_file_runtime(host): + captured = contextvars.copy_context() + capture = FlaskExecutionContext(Flask(__name__), captured) if flask else ExecutionContext(context_vars=captured) + monkeypatch.setattr("services.workflow.execution_lifecycle.capture_current_context", lambda: capture) + config = { + "nodes": [ + {"id": "start", "data": {"type": "start", "variables": []}}, + {"id": "end", "data": {"type": "end", "outputs": []}}, + ], + "edges": [{"id": "start-end", "source": "start", "target": "end"}], + } + state = RuntimeState(variable_pool=VariablePool(), start_at=0, workflow_id="wf") + factory = DifyNodeFactory( + init_params=build_test_graph_init_params(workflow_id="wf", graph_config=config), runtime_state=state + ) + graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="start") + seen = [] + + class Observe(Layer): + def on_node_run_start(self, node): + seen.append((node.id, peek_workflow_file_runtime() is adapter)) + + engine = Engine(graph=graph, runtime_state=state, workers=1, file_runtime=adapter) + engine.add_layer(WorkflowExecutionContextLayer()) + engine.add_layer(Observe()) + with use_workflow_file_runtime(None): + events = list(engine.run()) + assert peek_workflow_file_runtime() is None + assert isinstance(events[-1], GraphRunSucceededEvent) + assert seen == [("start", True), ("end", True)] + + +@pytest.mark.parametrize("kind", ["file", "image"]) +def test_public_preview_checks_real_signature_without_ambient_adapter(kind): + from services.upload_file_delivery_service import UploadFileDeliveryNotFoundError, UploadFileDeliveryService + + files, storage = Mock(), Mock() + service = UploadFileDeliveryService(files=files, storage=storage) + with use_workflow_file_runtime(None): + with pytest.raises(UploadFileDeliveryNotFoundError): + getattr(service, f"get_signed_{kind}_preview")(file_id="file", timestamp="1", nonce="n", sign="invalid") + assert peek_workflow_file_runtime() is None + files.get_by_id.assert_not_called() + storage.load_stream.assert_not_called() + + +@pytest.mark.parametrize("failure", [False, True]) +def test_dify_application_context_binds_rendering_for_request_and_background(failure): + from dify_app import DifyApp + from graphon.file import File, FileTransferMethod, FileType + + app = DifyApp(__name__) + remote = File( + type=FileType.IMAGE, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.invalid/f.png", + extension=".png", + mime_type="image/png", + ) + + def render(): + with app.app_context(): + assert remote.to_dict()["url"] == remote.remote_url + if failure: + raise ValueError("render failed") + + with use_workflow_file_runtime(None): + if failure: + with pytest.raises(ValueError, match="render failed"): + render() + else: + render() + assert peek_workflow_file_runtime() is None + + +@pytest.mark.parametrize("ending", ["exhaust", "close", "error"]) +def test_lazy_file_response_keeps_owner_until_iteration_and_cleanup(ending): + from core.app.apps.base_app_generator import BaseAppGenerator + from core.app.entities.app_invoke_entities import InvokeFrom + from core.app.file_access import get_current_file_access_scope + from graphon.file import File, FileTransferMethod, FileType + from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter + from models import Account + + remote = File( + type=FileType.IMAGE, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.invalid/f.png", + extension=".png", + mime_type="image/png", + ) + user = Account(name="Scope", email="scope@example.invalid") + user.id = "account" + observed = [] + thread = Mock() + thread.is_alive.return_value = False + + def response(): + try: + observed.append(get_current_file_access_scope()) + yield WorkflowRuntimeTypeConverter().to_json_encodable(remote) + if ending == "error": + raise ValueError("response failed") + yield WorkflowRuntimeTypeConverter().to_json_encodable(remote) + finally: + observed.append(get_current_file_access_scope()) + assert remote.to_dict()["url"] == remote.remote_url + + with use_workflow_file_runtime(None): + with BaseAppGenerator._bind_file_access_scope(tenant_id="tenant", user=user, invoke_from=InvokeFrom.DEBUGGER): + owner = get_current_file_access_scope() + stream = BaseAppGenerator._wrap_stream_with_worker_thread_join(response(), thread) + assert peek_workflow_file_runtime() is None + assert next(stream)["url"] == remote.remote_url + if ending == "close": + stream.close() + elif ending == "error": + with pytest.raises(ValueError, match="response failed"): + next(stream) + else: + assert len(list(stream)) == 1 + assert peek_workflow_file_runtime() is None + assert get_current_file_access_scope() is None + assert observed == [owner, owner] + thread.join.assert_called_once() + + +@pytest.mark.parametrize("wrapped", [False, True]) +def test_json_prevalidation_compares_original_binding_before_dto_adaptation(wrapped): + import json + from copy import deepcopy + from types import SimpleNamespace + + from core.workflow.node_factory import DifyNodeFactory + from services.dify_builder.json_tool_execution import admit_json_workflow + from tests.unit_tests.services.dify_builder.test_json_tool_execution import snapshot_for + from tests.unit_tests.services.dify_builder.test_json_tool_runtime import EXPECTED, GRAPH, _context, _installation + + graph = json.loads(GRAPH.read_text()) + graph["nodes"][1]["data"]["tool_configurations"]["ensure_ascii"] = ( + {"type": "constant", "value": True} if wrapped else True + ) + context = _context(_installation(), EXPECTED).model_copy( + update={"admitted_nodes": admit_json_workflow(snapshot_for(graph)).nodes} + ) + factory = object.__new__(DifyNodeFactory) + factory._dify_context = SimpleNamespace(builder_execution=context) + factory._admitted_graph_config = deepcopy(graph) + for node in graph["nodes"]: + candidate = deepcopy(node) + candidate["data"]["container_id"] = "" + factory.validate_node(candidate) + + +@pytest.mark.parametrize("kind", ["file", "image"]) +def test_valid_public_preview_retains_normal_delivery_without_ambient_adapter(kind): + from services.upload_file_delivery_service import UploadFileDeliveryRecord, UploadFileDeliveryService + + files, storage = Mock(), Mock() + files.get_by_id.return_value = UploadFileDeliveryRecord("key", "file.png", 4, "png", "image/png") + storage.load_stream.return_value = iter([b"data"]) + service = UploadFileDeliveryService(files=files, storage=storage) + signature = DifyWorkflowFileRuntime(file_access_controller=Mock())._sign_query(payload=f"{kind}-preview|file") + with use_workflow_file_runtime(None): + result = getattr(service, f"get_signed_{kind}_preview")(file_id="file", **signature) + assert b"".join(result.content) == b"data" + assert peek_workflow_file_runtime() is None + files.get_by_id.assert_called_once_with(file_id="file") + + +@pytest.mark.parametrize("boundary", ["icon", "avatar", "avatar_gateway", "file_service"]) +def test_direct_host_signing_boundary_needs_no_ambient_runtime(boundary, monkeypatch): + from unittest.mock import MagicMock + + from libs.helper import build_avatar_url, build_icon_url + from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway + from services.file_service import FileService + + sessions = MagicMock() + sessions.return_value.__enter__.return_value.scalar.return_value = "file" + with use_workflow_file_runtime(None): + if boundary == "icon": + url = build_icon_url("image", "file") + elif boundary == "avatar": + url = build_avatar_url("file") + elif boundary == "avatar_gateway": + url = SQLAlchemyAccountAvatarFileGateway(session_factory=sessions).get_owned_signed_url( + account_id="actor", upload_file_id="file" + ) + else: + service = object.__new__(FileService) + service._session_maker = sessions + monkeypatch.setattr(service, "get_upload_file_by_id", lambda *_a, **_k: Mock()) + url = service.get_icon_url("file", "tenant") + assert "/files/file/file-preview?" in url + assert peek_workflow_file_runtime() is None + + +def test_request_rendering_and_teardown_restore_none_even_on_failure(): + from dify_app import DifyApp + from graphon.file import File, FileTransferMethod, FileType + from libs.helper import build_icon_url + + app = DifyApp(__name__) + remote = File( + type=FileType.IMAGE, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.invalid/f.png", + extension=".png", + mime_type="image/png", + ) + seen = [] + + @app.get("/render") + def render(): + return {"file": remote.to_dict(), "icon": build_icon_url("image", "file")} + + @app.get("/fail") + def fail(): + raise ValueError("request failed") + + @app.teardown_appcontext + def teardown(_error): + seen.append(remote.to_dict()["url"]) + + with use_workflow_file_runtime(None): + response = app.test_client().get("/render") + assert response.status_code == 200 + assert response.json["file"]["url"] == remote.remote_url + assert peek_workflow_file_runtime() is None + assert app.test_client().get("/fail").status_code == 500 + assert peek_workflow_file_runtime() is None + assert seen == [remote.remote_url, remote.remote_url] + + +def test_background_summary_keeps_real_image_conversion_without_ambient_runtime(monkeypatch): + from configs import dify_config + from core.rag.index_processor.processor import paragraph_index_processor as module + from graphon.file import File, FileTransferMethod, FileType + from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage + from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, ImagePromptMessageContent + from graphon.model_runtime.entities.model_entities import ModelFeature + + model = Mock() + model.model_type_instance.get_model_schema.return_value.features = [ModelFeature.VISION] + model.invoke_llm.return_value = LLMResult( + model="test", + prompt_messages=[], + message=AssistantPromptMessage(content="summary"), + usage=LLMUsage.empty_usage(), + ) + manager = Mock() + manager.get_model_instance.return_value = model + monkeypatch.setattr(module.ModelManager, "for_tenant", lambda **_kwargs: manager) + monkeypatch.setattr(dify_config, "MULTIMODAL_SEND_FORMAT", "url") + remote = File( + type=FileType.IMAGE, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.invalid/f.png", + extension=".png", + mime_type="image/png", + ) + with use_workflow_file_runtime(None): + summary, _ = module.ParagraphIndexProcessor.generate_summary_from_inputs( + tenant_id="tenant", + text="text", + summary_index_setting={"enable": True, "model_name": "test", "model_provider_name": "test"}, + document_language=None, + image_loader=lambda: [remote], + ) + assert summary == "summary" + assert peek_workflow_file_runtime() is None + content = model.invoke_llm.call_args.kwargs["prompt_messages"][0].content + assert isinstance(content, list) + assert isinstance(content[0], ImagePromptMessageContent) + assert content[0].url == remote.remote_url + + +@pytest.mark.parametrize("case", ["root", "container"]) +@pytest.mark.parametrize("resume_owner", ["entry", "runner"]) +def test_actual_dify_v07_envelope_resumes_exact_graph_through_entry(case, resume_owner, monkeypatch): + import hashlib + import json + from pathlib import Path + + from core.app.entities.app_invoke_entities import UserFrom + from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext + from core.workflow.node_factory import DifyNodeFactory + from core.workflow.workflow_entry import WorkflowEntry + from graphon.engine.layer import Layer + from graphon.engine_events import GraphRunSucceededEvent, NodeRunStreamChunkEvent, NodeRunSucceededEvent + from graphon.graph import Graph + from graphon.nodes.human_input.entities import Completed + from graphon.runtime import RuntimeState + from tests.workflow_test_utils import build_test_graph_init_params + + directory = Path(__file__).with_name("fixtures") / "dify_v07_history" + provenance = json.loads((directory / "PROVENANCE.json").read_text()) + for name, digest in provenance["files"].items(): + assert hashlib.sha256((directory / name).read_bytes()).hexdigest() == digest + context = WorkflowResumptionContext.loads((directory / f"{case}.envelope.json").read_text()) + entity = context.get_generate_entity() + graph_config = json.loads((directory / f"{case}.graph.json").read_text()) + state = RuntimeState.from_snapshot(context.serialized_graph_runtime_state) + response_filter = context.get_response_stream_filter() + completed = [] + + def complete(ctx): + completed.append(ctx.node_id) + from core.workflow.nodes.human_input.constants import ( + OUTPUT_FIELD_ACTION_ID, + OUTPUT_FIELD_ACTION_VALUE, + OUTPUT_FIELD_RENDERED_CONTENT, + ) + from graphon.variables import build_segment + + return Completed( + selected_handle="source", + inputs={}, + outputs={ + key: build_segment(value) + for key, value in [ + (OUTPUT_FIELD_ACTION_ID, "source"), + (OUTPUT_FIELD_ACTION_VALUE, "Approved"), + (OUTPUT_FIELD_RENDERED_CONTENT, "Form"), + ] + }, + ) + + monkeypatch.setattr(DifyNodeFactory, "_build_human_input_callback", lambda *_a, **_k: complete) + factory = DifyNodeFactory( + init_params=build_test_graph_init_params( + workflow_id="workflow", graph_config=graph_config, tenant_id="tenant", app_id="app", user_id="user" + ), + runtime_state=state, + ) + graph = Graph.init(graph_config=graph_config, node_factory=factory, root_node_id="captured-start") + if resume_owner == "runner": + from unittest.mock import MagicMock + + from core.app.apps.workflow import app_runner as runner_module + from graphon.variable_loader import DUMMY_VARIABLE_LOADER + from tests.unit_tests.model_factories import make_workflow + + redis = MagicMock() + redis.pipeline.return_value.__enter__.return_value.execute.return_value = [[], 0] + monkeypatch.setattr(runner_module, "redis_client", redis) + monkeypatch.setattr("core.app.apps.workflow.command_channels.is_app_task_stop_flag_set", lambda _task: False) + monkeypatch.setattr( + "core.app.apps.workflow.stop_aware_ready_queue.is_app_task_stop_flag_set", lambda _task: False + ) + monkeypatch.setattr("core.app.workflow.layers.persistence._inspector_publish_node_changed", Mock()) + monkeypatch.setattr("core.app.workflow.layers.persistence._inspector_publish_workflow_completed", Mock()) + nodes = Mock() + nodes.get_by_workflow_execution.return_value = [] + workflow = make_workflow( + workflow_id="workflow", tenant_id="tenant", app_id="app", graph=graph_config, created_by="user" + ) + runner = runner_module.WorkflowAppRunner( + application_generate_entity=entity, + queue_manager=Mock(), + variable_loader=DUMMY_VARIABLE_LOADER, + workflow=workflow, + system_user_id="user", + workflow_execution_repository=Mock(), + workflow_node_execution_repository=nodes, + runtime_state=state, + response_stream_filter=response_filter, + ) + events, seen = [], [] + original_handle = runner._handle_event + + def observe(entry, event): + events.append(event) + seen.append(isinstance(entry.graph_engine.file_runtime, DifyWorkflowFileRuntime)) + return original_handle(entry, event) + + monkeypatch.setattr(runner, "_handle_event", observe) + with use_workflow_file_runtime(None): + runner.run() + assert peek_workflow_file_runtime() is None + else: + with use_workflow_file_runtime(None): + entry = WorkflowEntry( + tenant_id="tenant", + app_id="app", + workflow_id="workflow", + graph_config=graph_config, + graph=graph, + user_id="user", + user_from=UserFrom.ACCOUNT, + invoke_from=entity.invoke_from, + call_depth=0, + variable_pool=state.variable_pool, + runtime_state=state, + response_stream_filter=response_filter, + ) + seen = [] + + class Observe(Layer): + def on_node_run_start(self, _node): + seen.append(peek_workflow_file_runtime() is entry.graph_engine.file_runtime) + + entry.add_layer(Observe()) + events = list(entry.run()) + assert peek_workflow_file_runtime() is None + assert isinstance(events[-1], GraphRunSucceededEvent) + assert seen + assert all(seen) + assert completed == ["captured-human"] * (2 if case == "container" else 1) + succeeded = [event for event in events if isinstance(event, NodeRunSucceededEvent)] + assert not any(event.node_id in {"captured-start", "captured-preface"} for event in succeeded) + assert len({event.id for event in succeeded}) == len(succeeded) + if case == "root": + assert not any(event.node_id == "captured-answer" for event in succeeded) + assert not any( + isinstance(event, NodeRunStreamChunkEvent) and event.chunk in {"before-pause", "container-before-pause"} + for event in events + ) + + +@pytest.mark.parametrize("flask_capture", [False, True]) +def test_captured_otel_context_is_reversible_on_exception(flask_capture): + from opentelemetry import context as otel_context + + key = otel_context.create_key("nw3-segment") + host_token = otel_context.attach(otel_context.set_value(key, "host")) + try: + captured = contextvars.copy_context() + finally: + otel_context.detach(host_token) + capture = ( + FlaskExecutionContext(Flask(__name__), captured) if flask_capture else ExecutionContext(context_vars=captured) + ) + + def run_segment(): + with capture: + assert otel_context.get_value(key) == "host" + raise ValueError("segment failed") + + worker_token = otel_context.attach(otel_context.set_value(key, "worker")) + try: + with use_workflow_file_runtime(None): + with pytest.raises(ValueError, match="segment failed"): + run_segment() + assert otel_context.get_value(key) == "worker" + assert peek_workflow_file_runtime() is None + finally: + otel_context.detach(worker_token) + + +def test_lazy_response_retains_ownership_grants_between_steps_without_leaking(): + from core.app.apps.base_app_generator import BaseAppGenerator + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom + from core.app.file_access import ( + FileAccessScope, + bind_file_access_scope, + get_current_file_access_scope, + grant_upload_file_access, + ) + from core.app.workflow.file_runtime import bind_dify_workflow_file_runtime + + owner = FileAccessScope( + tenant_id="tenant", user_id="user", user_from=UserFrom.END_USER, invoke_from=InvokeFrom.WEB_APP + ) + thread = Mock() + thread.is_alive.return_value = False + + def response(): + grant_upload_file_access(["owned-result"]) + yield "first" + assert get_current_file_access_scope().granted_upload_file_ids == frozenset({"owned-result"}) + yield "second" + + with use_workflow_file_runtime(None), bind_file_access_scope(None): + with bind_dify_workflow_file_runtime(), bind_file_access_scope(owner): + stream = BaseAppGenerator._wrap_stream_with_worker_thread_join(response(), thread) + assert next(stream) == "first" + assert peek_workflow_file_runtime() is None + assert get_current_file_access_scope() is None + assert list(stream) == ["second"] + assert get_current_file_access_scope() is None + thread.join.assert_called_once() diff --git a/api/tests/unit_tests/core/workflow/test_llm_node.py b/api/tests/unit_tests/core/workflow/test_llm_node.py index cafaa3f47a9..5a1766ea893 100644 --- a/api/tests/unit_tests/core/workflow/test_llm_node.py +++ b/api/tests/unit_tests/core/workflow/test_llm_node.py @@ -22,6 +22,7 @@ def test_dify_llm_node_finalizes_polling_when_generator_is_closed(monkeypatch: p node._polling_finalizer = finalizer events = node._invoke_llm_with_polling( + file_outputs=[], polling_model=cast(LLMPollingCapableProtocol, SimpleNamespace()), prompt_messages=[], stop=None, @@ -44,6 +45,7 @@ def test_dify_llm_node_finalizes_polling_when_polling_fails(monkeypatch: pytest. node = object.__new__(DifyLLMNode) node._polling_finalizer = finalizer events = node._invoke_llm_with_polling( + file_outputs=[], polling_model=cast(LLMPollingCapableProtocol, SimpleNamespace()), prompt_messages=[], stop=None, diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index 725759ba1b7..4432e969880 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -37,7 +37,7 @@ from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData from graphon.nodes.tool.tool_node import ToolNode -from graphon.runtime import GraphRuntimeState +from graphon.runtime import RuntimeState from graphon.variables.segments import ArrayObjectSegment, ObjectSegment, StringSegment from models.base import TypeBase from models.model import AppMode, Conversation, ConversationFromSource @@ -367,8 +367,9 @@ class TestDifyNodeFactoryCreateNode: @pytest.fixture def factory(self): factory = object.__new__(node_factory.DifyNodeFactory) - factory.graph_init_params = sentinel.graph_init_params - factory.graph_runtime_state = MagicMock(spec=GraphRuntimeState, variable_pool=MagicMock()) + factory._native_runtime = None + factory.init_params = sentinel.init_params + factory.runtime_state = MagicMock(spec=RuntimeState, variable_pool=MagicMock()) factory._dify_context = DifyRunContext( builder_execution=None, tenant_id="tenant-id", @@ -441,8 +442,8 @@ class TestDifyNodeFactoryCreateNode: kwargs = matched_node_class.call_args.kwargs assert kwargs["node_id"] == "node-id" _assert_constructor_node_data(kwargs["data"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") - assert kwargs["graph_init_params"] is sentinel.graph_init_params - assert kwargs["graph_runtime_state"] is factory.graph_runtime_state + assert kwargs["init_params"] is sentinel.init_params + assert kwargs["runtime_state"] is factory.runtime_state latest_node_class.assert_not_called() def test_falls_back_to_latest_class_when_version_specific_mapping_is_missing( @@ -463,8 +464,8 @@ class TestDifyNodeFactoryCreateNode: kwargs = latest_node_class.call_args.kwargs assert kwargs["node_id"] == "node-id" _assert_constructor_node_data(kwargs["data"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") - assert kwargs["graph_init_params"] is sentinel.graph_init_params - assert kwargs["graph_runtime_state"] is factory.graph_runtime_state + assert kwargs["init_params"] is sentinel.init_params + assert kwargs["runtime_state"] is factory.runtime_state @pytest.mark.parametrize( ("node_type", "constructor_name"), @@ -500,8 +501,8 @@ class TestDifyNodeFactoryCreateNode: kwargs = constructor.call_args.kwargs assert kwargs["node_id"] == "node-id" _assert_constructor_node_data(kwargs["data"], node_id="node-id", node_type=node_type) - assert kwargs["graph_init_params"] is sentinel.graph_init_params - assert kwargs["graph_runtime_state"] is factory.graph_runtime_state + assert kwargs["init_params"] is sentinel.init_params + assert kwargs["runtime_state"] is factory.runtime_state if constructor_name == "CodeNode": assert kwargs["code_executor"] is sentinel.code_executor @@ -634,7 +635,7 @@ class TestDifyNodeFactoryCreateNode: "vision": {"enabled": False}, } ) - factory.graph_runtime_state.variable_pool.get.return_value = ObjectSegment( + factory.runtime_state.variable_pool.get.return_value = ObjectSegment( value={ "provider": "new-provider", "name": "new-model", @@ -649,7 +650,7 @@ class TestDifyNodeFactoryCreateNode: assert result.model.name == "new-model" assert result.model.mode == node_data.model.mode assert result.model.completion_params == {"temperature": 0.8} - factory.graph_runtime_state.variable_pool.get.assert_called_once_with(("env", "for_summarize")) + factory.runtime_state.variable_pool.get.assert_called_once_with(("env", "for_summarize")) def test_resolve_llm_model_reference_keeps_node_parameters_for_legacy_variable(self, factory): node_data = LLMNodeData.model_validate( @@ -668,7 +669,7 @@ class TestDifyNodeFactoryCreateNode: "vision": {"enabled": False}, } ) - factory.graph_runtime_state.variable_pool.get.return_value = ObjectSegment( + factory.runtime_state.variable_pool.get.return_value = ObjectSegment( value={"provider": "new-provider", "name": "new-model", "mode": "chat"} ) @@ -688,7 +689,7 @@ class TestDifyNodeFactoryCreateNode: "vision": {"enabled": False}, } ) - factory.graph_runtime_state.variable_pool.get.return_value = ObjectSegment( + factory.runtime_state.variable_pool.get.return_value = ObjectSegment( value={"provider": "provider", "name": "model", "mode": "completion"} ) @@ -707,7 +708,7 @@ class TestDifyNodeFactoryCreateNode: "vision": {"enabled": False}, } ) - factory.graph_runtime_state.variable_pool.get.return_value = None + factory.runtime_state.variable_pool.get.return_value = None with pytest.raises(ValueError, match="shared_model.*not found"): factory._resolve_llm_model_reference(node_data) @@ -728,7 +729,7 @@ class TestDifyNodeFactoryCreateNode: result = factory._resolve_llm_model_reference(node_data) assert result is node_data - factory.graph_runtime_state.variable_pool.get.assert_not_called() + factory.runtime_state.variable_pool.get.assert_not_called() def test_build_llm_compatible_node_init_kwargs_uses_polling_wrapper_for_polling_llm_node(self, factory): node_data = LLMNodeData.model_validate( @@ -826,12 +827,12 @@ class TestDifyNodeFactoryCreateNode: data = constructor.call_args.kwargs["data"] assert isinstance(data, Mapping) - assert data["structured_output_enabled"] is True - assert "structured_output_switch_on" not in data + assert data["structured_output_switch_on"] is True + assert "structured_output_enabled" not in data assert LLMNodeData.model_validate(data).structured_output_enabled is True def test_create_node_preserves_structured_output_switch_after_graphon_constructor(self, monkeypatch, factory): - factory.graph_init_params = SimpleNamespace( + factory.init_params = SimpleNamespace( workflow_id="workflow-id", graph_config={}, run_context={}, @@ -876,7 +877,7 @@ class TestDifyNodeFactoryCreateNode: assert node.node_data.structured_output_enabled is True def test_create_node_uses_dify_llm_node_for_persisted_version_one(self, monkeypatch, factory): - factory.graph_init_params = SimpleNamespace( + factory.init_params = SimpleNamespace( workflow_id="workflow-id", graph_config={}, run_context={}, @@ -991,8 +992,8 @@ class TestDifyNodeFactoryCreateNode: constructor_kwargs = constructor.call_args.kwargs assert constructor_kwargs["node_id"] == "node-id" _assert_constructor_node_data(constructor_kwargs["data"], node_id="node-id", node_type=node_type) - assert constructor_kwargs["graph_init_params"] is sentinel.graph_init_params - assert constructor_kwargs["graph_runtime_state"] is factory.graph_runtime_state + assert constructor_kwargs["init_params"] is sentinel.init_params + assert constructor_kwargs["runtime_state"] is factory.runtime_state assert constructor_kwargs["credentials_provider"] is sentinel.credentials_provider assert constructor_kwargs["model_factory"] is sentinel.model_factory assert constructor_kwargs["model_instance"] is sentinel.model_instance @@ -1042,7 +1043,7 @@ class TestDifyNodeFactoryRetrieverAttachmentAccess: @pytest.fixture def factory(self): factory = object.__new__(node_factory.DifyNodeFactory) - factory.graph_runtime_state = MagicMock(spec=GraphRuntimeState, variable_pool=MagicMock()) + factory.runtime_state = MagicMock(spec=RuntimeState, variable_pool=MagicMock()) return factory def test_retriever_attachment_loader_is_typed_for_llm_node_data_only(self): @@ -1052,7 +1053,7 @@ class TestDifyNodeFactoryRetrieverAttachmentAccess: def test_build_retriever_attachment_loader_uses_llm_context_selector(self, factory): factory._file_reference_factory = sentinel.file_reference_factory - factory.graph_runtime_state.variable_pool.get.return_value = ArrayObjectSegment( + factory.runtime_state.variable_pool.get.return_value = ArrayObjectSegment( value=[ { "metadata": { @@ -1077,16 +1078,16 @@ class TestDifyNodeFactoryRetrieverAttachmentAccess: assert loader._segment_access_checker is not None assert loader._segment_access_checker("allowed-segment") is True - factory.graph_runtime_state.variable_pool.get.assert_called_once_with(["knowledge-node", "result"]) + factory.runtime_state.variable_pool.get.assert_called_once_with(["knowledge-node", "result"]) def test_checker_rejects_missing_context_selector_without_reading_variable_pool(self, factory): checker = factory._build_retriever_segment_access_checker(None) assert checker("segment-id") is False - factory.graph_runtime_state.variable_pool.get.assert_not_called() + factory.runtime_state.variable_pool.get.assert_not_called() def test_checker_rejects_non_knowledge_context_items(self, factory): - factory.graph_runtime_state.variable_pool.get.return_value = ArrayObjectSegment.model_construct( + factory.runtime_state.variable_pool.get.return_value = ArrayObjectSegment.model_construct( value=[ "plain-text", {"metadata": "not-a-mapping"}, @@ -1098,14 +1099,14 @@ class TestDifyNodeFactoryRetrieverAttachmentAccess: assert checker("segment-id") is False def test_checker_rejects_non_array_context_value(self, factory): - factory.graph_runtime_state.variable_pool.get.return_value = StringSegment(value="not knowledge context") + factory.runtime_state.variable_pool.get.return_value = StringSegment(value="not knowledge context") checker = factory._build_retriever_segment_access_checker(["knowledge-node", "result"]) assert checker("segment-id") is False def test_checker_allows_only_segments_from_selected_knowledge_context(self, factory): - factory.graph_runtime_state.variable_pool.get.return_value = ArrayObjectSegment( + factory.runtime_state.variable_pool.get.return_value = ArrayObjectSegment( value=[ { "metadata": { @@ -1120,7 +1121,7 @@ class TestDifyNodeFactoryRetrieverAttachmentAccess: assert checker("allowed-segment") is True assert checker("other-segment") is False - factory.graph_runtime_state.variable_pool.get.assert_any_call(["knowledge-node", "result"]) + factory.runtime_state.variable_pool.get.assert_any_call(["knowledge-node", "result"]) class TestDifyNodeFactoryModelInstance: @@ -1179,7 +1180,7 @@ class TestDifyNodeFactoryMemory: user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, ) - factory.graph_runtime_state = MagicMock(spec=GraphRuntimeState, variable_pool=MagicMock()) + factory.runtime_state = MagicMock(spec=RuntimeState, variable_pool=MagicMock()) return factory def test_returns_none_when_memory_is_not_configured(self, factory): @@ -1189,11 +1190,11 @@ class TestDifyNodeFactoryMemory: ) assert result is None - factory.graph_runtime_state.variable_pool.get.assert_not_called() + factory.runtime_state.variable_pool.get.assert_not_called() def test_uses_string_segment_conversation_id(self, monkeypatch: pytest.MonkeyPatch, factory): memory_config = sentinel.memory_config - factory.graph_runtime_state.variable_pool.get.return_value = StringSegment(value="conversation-id") + factory.runtime_state.variable_pool.get.return_value = StringSegment(value="conversation-id") fetch_memory = MagicMock(return_value=sentinel.memory) monkeypatch.setattr(node_factory, "fetch_memory", fetch_memory) @@ -1203,7 +1204,7 @@ class TestDifyNodeFactoryMemory: ) assert result is sentinel.memory - factory.graph_runtime_state.variable_pool.get.assert_called_once_with(("sys", "conversation_id")) + factory.runtime_state.variable_pool.get.assert_called_once_with(("sys", "conversation_id")) fetch_memory.assert_called_once_with( conversation_id="conversation-id", app_id="app-id", @@ -1213,7 +1214,7 @@ class TestDifyNodeFactoryMemory: def test_ignores_non_string_segment_conversation_ids(self, monkeypatch: pytest.MonkeyPatch, factory): memory_config = sentinel.memory_config - factory.graph_runtime_state.variable_pool.get.return_value = sentinel.segment + factory.runtime_state.variable_pool.get.return_value = sentinel.segment fetch_memory = MagicMock(return_value=sentinel.memory) monkeypatch.setattr(node_factory, "fetch_memory", fetch_memory) @@ -1300,8 +1301,9 @@ class TestNodeConfigErrorsNameTheNode: @pytest.fixture def factory(self): factory = object.__new__(node_factory.DifyNodeFactory) - factory.graph_init_params = sentinel.graph_init_params - factory.graph_runtime_state = MagicMock(spec=GraphRuntimeState, variable_pool=MagicMock()) + factory._native_runtime = None + factory.init_params = sentinel.init_params + factory.runtime_state = MagicMock(spec=RuntimeState, variable_pool=MagicMock()) factory._dify_context = DifyRunContext( tenant_id="tenant-id", app_id="app-id", @@ -1361,7 +1363,7 @@ class TestNodeConfigErrorsNameTheNode: assert data.type == BuiltinNodeTypes.IF_ELSE def test_validate_node_config_needs_no_factory_instance(self): - # No graph_init_params / runtime state anywhere: a pure, dry check. + # No init_params / runtime state anywhere: a pure, dry check. data = node_factory.validate_node_config( {"id": "s", "data": {"type": BuiltinNodeTypes.START, "title": "Start"}} ) @@ -1433,8 +1435,7 @@ def _confirmed_factory( """ from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.variable_pool_initializer import add_variables_to_pool - from graphon.entities import GraphInitParams - from graphon.runtime import GraphRuntimeState, VariablePool + from graphon.runtime import InitParams, RuntimeState, VariablePool from models.workflow import Workflow from tests.unit_tests.services.dify_builder.test_confirmed_tool_launch import ( ServiceGuardRecorder, @@ -1459,7 +1460,7 @@ def _confirmed_factory( created_by=None, trace_session_id=None, ) - params = GraphInitParams( + params = InitParams( workflow_id=workflow_id or owner.workflow_id, graph_config=graph, call_depth=0, @@ -1477,10 +1478,8 @@ def _confirmed_factory( ) ), ) - state = GraphRuntimeState(variable_pool=pool, start_at=0.0) - factory = node_factory.DifyNodeFactory( - graph_init_params=params, graph_runtime_state=state, execution_recorder=recorder - ) + state = RuntimeState(workflow_id="test-workflow", variable_pool=pool, start_at=0.0) + factory = node_factory.DifyNodeFactory(init_params=params, runtime_state=state, execution_recorder=recorder) return factory, graph, reserved, run_id diff --git a/api/tests/unit_tests/core/workflow/test_response_stream_filter_issue_170.py b/api/tests/unit_tests/core/workflow/test_response_stream_filter_issue_170.py index 8056da32acd..2c0548cbb58 100644 --- a/api/tests/unit_tests/core/workflow/test_response_stream_filter_issue_170.py +++ b/api/tests/unit_tests/core/workflow/test_response_stream_filter_issue_170.py @@ -1,9 +1,9 @@ """Unit coverage for Dify's in-memory ResponseStreamFilter boundary behavior.""" from core.workflow.workflow_entry import iter_dify_graph_engine_events -from graphon.graph_engine import GraphEngine, GraphEngineConfig -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import GraphRunSucceededEvent, NodeRunStreamChunkEvent +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine_events import GraphRunSucceededEvent, NodeRunStreamChunkEvent from tests.unit_tests.core.workflow.graph_engine.test_mock_config import MockConfig, MockConfigBuilder from tests.unit_tests.core.workflow.graph_engine.test_table_runner import WorkflowRunner @@ -45,7 +45,7 @@ def _build_issue_170_mock_config() -> tuple[WorkflowRunner, MockConfig]: def test_dify_response_stream_filter_handles_issue_170_shape() -> None: runner, mock_config = _build_issue_170_mock_config() fixture_data = runner.load_fixture("response_stream_filter_issue_170_workflow") - graph, graph_runtime_state = runner.create_graph_from_fixture( + graph, runtime_state = runner.create_graph_from_fixture( fixture_data=fixture_data, query="1", use_mock_factory=True, @@ -54,12 +54,11 @@ def test_dify_response_stream_filter_handles_issue_170_shape() -> None: expected_answer = "# Du Fu\n\nSpring View\n\n# Li Bai\n\nQuiet Night Thought" - engine = GraphEngine( - workflow_id="test_workflow", + engine = Engine( graph=graph, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=InMemoryChannel(), - config=GraphEngineConfig(), + workers=5, ) events = list(iter_dify_graph_engine_events(engine)) diff --git a/api/tests/unit_tests/core/workflow/test_response_stream_filter_pause_resume.py b/api/tests/unit_tests/core/workflow/test_response_stream_filter_pause_resume.py index 02993ac9043..d419455833a 100644 --- a/api/tests/unit_tests/core/workflow/test_response_stream_filter_pause_resume.py +++ b/api/tests/unit_tests/core/workflow/test_response_stream_filter_pause_resume.py @@ -20,11 +20,11 @@ from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserAct from core.workflow.nodes.human_input.enums import HumanInputFormStatus from core.workflow.system_variables import build_system_variables from core.workflow.workflow_entry import iter_dify_graph_engine_events -from graphon.filters import GraphEventFilterContext, ResponseStreamFilter, filter_graph_events +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine.filter import EngineEventFilterContext, ResponseStreamFilter, filter_engine_events +from graphon.engine_events import GraphRunPausedEvent, GraphRunSucceededEvent, NodeRunStreamChunkEvent from graphon.graph import Graph -from graphon.graph_engine import GraphEngine, GraphEngineConfig -from graphon.graph_engine.command_channels import InMemoryChannel -from graphon.graph_events import GraphRunPausedEvent, GraphRunSucceededEvent, NodeRunStreamChunkEvent from graphon.nodes.answer.answer_node import AnswerNode from graphon.nodes.answer.entities import AnswerNodeData from graphon.nodes.human_input.entities import HumanInputNodeData as GraphonHumanInputNodeData @@ -33,7 +33,7 @@ from graphon.nodes.if_else.entities import IfElseNodeData from graphon.nodes.if_else.if_else_node import IfElseNode 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 graphon.utils.condition.entities import Condition from libs.datetime_utils import naive_utc_now from models.human_input import HumanInputForm @@ -42,7 +42,7 @@ from tests.workflow_test_utils import build_test_graph_init_params WORKFLOW_EXECUTION_ID = "wf-exec-38525" -def _build_graph(runtime_state: GraphRuntimeState, form_repository: HumanInputFormRepository) -> Graph: +def _build_graph(runtime_state: RuntimeState, form_repository: HumanInputFormRepository) -> Graph: params = build_test_graph_init_params( workflow_id="wf", graph_config={"nodes": [], "edges": []}, @@ -53,8 +53,8 @@ def _build_graph(runtime_state: GraphRuntimeState, form_repository: HumanInputFo start_node = StartNode( node_id="start", data=StartNodeData(title="start", variables=[]), - graph_init_params=params, - graph_runtime_state=runtime_state, + init_params=params, + runtime_state=runtime_state, ) if_else_node = IfElseNode( @@ -75,8 +75,8 @@ def _build_graph(runtime_state: GraphRuntimeState, form_repository: HumanInputFo ) ], ), - graph_init_params=params, - graph_runtime_state=runtime_state, + init_params=params, + runtime_state=runtime_state, ) human_data = HumanInputNodeData( @@ -88,30 +88,30 @@ def _build_graph(runtime_state: GraphRuntimeState, form_repository: HumanInputFo human_node = HumanInputNode( node_id="human_input", data=GraphonHumanInputNodeData.model_validate(human_data.model_dump()), - graph_init_params=params, - graph_runtime_state=runtime_state, + init_params=params, + runtime_state=runtime_state, hitl_callback=DifyHITLCallback(form_repository=form_repository, node_data=human_data), ) answer_false_node = AnswerNode( node_id="answer_false", data=AnswerNodeData(title="answer_false", answer="unreachable branch"), - graph_init_params=params, - graph_runtime_state=runtime_state, + init_params=params, + runtime_state=runtime_state, ) answer_after_pause = AnswerNode( node_id="answer_after_pause", data=AnswerNodeData(title="answer_after_pause", answer="Post-branch answer chunk 1"), - graph_init_params=params, - graph_runtime_state=runtime_state, + init_params=params, + runtime_state=runtime_state, ) answer_after_pause_2 = AnswerNode( node_id="answer_after_pause_2", data=AnswerNodeData(title="answer_after_pause_2", answer="Post-branch answer chunk 2"), - graph_init_params=params, - graph_runtime_state=runtime_state, + init_params=params, + runtime_state=runtime_state, ) return ( @@ -126,7 +126,7 @@ def _build_graph(runtime_state: GraphRuntimeState, form_repository: HumanInputFo ) -def _build_runtime_state() -> GraphRuntimeState: +def _build_runtime_state() -> RuntimeState: variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( workflow_execution_id=WORKFLOW_EXECUTION_ID, @@ -138,7 +138,7 @@ def _build_runtime_state() -> GraphRuntimeState: conversation_variables=[], ) variable_pool.add(("start", "category"), "fruit") # drives the if-else "true" branch - return GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) + return RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0.0) def test_if_else_human_input_pause_resume_answer_chunks_survive_resume( @@ -154,18 +154,17 @@ def test_if_else_human_input_pause_resume_answer_chunks_survive_resume( # ---- Phase 1: run to GraphRunPausedEvent ---- runtime_state_1 = _build_runtime_state() graph_1 = _build_graph(runtime_state_1, repository) - engine_1 = GraphEngine( - workflow_id="wf", + engine_1 = Engine( graph=graph_1, - graph_runtime_state=runtime_state_1, + runtime_state=runtime_state_1, command_channel=InMemoryChannel(), - config=GraphEngineConfig(), + workers=5, ) filter_1 = ResponseStreamFilter() phase1_events = list( - filter_graph_events( + filter_engine_events( engine_1.run(), - context=GraphEventFilterContext.from_engine(engine_1), + context=EngineEventFilterContext.from_engine(engine_1), filters=[filter_1], ) ) @@ -187,14 +186,13 @@ def test_if_else_human_input_pause_resume_answer_chunks_survive_resume( form.status = HumanInputFormStatus.SUBMITTED # ---- Phase 2: rebuild engine + filter from snapshots, resume to completion ---- - runtime_state_2 = GraphRuntimeState.from_snapshot(runtime_snapshot) + runtime_state_2 = RuntimeState.from_snapshot(runtime_snapshot) graph_2 = _build_graph(runtime_state_2, repository) - engine_2 = GraphEngine( - workflow_id="wf", + engine_2 = Engine( graph=graph_2, - graph_runtime_state=runtime_state_2, + runtime_state=runtime_state_2, command_channel=InMemoryChannel(), - config=GraphEngineConfig(), + workers=5, ) filter_2 = ResponseStreamFilter() filter_2.loads(response_filter_snapshot) diff --git a/api/tests/unit_tests/core/workflow/test_restricted_execution.py b/api/tests/unit_tests/core/workflow/test_restricted_execution.py index 93de7b4006e..600d796de42 100644 --- a/api/tests/unit_tests/core/workflow/test_restricted_execution.py +++ b/api/tests/unit_tests/core/workflow/test_restricted_execution.py @@ -12,7 +12,7 @@ from core.dify_builder.execution_policy import BuilderExecutionPolicyError, admi from graphon.http import HttpClientProtocol from graphon.nodes.http_request.node import HttpRequestNode from graphon.nodes.start.start_node import StartNode -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from tests.unit_tests.core.dify_builder.test_execution_policy import fixture, graph, snapshot from tests.unit_tests.core.workflow.graph_engine.test_builder_restricted_native import ( forbid_live as forbid_live, # noqa: PLC0414 @@ -49,7 +49,7 @@ def test_all_protocol_methods_serve_text_and_append_distinct_attempts(owned, met from core.workflow.restricted_execution import PolicyTransportError, RestrictedHttpClient factory, context, recorder = prepare_native(owned, "http_text.yml", samples=(fixture(body="你好"),)) - node = factory.create_node(factory.graph_init_params.graph_config["nodes"][1]) + node = factory.create_node(factory.init_params.graph_config["nodes"][1]) assert isinstance(node, HttpRequestNode) client = node.http_client assert isinstance(client, RestrictedHttpClient) @@ -105,7 +105,7 @@ def test_runtime_kwargs_deny_before_fixture_return_and_append_receipt(owned, kwa from core.workflow.restricted_execution import PolicyTransportError factory, _, _ = prepare_native(owned, "http_text.yml", samples=(fixture(),)) - node = factory.create_node(factory.graph_init_params.graph_config["nodes"][1]) + node = factory.create_node(factory.init_params.graph_config["nodes"][1]) assert isinstance(node, HttpRequestNode) client = node.http_client with pytest.raises(PolicyTransportError, match="execution_policy: http_request_arguments"): @@ -121,7 +121,7 @@ def test_file_collaborators_deny_and_record(owned, entry): from core.workflow.restricted_execution import PolicyTransportError factory, _, _ = prepare_native(owned, "http_text.yml", samples=(fixture(),)) - node = factory.create_node(factory.graph_init_params.graph_config["nodes"][1]) + node = factory.create_node(factory.init_params.graph_config["nodes"][1]) assert isinstance(node, HttpRequestNode) def invoke(): @@ -154,7 +154,7 @@ def test_forbidden_raw_type_refused_before_concrete_validation_or_model_resoluti ) monkeypatch.setattr(forbidden_class, "validate_node_data", forbidden) monkeypatch.setattr(forbidden_class, "__init__", forbidden) - node = deepcopy(factory.graph_init_params.graph_config["nodes"][0]) + node = deepcopy(factory.init_params.graph_config["nodes"][0]) node["data"]["type"] = node_type with pytest.raises(BuilderExecutionPolicyError): factory.create_node(node) @@ -168,7 +168,7 @@ def test_raw_identity_and_config_gate_runs_before_concrete_validation(owned, mon from graphon.graph import Graph factory, _, _ = prepare_native(owned, "scalar.yml", inputs={"text": "x"}) - node = deepcopy(factory.graph_init_params.graph_config["nodes"][0]) + node = deepcopy(factory.init_params.graph_config["nodes"][0]) forbidden = MagicMock(side_effect=AssertionError("concrete validation reached")) monkeypatch.setattr(StartNode, "validate_node_data", forbidden) if mutation == "class": @@ -187,7 +187,7 @@ def test_raw_identity_and_config_gate_runs_before_concrete_validation(owned, mon node["data"]["new_extra"] = "changed" else: node["data"]["version"] = "latest" if mutation == "latest" else "999" - config = deepcopy(factory.graph_init_params.graph_config) + config = deepcopy(factory.init_params.graph_config) config["nodes"][0] = node def invoke(): @@ -196,8 +196,8 @@ def test_raw_identity_and_config_gate_runs_before_concrete_validation(owned, mon if entry == "graph_init": return Graph.init(graph_config=config, node_factory=factory, root_node_id="start") return DifyNodeFactory( - factory.graph_init_params.model_copy(update={"graph_config": config}), - factory.graph_runtime_state, + factory.init_params.model_copy(update={"graph_config": config}), + factory.runtime_state, execution_recorder=factory.execution_recorder, ) @@ -208,13 +208,13 @@ def test_raw_identity_and_config_gate_runs_before_concrete_validation(owned, mon def test_with_runtime_state_keeps_exact_policy_recorder_and_private_key(owned): factory, context, recorder = prepare_native(owned, "http_text.yml", samples=(fixture(),)) - state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0) + state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0) clone = factory.with_runtime_state(state) assert clone.execution_recorder is recorder assert clone._dify_context.builder_execution is context assert clone._dify_context.builder_execution.http_fixtures is context.http_fixtures assert clone._request_hmac_key is factory._request_hmac_key - node = clone.create_node(clone.graph_init_params.graph_config["nodes"][1]) + node = clone.create_node(clone.init_params.graph_config["nodes"][1]) assert isinstance(node, HttpRequestNode) node.http_client.get("https://sample.invalid/clone") assert len(receipts(owned)) == 1 @@ -290,9 +290,9 @@ def test_wrong_fixture_or_context_cannot_construct_native_adapter(owned, change) if change != "context_digest": altered = altered.model_copy(update={"context_digest": context_digest(altered)}) dify_context = factory._dify_context.model_copy(update={"builder_execution": altered}) - params = factory.graph_init_params.model_copy(update={"run_context": {DIFY_RUN_CONTEXT_KEY: dify_context}}) + params = factory.init_params.model_copy(update={"run_context": {DIFY_RUN_CONTEXT_KEY: dify_context}}) with pytest.raises(BuilderExecutionPolicyError): - DifyNodeFactory(params, factory.graph_runtime_state, execution_recorder=recorder) + DifyNodeFactory(params, factory.runtime_state, execution_recorder=recorder) assert receipts(owned) == [] @@ -344,7 +344,7 @@ def test_actual_resolved_class_is_guarded_before_its_validator_when_registry_cha classes = iter((StartNode, StartNode, ChangedStart)) monkeypatch.setattr(factory, "_resolve_node_class", lambda **_: next(classes)) with pytest.raises(BuilderExecutionPolicyError): - factory.create_node(factory.graph_init_params.graph_config["nodes"][0]) + factory.create_node(factory.init_params.graph_config["nodes"][0]) forbidden.assert_not_called() @@ -353,7 +353,7 @@ def test_typed_capability_recheck_requires_same_exact_binding_and_version(owned, from core.workflow.restricted_execution import validate_restricted_node factory, context, _ = prepare_native(owned, "scalar.yml", inputs={"text": "x"}) - data = StartNode.validate_node_data(factory.graph_init_params.graph_config["nodes"][0]["data"]) + data = StartNode.validate_node_data(factory.init_params.graph_config["nodes"][0]["data"]) if change == "binding": binding = context.admitted_nodes[0].model_copy(update={"implementation": "unreviewed.StartNode"}) context = context.model_copy(update={"admitted_nodes": (binding, *context.admitted_nodes[1:])}) diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index 384340c7d0b..a5c3a7bb0b2 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py @@ -1,7 +1,7 @@ from collections import UserString from datetime import datetime from types import SimpleNamespace -from unittest.mock import MagicMock, patch, sentinel +from unittest.mock import ANY, MagicMock, patch, sentinel import pytest @@ -10,12 +10,12 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.credit_usage import CreditUsageAppType from core.workflow import workflow_entry from core.workflow.system_variables import default_system_variables +from graphon.engine.filter import ResponseStreamFilter +from graphon.engine_events import GraphRunFailedEvent, NodeRunSucceededEvent from graphon.entities.base_node_data import BaseNodeData from graphon.enums import NodeType, WorkflowNodeExecutionStatus from graphon.errors import WorkflowNodeRunFailedError from graphon.file import File, FileTransferMethod, FileType -from graphon.filters import ResponseStreamFilter -from graphon.graph_events import GraphRunFailedEvent, NodeRunSucceededEvent from graphon.node_events import NodeRunResult from graphon.nodes import BuiltinNodeTypes from graphon.runtime import VariablePool @@ -39,13 +39,12 @@ def _build_minimal_workflow_entry( *, response_stream_filter: ResponseStreamFilter | None = None, ) -> workflow_entry.WorkflowEntry: - """Construct a minimal WorkflowEntry with GraphEngine construction mocked out.""" + """Construct a minimal WorkflowEntry with Engine construction mocked out.""" graph_engine = MagicMock() - graph_runtime_state = SimpleNamespace(_execution_context=None) + runtime_state = SimpleNamespace(_execution_context=None) - monkeypatch.setattr(workflow_entry, "capture_current_context", lambda: sentinel.execution_context) - monkeypatch.setattr(workflow_entry, "GraphEngine", MagicMock(return_value=graph_engine)) - monkeypatch.setattr(workflow_entry, "GraphEngineConfig", MagicMock(return_value=sentinel.graph_engine_config)) + monkeypatch.setattr(workflow_entry, "WorkflowExecutionContextLayer", MagicMock()) + monkeypatch.setattr(workflow_entry, "Engine", MagicMock(return_value=graph_engine)) monkeypatch.setattr(workflow_entry, "InMemoryChannel", MagicMock(return_value=sentinel.command_channel)) return workflow_entry.WorkflowEntry( @@ -59,7 +58,7 @@ def _build_minimal_workflow_entry( invoke_from=InvokeFrom.DEBUGGER, call_depth=0, variable_pool=sentinel.variable_pool, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, response_stream_filter=response_stream_filter, ) @@ -80,12 +79,12 @@ class TestWorkflowEntryInit: invoke_from=InvokeFrom.DEBUGGER, call_depth=call_depth, variable_pool=sentinel.variable_pool, - graph_runtime_state=sentinel.graph_runtime_state, + runtime_state=sentinel.runtime_state, ) def test_applies_debug_and_observability_layers(self): graph_engine = MagicMock() - graph_runtime_state = SimpleNamespace(_execution_context=None) + runtime_state = SimpleNamespace(_execution_context=None) debug_layer = sentinel.debug_layer execution_limits_layer = sentinel.execution_limits_layer observability_layer = sentinel.observability_layer @@ -93,11 +92,12 @@ class TestWorkflowEntryInit: with ( config_overrides_context(DEBUG=True, ENABLE_OTEL=False), patch.object(workflow_entry, "is_instrument_flag_enabled", return_value=True), - patch.object(workflow_entry, "capture_current_context", return_value=sentinel.execution_context), - patch.object(workflow_entry, "GraphEngine", return_value=graph_engine) as graph_engine_cls, - patch.object(workflow_entry, "GraphEngineConfig", return_value=sentinel.graph_engine_config), + patch.object( + workflow_entry, "WorkflowExecutionContextLayer", return_value=sentinel.execution_context_layer + ), + patch.object(workflow_entry, "Engine", return_value=graph_engine) as graph_engine_cls, patch.object(workflow_entry, "InMemoryChannel", return_value=sentinel.command_channel), - patch.object(workflow_entry, "DebugLoggingLayer", return_value=debug_layer) as debug_logging_layer, + patch.object(workflow_entry, "WorkflowDebugLayer", return_value=debug_layer) as debug_logging_layer, patch.object( workflow_entry, "ExecutionLimitsLayer", @@ -116,31 +116,25 @@ class TestWorkflowEntryInit: invoke_from=InvokeFrom.DEBUGGER, call_depth=0, variable_pool=sentinel.variable_pool, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=None, ) assert entry.command_channel is sentinel.command_channel graph_engine_cls.assert_called_once_with( - workflow_id="workflow-id-123456", graph=sentinel.graph, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=sentinel.command_channel, - config=sentinel.graph_engine_config, - ) - assert graph_runtime_state._execution_context is sentinel.execution_context - debug_logging_layer.assert_called_once_with( - level="DEBUG", - include_inputs=True, - include_outputs=True, - include_process_data=False, - logger_name="GraphEngine.Debug.workflow", + workers=workflow_entry.dify_config.GRAPH_ENGINE_MAX_WORKERS, + file_runtime=ANY, ) + debug_logging_layer.assert_called_once_with() execution_limits_layer_cls.assert_called_once_with( max_steps=workflow_entry.dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_time=workflow_entry.dify_config.WORKFLOW_MAX_EXECUTION_TIME, ) - assert graph_engine.layer.call_args_list == [ + assert graph_engine.add_layer.call_args_list == [ + ((sentinel.execution_context_layer,), {}), ((debug_layer,), {}), ((execution_limits_layer,), {}), ((observability_layer,), {}), @@ -161,6 +155,7 @@ class TestWorkflowEntryInit: class TestWorkflowEntryRun: def test_run_swallows_generate_task_stopped_errors(self): entry = object.__new__(workflow_entry.WorkflowEntry) + entry._layers = [] entry.graph_engine = MagicMock() entry.graph_engine.run.side_effect = GenerateTaskStoppedError() entry._response_stream_filter = ResponseStreamFilter() @@ -173,7 +168,7 @@ class TestWorkflowEntryRun: with ( patch.object( - workflow_entry.GraphEventFilterContext, + workflow_entry.EngineEventFilterContext, "from_engine", return_value=sentinel.filter_context, ) as from_engine, @@ -189,16 +184,16 @@ class TestWorkflowEntryRun: ), patch.object( workflow_entry, - "filter_graph_events", + "filter_engine_events", return_value=iter([sentinel.filtered_event]), - ) as filter_graph_events, + ) as filter_engine_events, ): events = list(workflow_entry.iter_dify_graph_engine_events(graph_engine)) assert events == [sentinel.filtered_event] from_engine.assert_called_once_with(graph_engine) response_stream_filter_cls.assert_called_once_with() - filter_graph_events.assert_called_once_with( + filter_engine_events.assert_called_once_with( graph_engine.run.return_value, context=sentinel.filter_context, filters=[sentinel.human_input_filter, sentinel.response_stream_filter], @@ -206,6 +201,7 @@ class TestWorkflowEntryRun: def test_run_delegates_to_dify_event_iterator(self): entry = object.__new__(workflow_entry.WorkflowEntry) + entry._layers = [] entry.graph_engine = sentinel.graph_engine entry._response_stream_filter = sentinel.response_stream_filter @@ -217,10 +213,13 @@ class TestWorkflowEntryRun: events = list(entry.run()) assert events == [sentinel.filtered_event] - iter_dify_graph_engine_events.assert_called_once_with(sentinel.graph_engine, sentinel.response_stream_filter) + iter_dify_graph_engine_events.assert_called_once_with( + sentinel.graph_engine, sentinel.response_stream_filter, pause_callbacks=(), pause_failure_callbacks=() + ) def test_run_emits_failed_event_for_unexpected_errors(self): entry = object.__new__(workflow_entry.WorkflowEntry) + entry._layers = [] entry.graph_engine = MagicMock() entry.graph_engine.run.side_effect = RuntimeError("boom") entry._response_stream_filter = ResponseStreamFilter() @@ -280,10 +279,12 @@ class TestWorkflowEntrySingleStepRun: with ( patch.object(workflow, "get_node_config_by_id", return_value=node_config), - patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), + patch.object( + workflow_entry, "DifyGraphInitContext", return_value=SimpleNamespace(workflow_id="workflow-id") + ), patch.object( workflow_entry, - "GraphRuntimeState", + "RuntimeState", return_value=SimpleNamespace(variable_pool=variable_pool), ), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), @@ -338,8 +339,10 @@ class TestWorkflowEntrySingleStepRun: "get_node_config_by_id", return_value=_build_typed_node_config(BuiltinNodeTypes.START), ), - patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), - patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), + patch.object( + workflow_entry, "DifyGraphInitContext", return_value=SimpleNamespace(workflow_id="workflow-id") + ), + patch.object(workflow_entry, "RuntimeState", return_value=sentinel.runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeNode), @@ -405,8 +408,10 @@ class TestWorkflowEntrySingleStepRun: "get_node_config_by_id", return_value=_build_typed_node_config(BuiltinNodeTypes.DATASOURCE), ), - patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), - patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), + patch.object( + workflow_entry, "DifyGraphInitContext", return_value=SimpleNamespace(workflow_id="workflow-id") + ), + patch.object(workflow_entry, "RuntimeState", return_value=sentinel.runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeDatasourceNode), @@ -463,8 +468,10 @@ class TestWorkflowEntrySingleStepRun: "get_node_config_by_id", return_value=_build_typed_node_config(BuiltinNodeTypes.START), ), - patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), - patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), + patch.object( + workflow_entry, "DifyGraphInitContext", return_value=SimpleNamespace(workflow_id="workflow-id") + ), + patch.object(workflow_entry, "RuntimeState", return_value=sentinel.runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeNode), @@ -567,9 +574,9 @@ class TestWorkflowEntryHelpers: patch.object(workflow_entry, "VariablePool", return_value=sentinel.variable_pool) as variable_pool_cls, patch.object(workflow_entry, "add_variables_to_pool") as add_variables_to_pool, patch.object( - workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context + workflow_entry, "DifyGraphInitContext", return_value=SimpleNamespace(workflow_id="workflow-id") ) as graph_init_context_cls, - patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), + patch.object(workflow_entry, "RuntimeState", return_value=sentinel.runtime_state), patch.object( workflow_entry, "build_dify_run_context", return_value={"_dify": "context"} ) as build_dify_run_context, @@ -618,8 +625,8 @@ class TestWorkflowEntryHelpers: call_depth=0, ) dify_node_factory_cls.assert_called_once_with( - graph_init_context=sentinel.graph_init_context, - graph_runtime_state=sentinel.graph_runtime_state, + graph_init_context=ANY, + runtime_state=sentinel.runtime_state, ) mapping_user_inputs_to_variable_pool.assert_called_once_with( variable_mapping={}, @@ -655,8 +662,10 @@ class TestWorkflowEntryHelpers: patch.object(workflow_entry, "default_system_variables", return_value=sentinel.system_variables), patch.object(workflow_entry, "VariablePool", return_value=sentinel.variable_pool), patch.object(workflow_entry, "add_variables_to_pool"), - patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context), - patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state), + patch.object( + workflow_entry, "DifyGraphInitContext", return_value=SimpleNamespace(workflow_id="workflow-id") + ), + patch.object(workflow_entry, "RuntimeState", return_value=sentinel.runtime_state), patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}), patch.object(workflow_entry.time, "perf_counter", return_value=123.0), patch.object( @@ -761,7 +770,7 @@ class TestWorkflowEntryNodeLayers: ) class FakeNode: - graph_runtime_state = sentinel.graph_runtime_state + runtime_state = sentinel.runtime_state def bind_execution_id(self, _execution_id): return None @@ -775,14 +784,14 @@ class TestWorkflowEntryNodeLayers: patch.object(workflow_entry, "InMemoryChannel", return_value=sentinel.command_channel), patch.object( workflow_entry, - "ReadOnlyGraphRuntimeStateWrapper", + "ReadOnlyRuntimeStateWrapper", return_value=sentinel.read_only_runtime_state, ) as runtime_state_wrapper, ): events = list(workflow_entry.WorkflowEntry._run_node_with_layers(node, tenant_id="tenant-id")) assert events == [result_event] - runtime_state_wrapper.assert_called_once_with(sentinel.graph_runtime_state) + runtime_state_wrapper.assert_called_once_with(sentinel.runtime_state) for layer in (observability_layer,): layer.initialize.assert_called_once_with(sentinel.read_only_runtime_state, sentinel.command_channel) layer.on_graph_start.assert_called_once_with() @@ -794,7 +803,7 @@ class TestWorkflowEntryNodeLayers: observability_layer = MagicMock() class FakeNode: - graph_runtime_state = sentinel.graph_runtime_state + runtime_state = sentinel.runtime_state def bind_execution_id(self, _execution_id): return None @@ -808,7 +817,7 @@ class TestWorkflowEntryNodeLayers: patch.object(workflow_entry, "ObservabilityLayer", return_value=observability_layer), patch.object( workflow_entry, - "ReadOnlyGraphRuntimeStateWrapper", + "ReadOnlyRuntimeStateWrapper", return_value=sentinel.read_only_runtime_state, ), ): diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_redis_channel.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_redis_channel.py index 64852b6a12e..34575d1df98 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_redis_channel.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_redis_channel.py @@ -4,8 +4,8 @@ from unittest.mock import MagicMock, patch from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.workflow.workflow_entry import WorkflowEntry -from graphon.graph_engine.command_channels import RedisChannel -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.engine.command import RedisChannel +from graphon.runtime import RuntimeState, VariablePool class TestWorkflowEntryRedisChannel: @@ -17,14 +17,14 @@ class TestWorkflowEntryRedisChannel: mock_graph = MagicMock() mock_graph_config = {"nodes": [], "edges": []} variable_pool = VariablePool() - graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0) # Create a mock Redis channel mock_redis_client = MagicMock() redis_channel = RedisChannel(mock_redis_client, "test:channel:key") - # Patch GraphEngine to verify it receives the Redis channel - with patch("core.workflow.workflow_entry.GraphEngine", autospec=True) as MockGraphEngine: + # Patch Engine to verify it receives the Redis channel + with patch("core.workflow.workflow_entry.Engine", autospec=True) as MockGraphEngine: mock_graph_engine = MockGraphEngine.return_value # Create WorkflowEntry with Redis channel workflow_entry = WorkflowEntry( tenant_id="test-tenant", @@ -37,11 +37,11 @@ class TestWorkflowEntryRedisChannel: invoke_from=InvokeFrom.DEBUGGER, call_depth=0, variable_pool=variable_pool, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=redis_channel, # Provide Redis channel ) - # Verify GraphEngine was initialized with the Redis channel + # Verify Engine was initialized with the Redis channel MockGraphEngine.assert_called_once() call_args = MockGraphEngine.call_args[1] assert call_args["command_channel"] == redis_channel @@ -53,11 +53,11 @@ class TestWorkflowEntryRedisChannel: mock_graph = MagicMock() mock_graph_config = {"nodes": [], "edges": []} variable_pool = VariablePool() - graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0) - # Patch GraphEngine and InMemoryChannel + # Patch Engine and InMemoryChannel with ( - patch("core.workflow.workflow_entry.GraphEngine", autospec=True) as MockGraphEngine, + patch("core.workflow.workflow_entry.Engine", autospec=True) as MockGraphEngine, patch("core.workflow.workflow_entry.InMemoryChannel", autospec=True) as MockInMemoryChannel, ): mock_graph_engine = MockGraphEngine.return_value @@ -73,14 +73,14 @@ class TestWorkflowEntryRedisChannel: invoke_from=InvokeFrom.DEBUGGER, call_depth=0, variable_pool=variable_pool, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=None, # No channel provided ) # Verify InMemoryChannel was created MockInMemoryChannel.assert_called_once() - # Verify GraphEngine was initialized with the InMemory channel + # Verify Engine was initialized with the InMemory channel MockGraphEngine.assert_called_once() call_args = MockGraphEngine.call_args[1] assert call_args["command_channel"] == mock_inmemory_channel @@ -92,7 +92,7 @@ class TestWorkflowEntryRedisChannel: mock_graph = MagicMock() mock_graph_config = {"nodes": [], "edges": []} variable_pool = VariablePool() - graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=variable_pool, start_at=0) # Create a mock Redis channel mock_redis_client = MagicMock() @@ -102,8 +102,8 @@ class TestWorkflowEntryRedisChannel: mock_event1 = MagicMock() mock_event2 = MagicMock() - # Patch GraphEngine - with patch("core.workflow.workflow_entry.GraphEngine", autospec=True) as MockGraphEngine: + # Patch Engine + with patch("core.workflow.workflow_entry.Engine", autospec=True) as MockGraphEngine: mock_graph_engine = MagicMock() mock_graph_engine.run.return_value = iter([mock_event1, mock_event2]) MockGraphEngine.return_value = mock_graph_engine @@ -120,7 +120,7 @@ class TestWorkflowEntryRedisChannel: invoke_from=InvokeFrom.DEBUGGER, call_depth=0, variable_pool=variable_pool, - graph_runtime_state=graph_runtime_state, + runtime_state=runtime_state, command_channel=redis_channel, ) diff --git a/api/tests/unit_tests/extensions/test_workflow_warm_shutdown.py b/api/tests/unit_tests/extensions/test_workflow_warm_shutdown.py index ce774e4d160..88ca0a95cd5 100644 --- a/api/tests/unit_tests/extensions/test_workflow_warm_shutdown.py +++ b/api/tests/unit_tests/extensions/test_workflow_warm_shutdown.py @@ -6,7 +6,7 @@ import pytest from core.app.apps.workflow.active_workflow_tasks import reset_active_workflow_tasks from core.app.apps.workflow.command_channels import CelerySignalCommandChannel from extensions import workflow_warm_shutdown -from graphon.graph_engine.entities.commands import AbortCommand +from graphon.engine.command import AbortCommand @pytest.fixture(autouse=True) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_execution.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_execution.py index 7be11aaf69b..6dc0a917e91 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_execution.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_execution.py @@ -160,7 +160,7 @@ def test_ordinary_resume_cannot_rebuild_trace_for_native_marked_entity(monkeypat workflow=Mock(), user=Mock(), application_generate_entity=entity, - graph_runtime_state=Mock(), + runtime_state=Mock(), workflow_execution_repository=Mock(), workflow_node_execution_repository=Mock(), ) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_bridge.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_bridge.py index 7ca97248693..6956adf23f5 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_bridge.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_bridge.py @@ -1,10 +1,10 @@ """No ordinary collaborator is constructed for a marked native run.""" -from types import SimpleNamespace from unittest.mock import Mock import pytest +from graphon.runtime import InitParams from services.dify_builder.native_testing.context_binding import NativeContextUnavailable from services.dify_builder.native_testing.runtime_contracts import ResolvedNativeTestContext from tests.unit_tests.services.dify_builder.native_testing.test_runtime_contracts import CONTEXT @@ -61,8 +61,10 @@ def test_marked_factory_requires_bridge_before_eager_model_setup(monkeypatch): ) with pytest.raises(NativeContextUnavailable, match="native_context_missing"): node_factory.DifyNodeFactory( - graph_init_params=SimpleNamespace(run_context={"_dify": context}), - graph_runtime_state=object(), + init_params=InitParams( + run_context={"_dify": context}, workflow_id="workflow", graph_config={}, call_depth=0 + ), + runtime_state=object(), ) eager.assert_not_called() @@ -83,17 +85,19 @@ def test_factory_preserves_bridge_on_runtime_clone_and_checks_owners(monkeypatch native_test_required=True, native_test_ref=context.ref, ) - init = SimpleNamespace( + from graphon.runtime import InitParams + + init = InitParams( run_context={"_dify": run}, workflow_id=context.workflow_id, graph_config=context.graph, call_depth=0 ) - factory = node_factory.DifyNodeFactory(graph_init_params=init, graph_runtime_state=object(), native_runtime=bridge) + factory = node_factory.DifyNodeFactory(init_params=init, runtime_state=object(), native_runtime=bridge) clone = factory.with_runtime_state(object()) assert clone._native_runtime is bridge provider.assert_not_called() eager.assert_not_called() init.workflow_id = "foreign" with pytest.raises(NativeContextUnavailable, match="native_context_mismatch"): - node_factory.DifyNodeFactory(graph_init_params=init, graph_runtime_state=object(), native_runtime=bridge) + node_factory.DifyNodeFactory(init_params=init, runtime_state=object(), native_runtime=bridge) def test_unmarked_factory_cannot_accept_native_bridge(monkeypatch): @@ -108,8 +112,8 @@ def test_unmarked_factory_cannot_accept_native_bridge(monkeypatch): ) with pytest.raises(NativeContextUnavailable, match="native_context_missing"): node_factory.DifyNodeFactory( - graph_init_params=SimpleNamespace(run_context={"_dify": run}, workflow_id="w"), - graph_runtime_state=object(), + init_params=InitParams(run_context={"_dify": run}, workflow_id="w", graph_config={}, call_depth=0), + runtime_state=object(), native_runtime=bridge, ) eager.assert_not_called() @@ -119,7 +123,7 @@ def test_native_factory_constructs_real_registered_start_with_explicit_occurrenc from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory from graphon.nodes.start.start_node import StartNode - from graphon.runtime import GraphRuntimeState, VariablePool + from graphon.runtime import RuntimeState, VariablePool context, bridge, bundle, provider, occurrences, arguments = bridge_fixture() node = {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}} @@ -140,7 +144,7 @@ def test_native_factory_constructs_real_registered_start_with_explicit_occurrenc graph_init_context=DifyGraphInitContext( workflow_id=context.workflow_id, graph_config=context.graph, run_context={"_dify": run}, call_depth=0 ), - graph_runtime_state=GraphRuntimeState(variable_pool=VariablePool(), start_at=0), + runtime_state=RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0), native_runtime=bridge, ) created = factory.create_node(node) diff --git a/api/tests/unit_tests/services/dify_builder/test_template_execution.py b/api/tests/unit_tests/services/dify_builder/test_template_execution.py index e5f859f73e2..e7befb72438 100644 --- a/api/tests/unit_tests/services/dify_builder/test_template_execution.py +++ b/api/tests/unit_tests/services/dify_builder/test_template_execution.py @@ -13,7 +13,7 @@ import yaml from core.dify_builder.execution_policy import BuilderExecutionPolicyError, admitted_node_bindings, canonical_digest from core.workflow.restricted_execution import validate_restricted_node, validate_restricted_raw_node -from graphon.graph_events import GraphRunSucceededEvent, NodeRunSucceededEvent +from graphon.engine_events import GraphRunSucceededEvent, NodeRunSucceededEvent from graphon.nodes.template_transform.template_transform_node import TemplateTransformNode from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderError from tests.unit_tests.core.dify_builder.test_execution_policy import snapshot @@ -67,7 +67,7 @@ def renderer_from_factory(owned, template="{{ subject }}", names=("subject", "bo graph_config=template_graph(template, names), inputs=dict.fromkeys(names, "safe"), ) - node = factory.create_node(factory.graph_init_params.graph_config["nodes"][1]) + node = factory.create_node(factory.init_params.graph_config["nodes"][1]) assert type(node) is TemplateTransformNode renderer = node._jinja2_template_renderer assert isinstance(renderer, Jinja2TemplateRenderer) @@ -429,7 +429,7 @@ def test_native_template_class_version_and_raw_binding_are_rechecked(owned, muta "template_notification.yml", inputs={"subject": "Notice", "body": "Body"}, ) - raw = deepcopy(factory.graph_init_params.graph_config["nodes"][1]) + raw = deepcopy(factory.init_params.graph_config["nodes"][1]) node_class = TemplateTransformNode if mutation == "class": @@ -453,7 +453,7 @@ def test_native_template_class_version_and_raw_binding_are_rechecked(owned, muta context=context, node_id="template", node_class=node_class, raw_node_data=raw["data"] ) if mutation == "class": - data = TemplateTransformNode.validate_node_data(factory.graph_init_params.graph_config["nodes"][1]["data"]) + data = TemplateTransformNode.validate_node_data(factory.init_params.graph_config["nodes"][1]["data"]) with pytest.raises(BuilderExecutionPolicyError): validate_restricted_node( context=context, node_id="template", node_class=node_class, resolved_node_data=data @@ -471,7 +471,7 @@ def test_native_factory_rejects_template_raw_drift_before_native_validator(owned "template_notification.yml", inputs={"subject": "Notice", "body": "Body"}, ) - graph = deepcopy(factory.graph_init_params.graph_config) + graph = deepcopy(factory.init_params.graph_config) graph["nodes"][1]["data"]["template"] = "changed after launch" def forbidden(*_args, **_kwargs): @@ -485,8 +485,8 @@ def test_native_factory_rejects_template_raw_drift_before_native_validator(owned if entry == "graph-init": return Graph.init(graph_config=graph, node_factory=factory, root_node_id="start") return DifyNodeFactory( - factory.graph_init_params.model_copy(update={"graph_config": graph}), - factory.graph_runtime_state, + factory.init_params.model_copy(update={"graph_config": graph}), + factory.runtime_state, execution_recorder=recorder, ) diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py index 20104e3c9cb..23bb93de4bc 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py @@ -15,13 +15,13 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.apps.pipeline.pipeline_generator import PipelineGenerator from core.app.entities.app_invoke_entities import InvokeFrom from core.rag.index_processor.constant.index_type import IndexStructureType +from graphon.engine_events import NodeRunFailedEvent from graphon.enums import ( BuiltinNodeTypes, ErrorStrategy, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus, ) -from graphon.graph_events import NodeRunFailedEvent from graphon.node_events.base import NodeRunResult from models import Account, Tenant from models.dataset import ( @@ -223,7 +223,7 @@ def _make_failed_published_node_run() -> tuple[SimpleNamespace, NodeRunFailedEve workflow_id="wf-1", node_type="start", title="Start", - graph_runtime_state=SimpleNamespace(variable_pool=FakeVariablePool()), + runtime_state=SimpleNamespace(variable_pool=FakeVariablePool()), error_strategy=None, ) run_result = NodeRunResult( @@ -829,7 +829,7 @@ def test_run_datasource_node_preview_online_document( def test_handle_node_run_result_success( mocker: MockerFixture, rag_pipeline_service: RagPipelineServiceTestContext ) -> None: - from graphon.graph_events import NodeRunSucceededEvent + from graphon.engine_events import NodeRunSucceededEvent from graphon.node_events.base import NodeRunResult # 1. Setup mock node and result @@ -1429,7 +1429,7 @@ def test_handle_node_run_result_default_value_strategy( ) -> None: from datetime import datetime - from graphon.graph_events import NodeRunFailedEvent + from graphon.engine_events import NodeRunFailedEvent from graphon.node_events.base import NodeRunResult node_instance = SimpleNamespace( @@ -1438,7 +1438,7 @@ def test_handle_node_run_result_default_value_strategy( title="Start", error_strategy=ErrorStrategy.DEFAULT_VALUE, default_value_dict={"fallback": "ok"}, - graph_runtime_state=SimpleNamespace(variable_pool=mocker.Mock()), + runtime_state=SimpleNamespace(variable_pool=mocker.Mock()), ) failed_result = NodeRunResult( @@ -1854,7 +1854,7 @@ def test_handle_node_run_result_raises_when_no_terminal_event( workflow_id="wf-1", node_type="start", title="Start", - graph_runtime_state=SimpleNamespace(variable_pool=SimpleNamespace(get=lambda _: None)), + runtime_state=SimpleNamespace(variable_pool=SimpleNamespace(get=lambda _: None)), error_strategy=None, ) diff --git a/api/tests/unit_tests/services/test_app_task_control_service.py b/api/tests/unit_tests/services/test_app_task_control_service.py index 40cc19ff98a..a0aedcd22d4 100644 --- a/api/tests/unit_tests/services/test_app_task_control_service.py +++ b/api/tests/unit_tests/services/test_app_task_control_service.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest from extensions.ext_redis import RedisClientWrapper +from graphon.engine.command import AbortCommand from services.app_task_service import AppTaskControlService @@ -18,35 +19,33 @@ def test_workflow_stop_sets_flag_before_sending_command( commands.side_effect = lambda *_, **__: events.append("flag") failure = RuntimeError("command channel unavailable") - def send_stop(requested_task_id: str) -> None: - assert requested_task_id == task_id + def send_stop(command: AbortCommand) -> None: + assert command == AbortCommand(reason="User requested stop") events.append("command") if command_fails: raise failure - with patch("services.app_task_service.GraphEngineManager") as manager: - manager.return_value.send_stop_command.side_effect = send_stop + with patch("services.workflow.command_service.RedisChannel") as manager: + manager.return_value.send_command.side_effect = send_stop service = AppTaskControlService(redis_client=redis) - if command_fails: - with pytest.raises(RuntimeError) as caught: - service.stop_workflow_task_no_user_check(task_id=task_id) - assert caught.value is failure + service.stop_workflow_task_no_user_check(task_id=task_id) + if task_id: + manager.assert_called_once_with(redis, f"workflow:{task_id}:commands") else: - service.stop_workflow_task_no_user_check(task_id=task_id) - manager.assert_called_once_with(redis) + manager.assert_not_called() if task_id: assert events == ["flag", "command"] commands.assert_called_once_with("SETEX", "generate_task_stopped:task-1", 600, 1) else: - assert events == ["command"] + assert events == [] commands.assert_not_called() def test_flag_failure_does_not_send_command(redis_transport: tuple[RedisClientWrapper, MagicMock]) -> None: redis, commands = redis_transport commands.side_effect = RuntimeError("flag store unavailable") - with patch("services.app_task_service.GraphEngineManager") as manager: + with patch("services.workflow.command_service.RedisChannel") as manager: with pytest.raises(RuntimeError, match="flag store unavailable"): AppTaskControlService(redis_client=redis).stop_workflow_task_no_user_check(task_id="task-1") manager.assert_not_called() diff --git a/api/tests/unit_tests/services/test_app_task_service.py b/api/tests/unit_tests/services/test_app_task_service.py index 603dcc9509b..ea635373d78 100644 --- a/api/tests/unit_tests/services/test_app_task_service.py +++ b/api/tests/unit_tests/services/test_app_task_service.py @@ -26,7 +26,7 @@ _COMMAND_KEY = f"workflow:{_TASK_ID}:commands" @dataclass class _StopRedis(RedisClientWrapper): - """In-memory Redis boundary; task policy and GraphEngine serialization stay real.""" + """In-memory Redis boundary; task policy and Engine serialization stay real.""" values: dict[str, bytes] = field(default_factory=dict) commands: dict[str, list[str]] = field(default_factory=dict) @@ -93,13 +93,13 @@ def 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(queue_module, "redis_client", redis) monkeypatch.setattr(coordinator_module, "redis_client", redis) monkeypatch.setattr(task_module, "redis_client", redis) yield redis - # GraphEngine catches Redis failures, so a trap exception alone would not fail the test. + # Engine catches Redis failures, so a trap exception alone would not fail the test. assert redis.reads == [] assert redis.operations == [] @@ -112,7 +112,7 @@ def _assert_stop_flag(redis: _StopRedis) -> None: def _assert_graph_command(redis: _StopRedis) -> None: assert set(redis.commands) == {_COMMAND_KEY} assert [json.loads(command) for command in redis.commands[_COMMAND_KEY]] == [ - {"command_type": "abort", "payload": None, "reason": "User requested stop"} + {"command_type": "abort", "reason": "User requested stop"} ] assert redis.expirations[_COMMAND_KEY] == 3600 assert redis.values[f"{_COMMAND_KEY}:pending"] == b"1" @@ -231,7 +231,7 @@ def test_graph_redis_failure_is_swallowed_after_legacy_flag(unchecked: bool, cap _assert_stop_flag(redis) assert redis.operations == ["legacy_flag", "graph_command"] assert redis.commands == {} - assert "Failed to send graph engine command AbortCommand" in caplog.text + assert "Failed to send stop command for workflow task" in caplog.text def test_ownership_read_failure_propagates_without_either_stop_signal() -> None: diff --git a/api/tests/unit_tests/services/test_draft_tool_admission.py b/api/tests/unit_tests/services/test_draft_tool_admission.py index 28ac187b815..57f27117522 100644 --- a/api/tests/unit_tests/services/test_draft_tool_admission.py +++ b/api/tests/unit_tests/services/test_draft_tool_admission.py @@ -677,7 +677,7 @@ def native_tool_node(case, *, workflow_id=None): """Construct the real Tool through the production native factory and scope.""" from core.app.entities.app_invoke_entities import UserFrom, build_dify_run_context from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory - from graphon.runtime import GraphRuntimeState, VariablePool + from graphon.runtime import RuntimeState, VariablePool pool = VariablePool() factory = DifyNodeFactory.from_graph_init_context( @@ -693,7 +693,7 @@ def native_tool_node(case, *, workflow_id=None): ), call_depth=0, ), - graph_runtime_state=GraphRuntimeState(variable_pool=pool, start_at=0.0), + runtime_state=RuntimeState(workflow_id="test-workflow", variable_pool=pool, start_at=0.0), ) return factory.create_node(case.workflow.get_node_config_by_id("t")), pool @@ -1144,7 +1144,7 @@ def paused_native_draft(native_draft, sqlite_session, monkeypatch): from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext from graphon.enums import WorkflowExecutionStatus - from graphon.runtime import GraphRuntimeState, VariablePool + from graphon.runtime import RuntimeState, VariablePool from models.enums import ConversationFromSource, CreatorUserRole, WorkflowRunTriggeredFrom from models.model import Conversation, Message from models.workflow import WorkflowPause, WorkflowRun @@ -1253,7 +1253,9 @@ def paused_native_draft(native_draft, sqlite_session, monkeypatch): def save_context(): context = WorkflowResumptionContext( generate_entity={"type": state.mode, "entity": state.entity}, - serialized_graph_runtime_state=GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0).dumps(), + serialized_graph_runtime_state=RuntimeState( + workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0 + ).dumps(), ) state.saved_blob = context.dumps().encode() diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index 840ac115c64..bbb61e3983a 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -32,7 +32,7 @@ from core.workflow.nodes.human_input.entities import ( ) from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType from graphon.file import File, FileTransferMethod, FileType -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from libs.datetime_utils import naive_utc_now from models.human_input import HumanInputDelivery, HumanInputForm, HumanInputFormRecipient, RecipientType from models.model import App, AppMode @@ -287,7 +287,7 @@ def _build_resumption_context_state(*, options: list[str], workflow_run_id: str) 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) context = WorkflowResumptionContext( generate_entity=_WorkflowGenerateEntityWrapper(entity=generate_entity), diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index b3c16068912..477487e597d 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -26,6 +26,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.workflow.llm_environment_variable import LLMEnvironmentVariable from enums import DeploymentEdition +from graphon.engine_events import NodeRunFailedEvent, NodeRunSucceededEvent from graphon.enums import ( BuiltinNodeTypes, ErrorStrategy, @@ -33,7 +34,6 @@ from graphon.enums import ( WorkflowNodeExecutionStatus, ) from graphon.errors import WorkflowNodeRunFailedError -from graphon.graph_events import NodeRunFailedEvent, NodeRunSucceededEvent from graphon.model_runtime.entities.model_entities import ModelType from graphon.node_events import NodeRunResult from graphon.nodes.http_request import HTTP_REQUEST_CONFIG_FILTER_KEY, HttpRequestNode, HttpRequestNodeConfig diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index e52ce4ca02d..cc5499e5b9d 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -26,7 +26,7 @@ from core.workflow.nodes.human_input.entities import SelectInputConfig, StringLi from core.workflow.nodes.human_input.enums import ValueSourceType from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import RuntimeState, VariablePool from libs.broadcast_channel.exc import SubscriptionClosedError from libs.datetime_utils import to_utc_timestamp from models.enums import ConversationFromSource, CreatorUserRole @@ -141,7 +141,7 @@ def _build_resumption_context(task_id: str, *, select_options: list[str] | None 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) if select_options is not None: runtime_state.variable_pool.add(("start", "options"), select_options) runtime_state.set_output("result", "value") @@ -248,7 +248,7 @@ def _build_resumption_context_additional(task_id: str) -> WorkflowResumptionCont 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("answer", "ok") wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) return WorkflowResumptionContext( @@ -277,7 +277,7 @@ def _build_advanced_chat_resumption_context(conversation_id: str | None) -> Work workflow_run_id="run-1", query="hello", ) - runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0) wrapper = _AdvancedChatAppGenerateEntityWrapper(entity=generate_entity) return WorkflowResumptionContext( generate_entity=wrapper, diff --git a/api/tests/unit_tests/tasks/test_workflow_execute_task.py b/api/tests/unit_tests/tasks/test_workflow_execute_task.py index 2dca4233c78..f0f396fad09 100644 --- a/api/tests/unit_tests/tasks/test_workflow_execute_task.py +++ b/api/tests/unit_tests/tasks/test_workflow_execute_task.py @@ -858,7 +858,7 @@ def test_resume_app_execution_queries_message_by_conversation_and_workflow_run( lambda *_args, **_kwargs: resumption_context, ) monkeypatch.setattr( - "tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", + "tasks.app_generate.workflow_execute_task.RuntimeState.from_snapshot", lambda *_args, **_kwargs: MagicMock(), ) @@ -906,7 +906,7 @@ def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversat lambda *_args, **_kwargs: resumption_context, ) monkeypatch.setattr( - "tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", + "tasks.app_generate.workflow_execute_task.RuntimeState.from_snapshot", lambda *_args, **_kwargs: MagicMock(), ) @@ -957,7 +957,7 @@ def test_resume_app_execution_clears_stale_cancellation_signals_before_resuming( lambda *_args, **_kwargs: resumption_context, ) monkeypatch.setattr( - "tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", + "tasks.app_generate.workflow_execute_task.RuntimeState.from_snapshot", lambda *_args, **_kwargs: MagicMock(), ) monkeypatch.setattr( @@ -1008,7 +1008,7 @@ def test_resume_app_execution_keeps_cancellation_signals_when_resume_is_abandone lambda *_args, **_kwargs: resumption_context, ) monkeypatch.setattr( - "tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", + "tasks.app_generate.workflow_execute_task.RuntimeState.from_snapshot", lambda *_args, **_kwargs: MagicMock(), ) monkeypatch.setattr( @@ -1060,7 +1060,7 @@ def test_resume_advanced_chat_publishes_events_for_originally_blocking_runs( conversation=_make_conversation(), message=_make_message(), generate_entity=generate_entity, - graph_runtime_state=MagicMock(), + runtime_state=MagicMock(), response_stream_filter=MagicMock(), session_factory=sqlite_session_factory, pause_state_config=MagicMock(), @@ -1117,7 +1117,7 @@ def test_resume_workflow_publishes_events_for_originally_blocking_runs( workflow=workflow, user=_make_account(), generate_entity=generate_entity, - graph_runtime_state=MagicMock(), + runtime_state=MagicMock(), response_stream_filter=MagicMock(), session_factory=sqlite_session_factory, pause_state_config=MagicMock(), @@ -1176,7 +1176,7 @@ def test_resume_workflow_ignores_missing_old_pause_after_repause( workflow=workflow, user=_make_account(), generate_entity=generate_entity, - graph_runtime_state=MagicMock(), + runtime_state=MagicMock(), response_stream_filter=MagicMock(), session_factory=sqlite_session_factory, pause_state_config=MagicMock(), diff --git a/api/tests/workflow_test_utils.py b/api/tests/workflow_test_utils.py index 1415bb1d521..a07d8724b2c 100644 --- a/api/tests/workflow_test_utils.py +++ b/api/tests/workflow_test_utils.py @@ -3,8 +3,7 @@ from typing import Any from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool -from graphon.entities import GraphInitParams -from graphon.runtime import VariablePool +from graphon.runtime import InitParams, VariablePool from graphon.variables.variables import Variable @@ -40,8 +39,8 @@ def build_test_graph_init_params( user_from: UserFrom | str = UserFrom.ACCOUNT, invoke_from: InvokeFrom | str = InvokeFrom.DEBUGGER, extra_context: Mapping[str, Any] | None = None, -) -> GraphInitParams: - return GraphInitParams( +) -> InitParams: + return InitParams( workflow_id=workflow_id, graph_config=graph_config or {}, run_context=build_test_run_context( diff --git a/api/uv.lock b/api/uv.lock index a833e249a79..6af1075f257 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = "==3.13.*" resolution-markers = [ "sys_platform == 'win32'", @@ -1392,7 +1392,6 @@ docs = [ name = "dify-api" version = "1.17.1" source = { virtual = "." } -default-groups = ["kms", "storage", "tools", "trace-all", "vdb-all"] dependencies = [ { name = "aliyun-log-python-sdk" }, { name = "azure-identity" }, @@ -1428,6 +1427,8 @@ dependencies = [ { name = "opentelemetry-propagator-b3" }, { name = "psycogreen" }, { name = "psycopg2-binary" }, + { name = "pypandoc", marker = "sys_platform == 'win32'" }, + { name = "pypandoc-binary", marker = "sys_platform != 'win32'" }, { name = "python-socketio" }, { name = "readabilipy" }, { name = "redis", extra = ["hiredis"] }, @@ -1704,7 +1705,7 @@ requires-dist = [ { name = "gmpy2", specifier = ">=2.3.0,<3.0.0" }, { name = "google-api-python-client", specifier = ">=2.201.0,<3.0.0" }, { name = "google-cloud-aiplatform", specifier = ">=2.3.0,<3.0.0" }, - { name = "graphon", specifier = "==0.7.0" }, + { name = "graphon", specifier = "==0.8.0" }, { name = "gunicorn", specifier = ">=26.2.0,<27.0.0" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "httpx-sse", specifier = "==0.4.3" }, @@ -1718,6 +1719,8 @@ requires-dist = [ { name = "opentelemetry-propagator-b3", specifier = ">=1.45.0,<2.0.0" }, { name = "psycogreen", specifier = ">=1.0.2,<2.0.0" }, { name = "psycopg2-binary", specifier = ">=2.9.12,<3.0.0" }, + { name = "pypandoc", marker = "sys_platform == 'win32'", specifier = "==1.17" }, + { name = "pypandoc-binary", marker = "sys_platform != 'win32'", specifier = "==1.17" }, { name = "python-socketio", specifier = ">=5.13.0,<6.0.0" }, { name = "readabilipy", specifier = "==0.3.0" }, { name = "redis", extras = ["hiredis"], specifier = ">=7.4.0,<8.0.0" }, @@ -3081,18 +3084,19 @@ httpx = [ [[package]] name = "graphon" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, + { name = "defusedxml" }, { name = "httpx" }, { name = "json-repair" }, { name = "jsonschema" }, + { name = "odfdo" }, { name = "orjson" }, { name = "pandas", extra = ["excel"] }, { name = "pydantic" }, { name = "pydantic-extra-types" }, - { name = "pypandoc" }, { name = "pypdf" }, { name = "pypdfium2" }, { name = "python-docx" }, @@ -3100,12 +3104,12 @@ dependencies = [ { name = "tiktoken" }, { name = "transformers" }, { name = "typing-extensions" }, - { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, + { name = "unstructured", extra = ["docx", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/c6/6f16398e28bceb11304f8dd743d5f4f2c1e1aedf20eef6531764edb40a7e/graphon-0.7.0.tar.gz", hash = "sha256:e3f19284432d6e4a947fb285ef36bc79eb48c41af36867b03c0d70f062fe8563", size = 266715, upload-time = "2026-07-29T10:00:51.303Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/82/a2ded2b6a7879e7ee855936db7410014cd41c1c709155a1ccfe07e208633/graphon-0.8.0.tar.gz", hash = "sha256:654ad8f5cb7ff1711cf6b09e68a6afc7fe1e552b2f8306babdb848142d0a6bf7", size = 281821, upload-time = "2026-09-09T20:03:19.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/9b/3b02d944a20b3693bb5bb77cbb16b3afed44a965096970c47625560c0235/graphon-0.7.0-py3-none-any.whl", hash = "sha256:6f7608aaaa65607c4935735137ed4b7029b1e6ca80202cbe829af3370cf4404e", size = 380138, upload-time = "2026-07-29T10:00:49.654Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8d/64552a2dc8ef4d1b0c43d927301000533be75d6c51664d5a33fb1ead00ff/graphon-0.8.0-py3-none-any.whl", hash = "sha256:183b668cdd2b1b6c39e75e68778994855400c9a8e314e0bda3b59a2b0fe7e8a0", size = 400203, upload-time = "2026-09-09T20:03:18.198Z" }, ] [[package]] @@ -3379,8 +3383,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -4360,6 +4364,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] +[[package]] +name = "odfdo" +version = "3.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/66/d1d13aad5f8466a70a230ba4709bb1b88a2cafedd34b6b4e63d785f6ae52/odfdo-3.27.1.tar.gz", hash = "sha256:79f37dc0cc1865544dd3a24ebc595ea89c6e0323848675a9f27f812e10f76ae6", size = 341759, upload-time = "2026-10-10T11:43:07.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/f6/04aa6e83654413508b0069f72f074788c138f0f3ccf0f8192d06f63f95b4/odfdo-3.27.1-py3-none-any.whl", hash = "sha256:3524b650815b4c5032e47bc5db14197b1013c5d22424f81a8b2337bc9bd22199", size = 439892, upload-time = "2026-10-10T11:43:05.405Z" }, +] + [[package]] name = "odfpy" version = "1.4.1" @@ -7464,9 +7481,6 @@ wheels = [ docx = [ { name = "python-docx" }, ] -epub = [ - { name = "pypandoc-binary", marker = "sys_platform != 'win32'" }, -] md = [ { name = "markdown" }, ]