From f5050842fe270eab0fe63a7bd99fc03a9accfda0 Mon Sep 17 00:00:00 2001 From: Charles Yao Date: Tue, 20 Oct 2026 17:33:54 -0700 Subject: [PATCH] Fix Builder migration portability and typed backend CI contracts --- .github/scripts/builder-native-runtime-ci.sh | 4 +- .../builder-safe-execution-tests.yml | 154 +++- .github/workflows/db-migration-test.yml | 2 + .github/workflows/dify-agent-tests.yml | 12 + .../inner_api/plugin/agent_config.py | 6 +- api/controllers/inner_api/wraps.py | 4 +- api/core/workflow/generator/tool_catalogue.py | 6 +- api/migrations/native_testing.py | 80 ++ ...w_draft_varaibles_add_node_execution_id.py | 38 +- ...a_remove_sequence_number_from_workflow_.py | 47 +- ...27822d22895_add_workflow_comments_table.py | 134 +-- ...00-d4a5e1f3c9b7_add_oauth_access_tokens.py | 8 +- ...e7b9c10_scope_agent_debug_conversations.py | 37 +- ...57_replace_agent_runtime_sessions_with_.py | 283 +++--- ...rename_workflow_copilot_to_dify_builder.py | 29 +- ...a9b2e6d4_add_dataset_api_token_bindings.py | 7 +- ...c67e12a904bf_add_builder_native_testing.py | 30 +- ...3c912a_add_native_continuation_dispatch.py | 23 +- ...d2e8_bind_native_invocation_read_owners.py | 51 +- ...2a6c1_add_native_effect_dispatch_claims.py | 24 +- ...c4e61a8_add_native_code_physical_owners.py | 109 ++- api/models/dify_builder.py | 165 ++-- api/models/dify_builder_native_tests.py | 76 +- api/services/dify_builder/agent/llm.py | 10 +- api/services/dify_builder/agent/resources.py | 27 +- .../dify_builder/application/ports.py | 14 + .../dify_builder/application/runner.py | 13 +- .../dify_builder/condition_evidence.py | 41 +- .../dify_builder/conversation_tasks.py | 6 +- api/services/dify_builder/dify_port.py | 33 +- .../dify_builder/execution_policy_service.py | 124 +-- .../dify_builder/json_tool_metadata.py | 54 +- .../native_testing/agent_binding.py | 2 +- .../native_testing/agent_bridge.py | 14 +- .../native_testing/agent_config_access.py | 4 +- .../agent_confirmed_workspace.py | 8 +- .../native_testing/agent_lineage.py | 2 +- .../native_testing/agent_operations.py | 64 +- .../native_testing/agent_preparation.py | 5 +- .../native_testing/agent_runtime_config.py | 8 +- .../native_testing/agent_tool_declarations.py | 3 +- .../native_testing/authorization.py | 25 +- .../native_testing/child_invocations.py | 2 + .../confirmed_agent_cancellation.py | 2 +- .../confirmed_agent_commands.py | 13 +- .../confirmed_agent_completion.py | 25 +- .../confirmed_agent_continuation.py | 11 +- .../native_testing/context_runtime.py | 12 +- .../dify_builder/native_testing/control.py | 9 +- .../native_testing/generation_wiring.py | 4 +- .../native_testing/host_capacity.py | 7 +- .../native_testing/host_execution.py | 68 +- .../native_testing/host_lifetime.py | 9 +- .../native_testing/http_binding.py | 45 +- .../dify_builder/native_testing/lifecycle.py | 6 +- .../native_testing/native_execution.py | 12 +- .../native_testing/observation_owner.py | 6 +- .../native_testing/observation_privacy.py | 9 +- .../native_testing/observation_repository.py | 30 +- .../native_testing/observation_stream.py | 12 +- .../native_testing/pending_intents.py | 8 +- .../dify_builder/native_testing/runtime.py | 4 +- .../native_testing/runtime_bridge.py | 32 +- .../native_testing/sandbox/contracts.py | 3 +- .../native_testing/sandbox/qualification.py | 7 +- .../native_testing/sandbox/repository.py | 8 +- .../native_testing/state_store.py | 32 +- .../native_testing/stop_settlement.py | 13 +- .../dify_builder/resource_grounding.py | 10 +- api/services/dify_builder/run_mapping.py | 25 +- .../dify_builder/template_execution.py | 5 +- .../dify_builder/trace_diagnostics.py | 16 +- api/services/workflow_runtime/agent_node.py | 4 +- api/services/workflow_runtime/node_factory.py | 10 +- .../gmail_confirmed_read.yml | 88 +- .../http_default_output.yml | 84 +- .../workflow/builder_restricted/http_text.yml | 68 +- .../workflow/builder_restricted/scalar.yml | 34 +- .../conftest.py | 16 +- .../builder_native_fixture_records.py | 185 ++++ .../builder_native_runtime_diagnostics.py | 43 +- .../test_builder_agent_create_recovery.py | 9 +- .../workflow/test_builder_agent_workspace.py | 4 +- .../test_builder_confirmed_tool_native.py | 25 +- ...st_builder_execution_policy_persistence.py | 90 +- .../workflow/test_builder_json_tool_native.py | 5 +- .../test_builder_native_agent_associations.py | 47 +- .../test_builder_native_approval_scope.py | 4 +- ...uilder_native_claimed_resume_checkpoint.py | 13 +- .../workflow/test_builder_native_code_host.py | 7 + ...test_builder_native_code_physical_state.py | 18 +- .../test_builder_native_console_api.py | 53 +- .../test_builder_native_continuation.py | 29 +- .../test_builder_native_dispatch_claims.py | 11 +- .../test_builder_native_dispatch_fence.py | 39 +- .../test_builder_native_host_capacity.py | 30 +- .../test_builder_native_http_binding.py | 31 +- ...test_builder_native_observation_history.py | 13 +- .../workflow/test_builder_native_runtime.py | 14 +- .../test_builder_restricted_native.py | 91 +- .../test_builder_native_migration_dialects.py | 341 ++++++++ .../nodes/code_executor/test_utils.py | 18 +- .../commands/test_check_no_new_getattr.py | 7 +- .../console/app/test_generator_api.py | 68 +- .../controllers/console/app/test_workflow.py | 16 +- .../test_builder_native_testing_routes.py | 19 +- .../console/test_dify_builder_routes.py | 554 +++++++----- .../controllers/console/test_wraps.py | 3 +- .../test_agent_config_runtime_access.py | 4 +- .../controllers/inner_api/test_agent_llm.py | 16 +- .../inner_api/test_agent_runtime_lineage.py | 120 ++- .../apps/advanced_chat/test_app_generator.py | 158 ++-- .../test_generate_task_pipeline.py | 52 +- .../apps/test_builder_execution_admission.py | 293 ++++--- .../app/apps/test_workflow_app_generator.py | 43 +- .../app/apps/test_workflow_app_runner_core.py | 77 +- .../core/app/workflow/test_node_factory.py | 6 +- .../unit_tests/core/dify_builder/fakes.py | 4 +- .../test_effect_confirmation_gate.py | 32 +- .../dify_builder/test_execution_evidence.py | 44 +- .../core/dify_builder/test_handlers_build.py | 11 +- .../core/dify_builder/test_handlers_edit.py | 7 +- .../core/dify_builder/test_handlers_fix.py | 13 +- .../core/dify_builder/test_node_defaults.py | 27 +- .../core/dify_builder/test_planning_source.py | 4 +- .../dify_builder/test_publication_gate.py | 86 +- .../core/dify_builder/test_testdata.py | 26 +- .../core/dify_builder/test_workflow_events.py | 14 +- .../unit_tests/core/helper/test_encrypter.py | 8 +- .../core/ops/test_ops_trace_manager.py | 9 +- .../core/plugin/impl/test_oauth_handler.py | 15 +- .../core/plugin/impl/test_tool_manager.py | 18 +- ...hemy_workflow_node_execution_repository.py | 10 +- .../plugin_tool/test_confirmed_plugin_tool.py | 93 +- .../core/tools/test_confirmed_tool_runtime.py | 15 +- .../core/tools/test_tool_manager.py | 68 +- .../test_native_agent_output_contract.py | 61 +- .../core/workflow/generator/test_runner.py | 79 +- .../test_builder_restricted_native.py | 10 +- .../agent_v2/test_confirmed_agent_node.py | 353 ++++++-- .../core/workflow/test_node_factory.py | 28 +- .../workflow/test_node_mapping_bootstrap.py | 8 +- .../core/workflow/test_node_runtime.py | 271 +++--- .../workflow/test_restricted_execution.py | 70 +- .../core/workflow/test_template_rendering.py | 18 +- api/tests/unit_tests/database_fixtures.py | 15 + .../unit_tests/extensions/test_ext_logging.py | 7 +- .../redis/test_channel_unit_tests.py | 7 +- .../key_providers/test_rsa_key_provider.py | 6 +- .../libs/test_sensitive_http_telemetry.py | 7 +- api/tests/unit_tests/libs/test_token.py | 14 +- api/tests/unit_tests/logging_helpers.py | 24 + .../test_agent_workspace_migration.py | 19 + .../test_builder_native_offline_migrations.py | 105 +++ .../test_builder_rename_migration.py | 47 + .../models/test_dify_builder_typed_models.py | 117 +++ .../test_workflow_draft_sync_repository.py | 9 +- .../agent/test_workflow_run_recovery.py | 12 +- .../data_migration/test_import_service.py | 38 +- .../dify_builder/draft_tool_inventory.py | 9 +- .../dify_builder/installed_tool_inventory.py | 23 +- .../dify_builder/native_owner_fixtures.py | 60 ++ .../native_testing/test_agent_associations.py | 71 +- .../test_agent_risk_assessor.py | 28 +- .../native_testing/test_authorization.py | 31 +- .../test_claimed_resume_checkpoint.py | 99 ++- .../native_testing/test_code_executor.py | 112 ++- .../native_testing/test_code_host.py | 21 +- .../test_code_physical_repository.py | 358 +++++--- .../native_testing/test_code_qualification.py | 19 +- .../test_confirmed_agent_commands.py | 300 +++++-- .../test_confirmed_agent_completion.py | 318 +++++-- .../test_confirmed_agent_continuation.py | 48 +- .../native_testing/test_context_binding.py | 120 ++- .../native_testing/test_dispatch_claims.py | 4 +- .../native_testing/test_dispatch_fence.py | 368 ++++++-- .../native_testing/test_host_capacity.py | 364 ++++++-- .../test_host_generation_wiring.py | 32 +- .../native_testing/test_http_adapter.py | 13 +- .../native_testing/test_http_binding.py | 104 ++- .../native_testing/test_lifecycle.py | 15 +- .../native_testing/test_native_execution.py | 336 +++++--- .../test_native_observation_wiring.py | 101 ++- .../test_native_resume_scheduling.py | 95 +- .../native_testing/test_native_runtime.py | 242 ++++-- .../native_testing/test_node_inputs.py | 106 ++- .../native_testing/test_observation_owner.py | 138 ++- .../test_observation_persistence.py | 20 +- .../test_observation_repository.py | 97 ++- .../native_testing/test_observation_stream.py | 162 ++-- .../native_testing/test_operation_journal.py | 58 +- .../test_pending_decision_guard.py | 15 +- .../test_public_decision_projection.py | 9 +- .../test_public_input_binding.py | 30 +- .../native_testing/test_runtime_bridge.py | 234 +++-- .../native_testing/test_runtime_contracts.py | 15 +- .../native_testing/test_stop_sealing.py | 124 ++- .../native_testing/test_stop_settlement.py | 14 +- .../native_testing/test_stream_runtime.py | 16 +- .../test_zero_http_admission.py | 146 ++-- .../test_agent_asset_operations.py | 223 +++-- .../dify_builder/test_agent_config_access.py | 60 +- .../test_agent_confirmed_workspace.py | 106 ++- .../test_agent_endpoint_lineage.py | 203 +++-- .../dify_builder/test_agent_factory.py | 3 +- .../test_agent_model_result_canonical.py | 48 +- .../test_agent_operation_owner.py | 357 +++++--- .../test_agent_preparation_owner.py | 816 +++++++++++------- .../dify_builder/test_agent_test_workspace.py | 181 ++-- .../test_agent_tool_declarations.py | 513 +++++++---- .../services/dify_builder/test_app_naming.py | 146 ++-- .../dify_builder/test_build_cognition.py | 8 +- .../dify_builder/test_code_risk_assessment.py | 36 +- .../test_code_risk_preparation.py | 9 +- .../dify_builder/test_condition_evidence.py | 218 +++-- .../dify_builder/test_confirmed_full_run.py | 73 +- .../test_confirmed_tool_launch.py | 58 +- .../dify_builder/test_conflict_chain.py | 12 +- .../services/dify_builder/test_dify_port.py | 627 +++++++++----- .../dify_builder/test_dify_port_preflight.py | 100 ++- .../dify_builder/test_draft_tool_setup.py | 38 +- .../dify_builder/test_edit_plan_lifecycle.py | 4 +- .../test_effect_account_bindings.py | 93 +- .../services/dify_builder/test_effect_mock.py | 12 +- .../test_execution_policy_contexts.py | 4 +- .../test_execution_policy_service.py | 13 +- .../services/dify_builder/test_identity.py | 51 +- .../dify_builder/test_initial_chatflow.py | 107 ++- .../dify_builder/test_json_tool_execution.py | 26 +- .../dify_builder/test_json_tool_metadata.py | 2 +- .../dify_builder/test_json_tool_runtime.py | 31 +- .../dify_builder/test_model_resolver.py | 92 +- .../dify_builder/test_native_agent_binding.py | 10 +- .../dify_builder/test_native_agent_catalog.py | 20 +- .../test_native_execution_admission.py | 103 ++- .../test_native_http_output_contract.py | 59 +- .../test_native_runtime_failure.py | 20 +- .../dify_builder/test_notice_retirement.py | 7 +- .../dify_builder/test_output_evidence.py | 43 +- .../dify_builder/test_plan_history.py | 8 +- .../test_plugin_activation_diagnostics.py | 3 +- .../dify_builder/test_progress_bus.py | 81 +- .../dify_builder/test_progress_snapshot.py | 28 +- .../test_real_call_consent_service.py | 25 +- .../dify_builder/test_real_call_metadata.py | 6 +- .../services/dify_builder/test_recovery.py | 13 +- .../dify_builder/test_resource_grounding.py | 27 +- .../services/dify_builder/test_resources.py | 58 +- .../services/dify_builder/test_revision.py | 87 +- .../services/dify_builder/test_run_mapping.py | 27 +- .../services/dify_builder/test_service.py | 6 +- .../dify_builder/test_service_model_config.py | 68 +- .../dify_builder/test_session_lock.py | 3 +- .../test_session_ui_localization.py | 4 +- .../dify_builder/test_template_execution.py | 7 +- .../dify_builder/test_trace_diagnostics.py | 89 +- .../services/dify_builder/test_wiring.py | 99 ++- .../services/test_agent_tool_inner_service.py | 10 +- ...test_app_generate_service_agent_context.py | 47 +- .../test_app_generate_service_in_process.py | 131 +-- .../services/test_draft_tool_admission.py | 168 ++-- .../test_feature_service_dify_builder.py | 10 +- .../test_workflow_draft_sync_service.py | 57 +- .../test_builder_credential_reference.py | 25 +- .../tools/test_ordinary_plugin_credentials.py | 243 ++++-- .../unit_tests/tasks/test_ops_trace_task.py | 19 +- .../test_remove_app_and_related_data_task.py | 18 +- api/tests/workflow_empty_draft_test_utils.py | 11 +- dev/start-worker | 6 +- .../docker/native-test-seccomp.json | 5 +- .../tests/test_native_code_environment.py | 146 +++- .../adapters/native_testing/client.py | 14 +- .../src/dify_agent/layers/runtime/layer.py | 3 +- .../src/dify_agent/runtime/run_scheduler.py | 41 +- .../runtime_backend/native_test_scope.py | 8 +- .../adapters/llm/test_model_receipt.py | 12 +- .../server/test_runtime_admission.py | 60 +- .../layers/dify_core_tools/test_layer.py | 43 +- .../dify_agent/layers/shell/test_layer.py | 35 +- .../runtime/native_test_entities.py | 55 ++ .../test_confirmed_continuation_owner.py | 42 +- .../dify_agent/runtime/test_native_effects.py | 64 +- .../runtime_backend/test_native_test_scope.py | 131 +-- 283 files changed, 12268 insertions(+), 5702 deletions(-) create mode 100644 api/migrations/native_testing.py create mode 100644 api/tests/test_containers_integration_tests/core/workflow/builder_native_fixture_records.py create mode 100644 api/tests/test_containers_integration_tests/migrations/test_builder_native_migration_dialects.py create mode 100644 api/tests/unit_tests/database_fixtures.py create mode 100644 api/tests/unit_tests/logging_helpers.py create mode 100644 api/tests/unit_tests/migrations/test_builder_native_offline_migrations.py create mode 100644 api/tests/unit_tests/migrations/test_builder_rename_migration.py create mode 100644 api/tests/unit_tests/models/test_dify_builder_typed_models.py create mode 100644 api/tests/unit_tests/services/dify_builder/native_owner_fixtures.py create mode 100644 dify-agent/tests/local/dify_agent/runtime/native_test_entities.py diff --git a/.github/scripts/builder-native-runtime-ci.sh b/.github/scripts/builder-native-runtime-ci.sh index bdcbdf67009..e1b364b190e 100755 --- a/.github/scripts/builder-native-runtime-ci.sh +++ b/.github/scripts/builder-native-runtime-ci.sh @@ -93,7 +93,7 @@ case ${1:-} in /usr/bin/python3 "$GITHUB_WORKSPACE/.github/scripts/builder-native-runtime-canary.py" sudo systemctl daemon-reload sudo systemctl start "$unit" - for attempt in $(seq 1 30); do + for _ in $(seq 1 30); do if printf 'header = "Authorization: Bearer %s"\n' "$token" | curl --fail --silent --config - http://127.0.0.1:5005/healthz >/dev/null; then assert_manager_subgroup exit 0 @@ -122,7 +122,7 @@ case ${1:-} in owned_fixture require_manager_subgroup_support sudo systemctl restart "$unit" - for attempt in $(seq 1 30); do + for _ in $(seq 1 30); do if printf 'header = "Authorization: Bearer %s"\n' "${SHELLCTL_NATIVE_TEST_TOKEN:?native supervisor token required}" | curl --fail --silent --config - http://127.0.0.1:5005/healthz >/dev/null; then assert_manager_subgroup exit 0 diff --git a/.github/workflows/builder-safe-execution-tests.yml b/.github/workflows/builder-safe-execution-tests.yml index 8627b649cfb..c61ccbbe9c2 100644 --- a/.github/workflows/builder-safe-execution-tests.yml +++ b/.github/workflows/builder-safe-execution-tests.yml @@ -72,7 +72,16 @@ on: test_scope: description: Select a focused lane; all always runs on build branch pushes type: choice - options: [all, native-agent, native-catalog, native-continuation, native-console, native-dispatch, native-postgres] + options: + [ + all, + native-agent, + native-catalog, + native-continuation, + native-console, + native-dispatch, + native-postgres, + ] default: all permissions: contents: read @@ -80,6 +89,44 @@ concurrency: group: builder-safe-execution-${{ github.ref }} cancel-in-progress: true jobs: + native-migration-dialects: + if: github.event_name != 'workflow_dispatch' || (!inputs.contract_codegen && (inputs.test_scope == 'all' || inputs.test_scope == 'native-postgres')) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 + with: + python-version: '3.13' + enable-cache: true + cache-dependency-glob: api/uv.lock + - run: uv sync --project api --dev --locked + - name: Real PostgreSQL and MySQL evidence protections + run: >- + uv run --project api --no-sync pytest --no-cov -q + api/tests/test_containers_integration_tests/migrations/test_builder_native_migration_dialects.py + --junitxml=builder-migration-dialects.xml + - name: Require both real dialect cases + if: always() + run: | + python - <<'PY' + import xml.etree.ElementTree as ET + cases = list(ET.parse("builder-migration-dialects.xml").getroot().iter("testcase")) + expected = { + "test_native_migrations_preserve_checks_and_evidence_on_both_databases[postgresql]", + "test_native_migrations_preserve_checks_and_evidence_on_both_databases[mysql]", + } + assert len(cases) == 2 + assert {case.get("name") for case in cases} == expected + assert all(case.find(tag) is None for case in cases for tag in ("skipped", "failure", "error")) + PY + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: always() + with: + name: builder-migration-dialects + path: builder-migration-dialects.xml native-postgres: if: github.event_name != 'workflow_dispatch' || (!inputs.contract_codegen && (inputs.test_scope == 'all' || inputs.test_scope == 'native-postgres')) runs-on: ubuntu-24.04 @@ -118,7 +165,7 @@ jobs: "test_composed_prelaunch_refusals_have_no_native_id": 6, "test_composed_worker_reload_trace_race_refuses": 1, "test_composed_effective_default_binding": 1, - "test_async_native_repositories_without_delivered_saves_stay_unknown": 1, + "test_async_node_saves_without_delivery_stay_unknown": 1, "test_ordinary_native_scalar_execution_remains_ordinary": 1, "test_composed_class_replacement_refuses_before_native_validation": 1, "test_composed_http_retry_receipts_and_recorder_poison": 2, @@ -510,11 +557,11 @@ jobs: 'test_ensure_snippet_start_node_in_worker_applies_snippet_start_injection': 1, 'test_ensure_snippet_start_node_in_worker_returns_snippet_workflow_when_snippet_missing': 1, 'test_ensure_snippet_start_node_in_worker_returns_standard_workflow_without_lookup': 1, - 'test_generate_appends_pause_layer_and_forwards_state': 1, + 'test_generate_forwards_pause_configuration_and_state': 1, 'test_generate_includes_parent_trace_context_in_extras': 1, 'test_resume_delegates_to_generate': 1, 'test_resume_path_runs_worker_with_runtime_state': 1}, - 'tests.unit_tests.core.app.apps.test_workflow_app_runner_core.TestWorkflowBasedAppRunner': {'test_get_graph_and_variable_pool_for_single_node_run': 1, + 'tests.unit_tests.core.app.apps.test_workflow_app_runner_core.TestWorkflowBasedAppRunner': {'test_get_graph_and_variable_pool_for_single_node_run_does_not_mutate_inputs': 1, 'test_get_graph_and_variable_pool_for_single_node_run_does_not_mutate_inputs': 1, 'test_get_graph_and_variable_pool_for_single_node_run_includes_trace_session_id': 1, 'test_get_graph_and_variable_pool_preloads_constructor_variables_before_graph_init': 1, @@ -1341,6 +1388,44 @@ jobs: "test_the_fix_reprompt_quotes_the_engines_own_words": 1, "test_the_repair_prompt_states_the_handle_rule_and_shows_the_handles": 1 } + # Exact additions/expanded cases after main integration; retain existing cases. + for module, functions in {'tests.unit_tests.services.dify_builder.test_agent_tool_declarations': {'test_workflow_factory_injects_native_declaration_preparation': 1}}.items(): + a3_expected.setdefault(module, {}).update(functions) + for module, functions in {'tests.unit_tests.core.app.apps.test_workflow_app_generator': {'test_actual_generation_marks_configuration_failure_before_worker_dispatch': 1, + 'test_generate_forwards_pause_configuration_and_state': 1, + 'test_worker_executes_and_persists_a_workflow_through_the_injected_driver': 1}, + 'tests.unit_tests.core.app.apps.test_workflow_app_runner_core.TestWorkflowBasedAppRunner': {'test_container_queue_events_preserve_lifecycle_and_ancestor_metadata': 4, + 'test_init_graph_preserves_container_execution_scopes': 3, + 'test_nested_engine_events_retain_both_container_ancestors': 3, + 'test_single_container_debugging_preserves_descendants': 9}, + 'tests.unit_tests.core.repositories.test_sqlalchemy_workflow_node_execution_repository': {'test_resume_history_includes_paused_nodes_without_changing_default_reads': 4}, + 'tests.unit_tests.core.workflow.nodes.agent_v2.test_dify_tools_builder': {'test_native_layers_without_an_injected_preparer_fail_closed': 1, + 'test_ordinary_layers_do_not_use_an_injected_native_preparer': 1}, + 'tests.unit_tests.services.dify_builder.native_testing.test_agent_risk_assessor': {'test_shell_assessment_uses_configured_assets_and_actual_script': 1}, + 'tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_commands': {'test_public_pause_read_refreshes_callback_and_preserves_only_live_owned_resume': 16}, + 'tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_completion': {'test_edit_terminal_failure_or_decline_preserves_edit_family_without_execution': 2, + 'test_edit_terminal_projection_refuses_stale_unknown_or_wrong_task_family': 5, + 'test_edit_terminal_success_closes_same_attempt_in_edit_review_once': 4}, + 'tests.unit_tests.services.dify_builder.test_agent_model_result_canonical': {'test_actual_oversized_model_output_stops_before_yield_and_keeps_unknown_without_replay': 1, + 'test_model_receipt_matches_sdk_roundtrip_without_changing_stream_or_accepting_replacement': 4}, + 'tests.unit_tests.services.dify_builder.test_agent_operation_owner': {'test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth': 5}, + 'tests.unit_tests.services.dify_builder.test_code_risk_assessment': {'test_code_reasoner_uses_session_language_instead_of_old_goal_language': 1, + 'test_code_risk_language_is_bound_to_snapshot_without_translating_source': 1}, + 'tests.unit_tests.services.dify_builder.test_confirmed_full_run': {'test_closed_preparation_failure_is_not_reconciled_as_a_dispatched_run': 1, + 'test_error_after_observed_dispatch_remains_unknown': 1, + 'test_failed_native_plugin_resource_uses_actual_historical_graph': 1, + 'test_observed_preparation_failure_is_failed_and_never_redispatched': 1, + 'test_preparation_failure_identifies_only_the_observed_resource_nodes': 1}, + 'tests.unit_tests.services.dify_builder.test_edit_cognition': {'test_edit_exposes_approved_insertion_plan_and_preserves_receipt_contract': 1, + 'test_edit_refuses_partial_added_node_when_its_wiring_is_rejected': 2, + 'test_edit_retry_can_correct_added_node_wiring_to_existing_anchor': 1, + 'test_edit_retry_cannot_remove_rejected_wiring_and_keep_same_isolated_addition': 1, + 'test_edit_rule_labels_receive_explicit_session_language_and_preserve_business_value': 1, + 'test_edit_still_allows_intentional_isolated_draft_addition': 1}, + 'tests.unit_tests.services.dify_builder.test_trace_diagnostics': {'test_native_preparation_diagnostic_is_persisted_with_handler_reply': 1, + 'test_resource_binding_refusal_retains_safe_code_and_target_without_private_message': 1}, + 'tests.unit_tests.services.test_app_generate_service_in_process': {'test_pre_generator_tool_admission_failure_has_qualified_preparation_boundary': 4}}.items(): + a4_expected.setdefault(module, {}).update(functions) for module, expected in {**a3_expected, **a4_expected}.items(): actual = {} for classname, name in identities: @@ -1474,6 +1559,67 @@ jobs: "test_json_non_success_evidence_allows_exact_false_seal_and_legacy_coercion_is_unchanged": 1, }, } + # Exact additions/expanded cases after main integration; retain existing cases. + for module, functions in {'tests.unit_tests.core.dify_builder.test_execution_policy': {'test_admission_rejects_unsupported_effect_shapes': 12, + 'test_all_submitted_values_must_be_scalars': 4, + 'test_canonical_digests_reject_nonfinite_and_surrogates_preserve_array_order': 1, + 'test_citation_display_absent_or_empty_metadata_compatibility': 2, + 'test_citation_display_flag_never_admits_enabled_external_features': 5, + 'test_citation_display_flag_never_admits_retrieval_model_or_tool_nodes': 3, + 'test_citation_display_metadata_rejects_non_boolean_or_unknown_shape': 9, + 'test_confirmed_completion_cannot_be_constructed_without_complete_confirmed_shape': 9, + 'test_confirmed_evidence_literals_and_sanitized_action_metadata': 1, + 'test_exact_implementation_identity_precedes_native_validation': 1, + 'test_fixture_strict_integer_and_immutable': 1, + 'test_http_default_finite_number_preserves_raw_binding': 4, + 'test_http_default_large_integer_refused_before_native_validation': 4, + 'test_invalid_fixture_rejected': 8, + 'test_known_native_disabled_defaults_pass': 3, + 'test_malformed_graph_identifiers_are_sanitized_refusals': 3, + 'test_native_start_end_identity_and_raw_digest_binding': 1, + 'test_normal_workflow_citation_display_metadata_admits_native_template': 2, + 'test_raw_metadata_denied': 3, + 'test_restricted_summary_cannot_carry_confirmed_metadata': 5, + 'test_trusted_completion_accepts_either_context_variant_and_rejects_unknown_tags': 1, + 'test_unknown_or_malformed_disabled_feature_forms_refuse': 4, + 'test_untrusted_fixture_decoder_is_strict_and_sanitized': 1}, + 'tests.unit_tests.core.dify_builder.test_verification': {'test_confirmed_card_has_ids_status_and_reasons_but_no_native_content': 3, + 'test_confirmed_success_is_publishable_only_when_sealed_and_revision_exact': 1, + 'test_observed_preparation_failure_has_specific_copy_without_entering_repair': 1, + 'test_unknown_execution_asks_for_reconciliation_instead_of_rerun': 1, + 'test_verification_evidence_denies': 4, + 'test_verification_policy_denies': 6, + 'test_verification_present_null_and_unknown_are_reviewable': 3}, + 'tests.unit_tests.services.dify_builder.test_execution_policy_service': {'test_all_handlers_persist_actual_server_stamp': 3, + 'test_append_verifies_fixture_node_implementation_profile_and_bound_limit': 1, + 'test_canonical_native_owner_and_proof_mutations': 14, + 'test_complete_non_success_native_status_remains_native_failed': 3, + 'test_fixture_stamp_lineage_stale_reuse_and_malformed_persistence': 1, + 'test_full_owner_chain_required': 7, + 'test_http_receipts_and_unknown_first_precedence': 6, + 'test_incomplete_proof_seals_unknown': 5, + 'test_launch_exactly_once_and_observation_binding': 1, + 'test_malformed_snapshot_refusal_is_sanitized_before_revision': 15, + 'test_metadata_refusal_precedes_revision_access': 4, + 'test_native_predecessor_corroborates_chain_when_present': 9, + 'test_native_rbac_agent_app_binding_precedes_maintainer_bypass': 2, + 'test_native_rbac_plain_app_maintainer_bypass_is_persisted': 1, + 'test_native_rbac_requires_both_scenes_and_allows_delegated_membership': 1, + 'test_null_predecessor_http_retry_requires_all_attempt_proof': 5, + 'test_null_predecessor_prefix_requires_failed_or_stopped_run': 4, + 'test_optional_predecessor_change_cannot_rewrite_sealed_evidence': 1, + 'test_owned_preparation_binds_both_inputs_and_does_not_infer_mock_mode': 1, + 'test_owner_helpers_are_exported_once_for_shared_callers': 1, + 'test_raw_tracing_refusal_in_prepare_and_fixture_stamp_precedes_revision': 1, + 'test_revalidation_refuses_changed_execution': 11, + 'test_seal_requires_authoritative_native_rows_and_is_idempotent': 1, + 'test_seal_revalidates_constructed_preworker_origin': 5, + 'test_sealed_append_and_tampered_full_context_refused': 1, + 'test_supplied_workflow_identity_cannot_disagree_with_owned_row': 2}, + 'tests.unit_tests.services.dify_builder.test_template_presentation': {'test_known_template_refusal_has_actionable_scene_without_native_content': 5, + 'test_template_refusal_clears_staged_repairs_and_cannot_publish': 1, + 'test_unknown_policy_reason_uses_fixed_sanitized_fallback': 1}}.items(): + json_contract_expected.setdefault(module, {}).update(functions) for module, expected in {json_policy_module: json_policy_expected, **json_contract_expected, **json_runtime_expected}.items(): actual = {} for classname, name in identities: diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 159bcd43e3f..76557b06b59 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -78,6 +78,8 @@ jobs: - name: Install dependencies run: uv sync --project api - name: Ensure Offline migration are supported + env: + DB_TYPE: mysql run: | # upgrade uv run --directory api flask db upgrade 'base:head' --sql diff --git a/.github/workflows/dify-agent-tests.yml b/.github/workflows/dify-agent-tests.yml index 26291087a8e..825fc762cbc 100644 --- a/.github/workflows/dify-agent-tests.yml +++ b/.github/workflows/dify-agent-tests.yml @@ -14,6 +14,18 @@ jobs: dify-agent-unit: name: Dify Agent Unit Tests runs-on: depot-ubuntu-24.04 + services: + redis: + image: redis:6-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DIFY_AGENT_NATIVE_TEST_REDIS_URL: redis://127.0.0.1:6379/15 defaults: run: shell: bash diff --git a/api/controllers/inner_api/plugin/agent_config.py b/api/controllers/inner_api/plugin/agent_config.py index e7a078fed0c..567dc842d3c 100644 --- a/api/controllers/inner_api/plugin/agent_config.py +++ b/api/controllers/inner_api/plugin/agent_config.py @@ -106,8 +106,8 @@ def _authorize_runtime_config_read( ) -> None: """Spend no second permit; revalidate the claimed parent operation before reads.""" context = ( - getattr(g, "agent_runtime_context", None) - if getattr(g, "agent_runtime_request", None) is cast(LocalProxy, request)._get_current_object() + g.get("agent_runtime_context") + if g.get("agent_runtime_request") is cast(LocalProxy, request)._get_current_object() else None ) if context is None: @@ -117,7 +117,7 @@ def _authorize_runtime_config_read( from services.dify_builder.native_testing.agent_operations import agent_operations_for_ref try: - envelope = getattr(g, "agent_runtime_dispatch", None) + envelope = g.get("agent_runtime_dispatch") if envelope is None: raise BuilderExecutionPolicyError("native_endpoint_owner_mismatch") validate_config_read( diff --git a/api/controllers/inner_api/wraps.py b/api/controllers/inner_api/wraps.py index 05f95cca9a8..6ef6225a6fe 100644 --- a/api/controllers/inner_api/wraps.py +++ b/api/controllers/inner_api/wraps.py @@ -158,8 +158,8 @@ def agent_endpoint_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs) -> R: if ( - getattr(g, "agent_runtime_admitted", False) - and getattr(g, "agent_runtime_request", None) is cast(LocalProxy, request)._get_current_object() + g.get("agent_runtime_admitted", False) + and g.get("agent_runtime_request") is cast(LocalProxy, request)._get_current_object() ): return view(*args, **kwargs) return authenticated_legacy(*args, **kwargs) diff --git a/api/core/workflow/generator/tool_catalogue.py b/api/core/workflow/generator/tool_catalogue.py index 61f086b2570..8c368c22278 100644 --- a/api/core/workflow/generator/tool_catalogue.py +++ b/api/core/workflow/generator/tool_catalogue.py @@ -617,9 +617,9 @@ def _provider_capability( ) -> tuple[str, str, list[str]]: """Provider label, description and tags from its declaration identity.""" identity = provider.entity.identity - label = _i18n_text(getattr(identity, "label", None)) - description = _i18n_text(getattr(identity, "description", None)) - tags = [str(tag) for tag in (getattr(identity, "tags", None) or [])] + label = _i18n_text(identity.label) + description = _i18n_text(identity.description) + tags = [str(tag) for tag in (identity.tags or [])] return label, description, tags diff --git a/api/migrations/native_testing.py b/api/migrations/native_testing.py new file mode 100644 index 00000000000..1727b014aa1 --- /dev/null +++ b/api/migrations/native_testing.py @@ -0,0 +1,80 @@ +"""DDL helpers shared by the native testing migration chain.""" + +import sqlalchemy as sa +from alembic import op + + +def function_schema_prefix() -> str: + """Resolve online schemas; exported SQL resolves the same search path when applied.""" + if op.get_context().as_sql: + return "" + connection = op.get_bind() + schema = connection.execute(sa.text("SELECT current_schema()")).scalar_one() + return connection.dialect.identifier_preparer.quote(schema) + "." + + +def require_empty_table(table: str, reason: str, *, lock: bool = False, predicate: str | None = None) -> None: + """Retain evidence checks in exported SQL instead of reading a nonexistent connection.""" + query = f"SELECT 1 FROM {table}" + if predicate: + query += f" WHERE {predicate}" + query += " LIMIT 1" + offline_mysql = op.get_context().as_sql and is_mysql() + procedure = "builder_downgrade_guard" + if offline_mysql: + # MySQL refuses routine DDL while tables are locked. Define the guard + # first, then run its read under the lock retained through DROP TABLE. + op.execute(sa.text(f"DROP PROCEDURE IF EXISTS {procedure}")) + mysql_program(f"""CREATE PROCEDURE {procedure}() BEGIN + IF EXISTS ({query}) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '{reason}'; + END IF; END""") + if lock: + op.execute( + sa.text(f"LOCK TABLES {table} WRITE" if is_mysql() else f"LOCK TABLE {table} IN ACCESS EXCLUSIVE MODE") + ) + if op.get_context().as_sql: + if is_mysql(): + op.execute(sa.text(f"CALL {procedure}()")) + if not lock: + op.execute(sa.text(f"DROP PROCEDURE {procedure}")) + else: + op.execute( + sa.text(f"""DO $$ BEGIN IF EXISTS ({query}) + THEN RAISE EXCEPTION '{reason}'; END IF; END $$""") + ) + elif op.get_bind().execute(sa.text(query)).first(): + if lock: + unlock_tables() + raise RuntimeError(reason) + + +def is_mysql() -> bool: + return op.get_bind().dialect.name == "mysql" + + +def mysql_program(statement: str) -> None: + """Emit a compound MySQL statement with CLI delimiters only in SQL exports.""" + if op.get_context().as_sql: + op.get_context().impl.static_output(f"DELIMITER $$\n{statement}$$\nDELIMITER ;") + else: + op.execute(sa.text(statement)) + + +def mysql_immutable_triggers(table: str, name: str, reason: str) -> None: + for event in ("UPDATE", "DELETE"): + mysql_program( + f"CREATE TRIGGER {name}_{event.lower()} BEFORE {event} ON {table} " + f"FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '{reason}'" + ) + + +def mysql_changed(fields: tuple[str, ...]) -> str: + """Null-safe binary comparisons preserve case-sensitive immutable identities.""" + return " OR ".join(f"NOT (BINARY NEW.{field} <=> BINARY OLD.{field})" for field in fields) + + +def unlock_tables() -> None: + if is_mysql(): + op.execute(sa.text("UNLOCK TABLES")) + if op.get_context().as_sql: + op.execute(sa.text("DROP PROCEDURE builder_downgrade_guard")) diff --git a/api/migrations/versions/2025_06_06_1424-4474872b0ee6_workflow_draft_varaibles_add_node_execution_id.py b/api/migrations/versions/2025_06_06_1424-4474872b0ee6_workflow_draft_varaibles_add_node_execution_id.py index 610064320ae..e427d7c7bc6 100644 --- a/api/migrations/versions/2025_06_06_1424-4474872b0ee6_workflow_draft_varaibles_add_node_execution_id.py +++ b/api/migrations/versions/2025_06_06_1424-4474872b0ee6_workflow_draft_varaibles_add_node_execution_id.py @@ -5,18 +5,20 @@ Revises: 2adcbe1f5dfb Create Date: 2025-06-06 14:24:44.213018 """ + +import sqlalchemy as sa from alembic import op -import models as models + +import models def _is_pg(conn): return conn.dialect.name == "postgresql" -import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '4474872b0ee6' -down_revision = '2adcbe1f5dfb' +revision = "4474872b0ee6" +down_revision = "2adcbe1f5dfb" branch_labels = None depends_on = None @@ -24,7 +26,7 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### conn = op.get_bind() - + if _is_pg(conn): # `CREATE INDEX CONCURRENTLY` cannot run within a transaction, so use the `autocommit_block` # context manager to wrap the index creation statement. @@ -34,22 +36,22 @@ def upgrade(): # - https://alembic.sqlalchemy.org/en/latest/api/runtime.html#alembic.runtime.migration.MigrationContext.autocommit_block with op.get_context().autocommit_block(): op.create_index( - op.f('workflow_node_executions_tenant_id_idx'), + op.f("workflow_node_executions_tenant_id_idx"), "workflow_node_executions", - ['tenant_id', 'workflow_id', 'node_id', sa.literal_column('created_at DESC')], + ["tenant_id", "workflow_id", "node_id", sa.literal_column("created_at DESC")], unique=False, postgresql_concurrently=True, ) else: op.create_index( - op.f('workflow_node_executions_tenant_id_idx'), + op.f("workflow_node_executions_tenant_id_idx"), "workflow_node_executions", - ['tenant_id', 'workflow_id', 'node_id', sa.literal_column('created_at DESC')], + ["tenant_id", "workflow_id", "node_id", sa.literal_column("created_at DESC")], unique=False, ) - with op.batch_alter_table('workflow_draft_variables', schema=None) as batch_op: - batch_op.add_column(sa.Column('node_execution_id', models.types.StringUUID(), nullable=True)) + with op.batch_alter_table("workflow_draft_variables", schema=None) as batch_op: + batch_op.add_column(sa.Column("node_execution_id", models.types.StringUUID(), nullable=True)) # ### end Alembic commands ### @@ -67,14 +69,18 @@ def downgrade(): # # https://www.postgresql.org/docs/current/sql-createindex.html#:~:text=Another%20difference%20is,CREATE%20INDEX%20CONCURRENTLY%20cannot. conn = op.get_bind() - + if _is_pg(conn): with op.get_context().autocommit_block(): - op.drop_index(op.f('workflow_node_executions_tenant_id_idx'), postgresql_concurrently=True) + op.drop_index( + op.f("workflow_node_executions_tenant_id_idx"), + table_name="workflow_node_executions", + postgresql_concurrently=True, + ) else: - op.drop_index(op.f('workflow_node_executions_tenant_id_idx')) + op.drop_index(op.f("workflow_node_executions_tenant_id_idx"), table_name="workflow_node_executions") - with op.batch_alter_table('workflow_draft_variables', schema=None) as batch_op: - batch_op.drop_column('node_execution_id') + with op.batch_alter_table("workflow_draft_variables", schema=None) as batch_op: + batch_op.drop_column("node_execution_id") # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_06_19_1633-0ab65e1cc7fa_remove_sequence_number_from_workflow_.py b/api/migrations/versions/2025_06_19_1633-0ab65e1cc7fa_remove_sequence_number_from_workflow_.py index 29fef777983..2be106d399c 100644 --- a/api/migrations/versions/2025_06_19_1633-0ab65e1cc7fa_remove_sequence_number_from_workflow_.py +++ b/api/migrations/versions/2025_06_19_1633-0ab65e1cc7fa_remove_sequence_number_from_workflow_.py @@ -5,23 +5,22 @@ Revises: 4474872b0ee6 Create Date: 2025-06-19 16:33:13.377215 """ -from alembic import op -import models as models -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '0ab65e1cc7fa' -down_revision = '4474872b0ee6' +revision = "0ab65e1cc7fa" +down_revision = "4474872b0ee6" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('workflow_runs', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('workflow_run_tenant_app_sequence_idx')) - batch_op.drop_column('sequence_number') + with op.batch_alter_table("workflow_runs", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("workflow_run_tenant_app_sequence_idx")) + batch_op.drop_column("sequence_number") # ### end Alembic commands ### @@ -38,14 +37,29 @@ def downgrade(): # migration approach that creates a backup table before removal. # Step 1: Add sequence_number column as nullable first - with op.batch_alter_table('workflow_runs', schema=None) as batch_op: - batch_op.add_column(sa.Column('sequence_number', sa.INTEGER(), autoincrement=False, nullable=True)) + with op.batch_alter_table("workflow_runs", schema=None) as batch_op: + batch_op.add_column(sa.Column("sequence_number", sa.INTEGER(), autoincrement=False, nullable=True)) # Step 2: Populate sequence_number values based on created_at order within each app # NOTE: This recreates sequence numbering logic but values will be different # from the original sequence numbers that were removed in the upgrade connection = op.get_bind() - connection.execute(sa.text(""" + if connection.dialect.name == "mysql": + connection.execute( + sa.text(""" + UPDATE workflow_runs + JOIN ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY tenant_id, app_id ORDER BY created_at, id + ) AS row_num + FROM workflow_runs + ) AS subquery ON workflow_runs.id = subquery.id + SET workflow_runs.sequence_number = subquery.row_num + """) + ) + else: + connection.execute( + sa.text(""" UPDATE workflow_runs SET sequence_number = subquery.row_num FROM ( @@ -56,11 +70,14 @@ def downgrade(): FROM workflow_runs ) subquery WHERE workflow_runs.id = subquery.id - """)) + """) + ) # Step 3: Make the column NOT NULL and add the index - with op.batch_alter_table('workflow_runs', schema=None) as batch_op: - batch_op.alter_column('sequence_number', nullable=False) - batch_op.create_index(batch_op.f('workflow_run_tenant_app_sequence_idx'), ['tenant_id', 'app_id', 'sequence_number'], unique=False) + with op.batch_alter_table("workflow_runs", schema=None) as batch_op: + batch_op.alter_column("sequence_number", nullable=False, existing_type=sa.INTEGER()) + batch_op.create_index( + batch_op.f("workflow_run_tenant_app_sequence_idx"), ["tenant_id", "app_id", "sequence_number"], unique=False + ) # ### end Alembic commands ### diff --git a/api/migrations/versions/2026_04_15_1726-227822d22895_add_workflow_comments_table.py b/api/migrations/versions/2026_04_15_1726-227822d22895_add_workflow_comments_table.py index 0548c932b58..367b9d7c4c1 100644 --- a/api/migrations/versions/2026_04_15_1726-227822d22895_add_workflow_comments_table.py +++ b/api/migrations/versions/2026_04_15_1726-227822d22895_add_workflow_comments_table.py @@ -5,86 +5,92 @@ Revises: 8574b23a38fd Create Date: 2025-08-22 17:26:15.255980 """ -from alembic import op -import models as models -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op + +import models # revision identifiers, used by Alembic. -revision = '227822d22895' -down_revision = '8574b23a38fd' +revision = "227822d22895" +down_revision = "8574b23a38fd" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('workflow_comments', - sa.Column('id', models.types.StringUUID(), nullable=False), - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('app_id', models.types.StringUUID(), nullable=False), - sa.Column('position_x', sa.Float(), nullable=False), - sa.Column('position_y', sa.Float(), nullable=False), - sa.Column('content', sa.Text(), nullable=False), - sa.Column('created_by', models.types.StringUUID(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('resolved', sa.Boolean(), server_default=sa.text('false'), nullable=False), - sa.Column('resolved_at', sa.DateTime(), nullable=True), - sa.Column('resolved_by', models.types.StringUUID(), nullable=True), - sa.PrimaryKeyConstraint('id', name='workflow_comments_pkey') + op.create_table( + "workflow_comments", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("position_x", sa.Float(), nullable=False), + sa.Column("position_y", sa.Float(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("created_by", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("resolved", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("resolved_at", sa.DateTime(), nullable=True), + sa.Column("resolved_by", models.types.StringUUID(), nullable=True), + sa.PrimaryKeyConstraint("id", name="workflow_comments_pkey"), ) - with op.batch_alter_table('workflow_comments', schema=None) as batch_op: - batch_op.create_index('workflow_comments_app_idx', ['tenant_id', 'app_id'], unique=False) - batch_op.create_index('workflow_comments_created_at_idx', ['created_at'], unique=False) + with op.batch_alter_table("workflow_comments", schema=None) as batch_op: + batch_op.create_index("workflow_comments_app_idx", ["tenant_id", "app_id"], unique=False) + batch_op.create_index("workflow_comments_created_at_idx", ["created_at"], unique=False) - op.create_table('workflow_comment_replies', - sa.Column('id', models.types.StringUUID(), nullable=False), - sa.Column('comment_id', models.types.StringUUID(), nullable=False), - sa.Column('content', sa.Text(), nullable=False), - sa.Column('created_by', models.types.StringUUID(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_replies_comment_id_fkey'), ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id', name='workflow_comment_replies_pkey') + op.create_table( + "workflow_comment_replies", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("comment_id", models.types.StringUUID(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("created_by", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.ForeignKeyConstraint( + ["comment_id"], + ["workflow_comments.id"], + name=op.f("workflow_comment_replies_comment_id_fkey"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name="workflow_comment_replies_pkey"), ) - with op.batch_alter_table('workflow_comment_replies', schema=None) as batch_op: - batch_op.create_index('comment_replies_comment_idx', ['comment_id'], unique=False) - batch_op.create_index('comment_replies_created_at_idx', ['created_at'], unique=False) + with op.batch_alter_table("workflow_comment_replies", schema=None) as batch_op: + batch_op.create_index("comment_replies_comment_idx", ["comment_id"], unique=False) + batch_op.create_index("comment_replies_created_at_idx", ["created_at"], unique=False) - op.create_table('workflow_comment_mentions', - sa.Column('id', models.types.StringUUID(), nullable=False), - sa.Column('comment_id', models.types.StringUUID(), nullable=False), - sa.Column('reply_id', models.types.StringUUID(), nullable=True), - sa.Column('mentioned_user_id', models.types.StringUUID(), nullable=False), - sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_mentions_comment_id_fkey'), ondelete='CASCADE'), - sa.ForeignKeyConstraint(['reply_id'], ['workflow_comment_replies.id'], name=op.f('workflow_comment_mentions_reply_id_fkey'), ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id', name='workflow_comment_mentions_pkey') + op.create_table( + "workflow_comment_mentions", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("comment_id", models.types.StringUUID(), nullable=False), + sa.Column("reply_id", models.types.StringUUID(), nullable=True), + sa.Column("mentioned_user_id", models.types.StringUUID(), nullable=False), + sa.ForeignKeyConstraint( + ["comment_id"], + ["workflow_comments.id"], + name=op.f("workflow_comment_mentions_comment_id_fkey"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["reply_id"], + ["workflow_comment_replies.id"], + name=op.f("workflow_comment_mentions_reply_id_fkey"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name="workflow_comment_mentions_pkey"), ) - with op.batch_alter_table('workflow_comment_mentions', schema=None) as batch_op: - batch_op.create_index('comment_mentions_comment_idx', ['comment_id'], unique=False) - batch_op.create_index('comment_mentions_reply_idx', ['reply_id'], unique=False) - batch_op.create_index('comment_mentions_user_idx', ['mentioned_user_id'], unique=False) + with op.batch_alter_table("workflow_comment_mentions", schema=None) as batch_op: + batch_op.create_index("comment_mentions_comment_idx", ["comment_id"], unique=False) + batch_op.create_index("comment_mentions_reply_idx", ["reply_id"], unique=False) + batch_op.create_index("comment_mentions_user_idx", ["mentioned_user_id"], unique=False) # ### end Alembic commands ### def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('workflow_comment_mentions', schema=None) as batch_op: - batch_op.drop_index('comment_mentions_user_idx') - batch_op.drop_index('comment_mentions_reply_idx') - batch_op.drop_index('comment_mentions_comment_idx') - - op.drop_table('workflow_comment_mentions') - with op.batch_alter_table('workflow_comment_replies', schema=None) as batch_op: - batch_op.drop_index('comment_replies_created_at_idx') - batch_op.drop_index('comment_replies_comment_idx') - - op.drop_table('workflow_comment_replies') - with op.batch_alter_table('workflow_comments', schema=None) as batch_op: - batch_op.drop_index('workflow_comments_created_at_idx') - batch_op.drop_index('workflow_comments_app_idx') - - op.drop_table('workflow_comments') - # ### end Alembic commands ### + # Drop children before parents; their FK-supporting indexes disappear with + # each table rather than being removed while still required by MySQL. + op.drop_table("workflow_comment_mentions") + op.drop_table("workflow_comment_replies") + op.drop_table("workflow_comments") diff --git a/api/migrations/versions/2026_05_22_1700-d4a5e1f3c9b7_add_oauth_access_tokens.py b/api/migrations/versions/2026_05_22_1700-d4a5e1f3c9b7_add_oauth_access_tokens.py index 3ad89441a6a..7701e0b3727 100644 --- a/api/migrations/versions/2026_05_22_1700-d4a5e1f3c9b7_add_oauth_access_tokens.py +++ b/api/migrations/versions/2026_05_22_1700-d4a5e1f3c9b7_add_oauth_access_tokens.py @@ -22,6 +22,7 @@ Cross-dialect notes: uniqueness across both dialects (NULLs are distinct in both, matching the rotate-in-place contract documented on ``OAuthAccessToken``). """ + import sqlalchemy as sa from alembic import op @@ -120,9 +121,6 @@ def upgrade(): def downgrade(): - op.drop_index("uq_oauth_active_per_device", table_name="oauth_access_tokens") - op.drop_index("idx_oauth_token_hash", table_name="oauth_access_tokens") - op.drop_index("idx_oauth_client", table_name="oauth_access_tokens") - op.drop_index("idx_oauth_account", table_name="oauth_access_tokens") - op.drop_index("idx_oauth_subject_email", table_name="oauth_access_tokens") + # Remove the table with its FK-backed indexes in one operation. MySQL + # cannot drop idx_oauth_account while the account foreign key exists. op.drop_table("oauth_access_tokens") diff --git a/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py b/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py index 2e3e41e2b82..cd73907a02c 100644 --- a/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py +++ b/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py @@ -51,19 +51,32 @@ def downgrade(): sa.column("draft_type", sa.String(length=32)), ) build_conversations = debug_conversations.alias("build_conversations") - op.get_bind().execute( - sa.delete(debug_conversations).where( - debug_conversations.c.draft_type == "draft", - sa.exists( - sa.select(sa.literal(1)).where( - build_conversations.c.tenant_id == debug_conversations.c.tenant_id, - build_conversations.c.agent_id == debug_conversations.c.agent_id, - build_conversations.c.account_id == debug_conversations.c.account_id, - build_conversations.c.draft_type == "debug_build", - ) - ), + if op.get_bind().dialect.name == "mysql": + # MySQL rejects a correlated self-read inside DELETE's WHERE clause. + op.execute( + sa.text(""" + DELETE drafts FROM agent_debug_conversations AS drafts + JOIN agent_debug_conversations AS builds + ON builds.tenant_id = drafts.tenant_id + AND builds.agent_id = drafts.agent_id + AND builds.account_id = drafts.account_id + WHERE drafts.draft_type = 'draft' AND builds.draft_type = 'debug_build' + """) + ) + else: + op.get_bind().execute( + sa.delete(debug_conversations).where( + debug_conversations.c.draft_type == "draft", + sa.exists( + sa.select(sa.literal(1)).where( + build_conversations.c.tenant_id == debug_conversations.c.tenant_id, + build_conversations.c.agent_id == debug_conversations.c.agent_id, + build_conversations.c.account_id == debug_conversations.c.account_id, + build_conversations.c.draft_type == "debug_build", + ) + ), + ) ) - ) op.drop_constraint( "agent_debug_conversation_agent_account_draft_type_unique", "agent_debug_conversations", diff --git a/api/migrations/versions/2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py b/api/migrations/versions/2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py index aed7bc93215..4f1531c814c 100644 --- a/api/migrations/versions/2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py +++ b/api/migrations/versions/2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py @@ -5,149 +5,204 @@ Revises: 2f39536b3feb Create Date: 2026-07-23 02:03:05.641638 """ -from alembic import op -import models as models + import sqlalchemy as sa -from sqlalchemy.dialects import postgresql +from alembic import op + +import models # revision identifiers, used by Alembic. -revision = 'f6e4c5686857' -down_revision = '2f39536b3feb' +revision = "f6e4c5686857" +down_revision = "2f39536b3feb" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('agent_workspace_bindings', - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('app_id', models.types.StringUUID(), nullable=False), - sa.Column('workspace_id', models.types.StringUUID(), nullable=False), - sa.Column('agent_id', models.types.StringUUID(), nullable=False), - sa.Column('base_home_snapshot_id', models.types.StringUUID(), nullable=True), - sa.Column('agent_config_version_id', models.types.StringUUID(), nullable=False), - sa.Column('agent_config_version_kind', sa.String(length=32), nullable=False), - sa.Column('backend_binding_ref', sa.String(length=255), nullable=False), - sa.Column('session_snapshot', models.types.LongText(), nullable=True), - sa.Column('status', sa.String(length=32), server_default='active', nullable=False), - sa.Column('retired_at', sa.DateTime(), nullable=True), - sa.Column('pending_form_id', models.types.StringUUID(), nullable=True), - sa.Column('pending_tool_call_id', sa.String(length=255), nullable=True), - sa.Column('id', models.types.StringUUID(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.PrimaryKeyConstraint('id', name='agent_workspace_binding_pkey') + op.create_table( + "agent_workspace_bindings", + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("workspace_id", models.types.StringUUID(), nullable=False), + sa.Column("agent_id", models.types.StringUUID(), nullable=False), + sa.Column("base_home_snapshot_id", models.types.StringUUID(), nullable=True), + sa.Column("agent_config_version_id", models.types.StringUUID(), nullable=False), + sa.Column("agent_config_version_kind", sa.String(length=32), nullable=False), + sa.Column("backend_binding_ref", sa.String(length=255), nullable=False), + sa.Column("session_snapshot", models.types.LongText(), nullable=True), + sa.Column("status", sa.String(length=32), server_default="active", nullable=False), + sa.Column("retired_at", sa.DateTime(), nullable=True), + sa.Column("pending_form_id", models.types.StringUUID(), nullable=True), + sa.Column("pending_tool_call_id", sa.String(length=255), nullable=True), + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.PrimaryKeyConstraint("id", name="agent_workspace_binding_pkey"), ) - with op.batch_alter_table('agent_workspace_bindings', schema=None) as batch_op: - batch_op.create_index('agent_workspace_binding_agent_status_idx', ['tenant_id', 'agent_id', 'status'], unique=False) - batch_op.create_index('agent_workspace_binding_status_retired_idx', ['status', 'retired_at'], unique=False) - batch_op.create_index('agent_workspace_binding_workspace_status_idx', ['tenant_id', 'workspace_id', 'status'], unique=False) + with op.batch_alter_table("agent_workspace_bindings", schema=None) as batch_op: + batch_op.create_index( + "agent_workspace_binding_agent_status_idx", ["tenant_id", "agent_id", "status"], unique=False + ) + batch_op.create_index("agent_workspace_binding_status_retired_idx", ["status", "retired_at"], unique=False) + batch_op.create_index( + "agent_workspace_binding_workspace_status_idx", ["tenant_id", "workspace_id", "status"], unique=False + ) - op.create_table('agent_workspaces', - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('app_id', models.types.StringUUID(), nullable=False), - sa.Column('owner_type', sa.String(length=32), nullable=False), - sa.Column('owner_id', models.types.StringUUID(), nullable=False), - sa.Column('owner_scope_key', sa.String(length=255), nullable=False), - sa.Column('backend_workspace_ref', sa.String(length=255), nullable=False), - sa.Column('status', sa.String(length=32), server_default='active', nullable=False), - sa.Column('active_guard', sa.SmallInteger(), server_default='1', nullable=True), - sa.Column('retired_at', sa.DateTime(), nullable=True), - sa.Column('id', models.types.StringUUID(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.PrimaryKeyConstraint('id', name='agent_workspace_pkey') + op.create_table( + "agent_workspaces", + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("owner_type", sa.String(length=32), nullable=False), + sa.Column("owner_id", models.types.StringUUID(), nullable=False), + sa.Column("owner_scope_key", sa.String(length=255), nullable=False), + sa.Column("backend_workspace_ref", sa.String(length=255), nullable=False), + sa.Column("status", sa.String(length=32), server_default="active", nullable=False), + sa.Column("active_guard", sa.SmallInteger(), server_default="1", nullable=True), + sa.Column("retired_at", sa.DateTime(), nullable=True), + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.PrimaryKeyConstraint("id", name="agent_workspace_pkey"), ) - with op.batch_alter_table('agent_workspaces', schema=None) as batch_op: - batch_op.create_index('agent_workspace_owner_active_unique', ['tenant_id', 'owner_type', 'owner_id', 'owner_scope_key', 'active_guard'], unique=True) - batch_op.create_index('agent_workspace_status_retired_idx', ['status', 'retired_at'], unique=False) - batch_op.create_index('agent_workspace_tenant_app_status_idx', ['tenant_id', 'app_id', 'status'], unique=False) - batch_op.create_index('agent_workspace_tenant_status_idx', ['tenant_id', 'status'], unique=False) + with op.batch_alter_table("agent_workspaces", schema=None) as batch_op: + batch_op.create_index( + "agent_workspace_owner_active_unique", + ["tenant_id", "owner_type", "owner_id", "owner_scope_key", "active_guard"], + unique=True, + ) + batch_op.create_index("agent_workspace_status_retired_idx", ["status", "retired_at"], unique=False) + batch_op.create_index("agent_workspace_tenant_app_status_idx", ["tenant_id", "app_id", "status"], unique=False) + batch_op.create_index("agent_workspace_tenant_status_idx", ["tenant_id", "status"], unique=False) - with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('agent_runtime_session_backend_run_idx')) - batch_op.drop_index(batch_op.f('agent_runtime_session_conversation_lookup_idx')) - batch_op.drop_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), postgresql_where='(conversation_id IS NOT NULL)') - batch_op.drop_index(batch_op.f('agent_runtime_session_workflow_lookup_idx')) - batch_op.drop_index(batch_op.f('agent_runtime_session_workflow_scope_unique'), postgresql_where='(workflow_run_id IS NOT NULL)') + with op.batch_alter_table("agent_runtime_sessions", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("agent_runtime_session_backend_run_idx")) + batch_op.drop_index(batch_op.f("agent_runtime_session_conversation_lookup_idx")) + batch_op.drop_index( + batch_op.f("agent_runtime_session_conversation_scope_unique"), + postgresql_where="(conversation_id IS NOT NULL)", + ) + batch_op.drop_index(batch_op.f("agent_runtime_session_workflow_lookup_idx")) + batch_op.drop_index( + batch_op.f("agent_runtime_session_workflow_scope_unique"), postgresql_where="(workflow_run_id IS NOT NULL)" + ) - op.drop_table('agent_runtime_sessions') - with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op: - batch_op.add_column(sa.Column('status', sa.String(length=32), server_default='active', nullable=False)) - batch_op.add_column(sa.Column('retired_at', sa.DateTime(), nullable=True)) - batch_op.create_index('agent_home_snapshot_status_retired_idx', ['status', 'retired_at'], unique=False) + op.drop_table("agent_runtime_sessions") + with op.batch_alter_table("agent_home_snapshots", schema=None) as batch_op: + batch_op.add_column(sa.Column("status", sa.String(length=32), server_default="active", nullable=False)) + batch_op.add_column(sa.Column("retired_at", sa.DateTime(), nullable=True)) + batch_op.create_index("agent_home_snapshot_status_retired_idx", ["status", "retired_at"], unique=False) - with op.batch_alter_table('conversations', schema=None) as batch_op: - batch_op.add_column(sa.Column('agent_workspace_binding_id', models.types.StringUUID(), nullable=True)) + with op.batch_alter_table("conversations", schema=None) as batch_op: + batch_op.add_column(sa.Column("agent_workspace_binding_id", models.types.StringUUID(), nullable=True)) - with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op: - batch_op.add_column(sa.Column('agent_workspace_binding_id', models.types.StringUUID(), nullable=True)) + with op.batch_alter_table("agent_config_drafts", schema=None) as batch_op: + batch_op.add_column(sa.Column("agent_workspace_binding_id", models.types.StringUUID(), nullable=True)) - with op.batch_alter_table('workflow_node_executions', schema=None) as batch_op: - batch_op.add_column(sa.Column('agent_workspace_binding_id', models.types.StringUUID(), nullable=True)) + with op.batch_alter_table("workflow_node_executions", schema=None) as batch_op: + batch_op.add_column(sa.Column("agent_workspace_binding_id", models.types.StringUUID(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('workflow_node_executions', schema=None) as batch_op: - batch_op.drop_column('agent_workspace_binding_id') + with op.batch_alter_table("workflow_node_executions", schema=None) as batch_op: + batch_op.drop_column("agent_workspace_binding_id") - with op.batch_alter_table('agent_config_drafts', schema=None) as batch_op: - batch_op.drop_column('agent_workspace_binding_id') + with op.batch_alter_table("agent_config_drafts", schema=None) as batch_op: + batch_op.drop_column("agent_workspace_binding_id") - with op.batch_alter_table('conversations', schema=None) as batch_op: - batch_op.drop_column('agent_workspace_binding_id') + with op.batch_alter_table("conversations", schema=None) as batch_op: + batch_op.drop_column("agent_workspace_binding_id") - with op.batch_alter_table('agent_home_snapshots', schema=None) as batch_op: - batch_op.drop_index('agent_home_snapshot_status_retired_idx') - batch_op.drop_column('retired_at') - batch_op.drop_column('status') + with op.batch_alter_table("agent_home_snapshots", schema=None) as batch_op: + batch_op.drop_index("agent_home_snapshot_status_retired_idx") + batch_op.drop_column("retired_at") + batch_op.drop_column("status") - op.create_table('agent_runtime_sessions', - sa.Column('id', sa.UUID(), server_default=sa.text('uuidv7()'), autoincrement=False, nullable=False), - sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False), - sa.Column('app_id', sa.UUID(), autoincrement=False, nullable=False), - sa.Column('owner_type', sa.VARCHAR(length=32), autoincrement=False, nullable=False), - sa.Column('agent_id', sa.UUID(), autoincrement=False, nullable=False), - sa.Column('backend_run_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True), - sa.Column('session_snapshot', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('workflow_id', sa.UUID(), autoincrement=False, nullable=True), - sa.Column('workflow_run_id', sa.UUID(), autoincrement=False, nullable=True), - sa.Column('node_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True), - sa.Column('node_execution_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True), - sa.Column('binding_id', sa.UUID(), autoincrement=False, nullable=True), - sa.Column('agent_config_snapshot_id', sa.UUID(), autoincrement=False, nullable=True), - sa.Column('composition_layer_specs', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('conversation_id', sa.UUID(), autoincrement=False, nullable=True), - sa.Column('status', sa.VARCHAR(length=32), server_default=sa.text("'active'::character varying"), autoincrement=False, nullable=False), - sa.Column('cleaned_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), - sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=False), - sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=False), - sa.Column('pending_form_id', sa.UUID(), autoincrement=False, nullable=True), - sa.Column('pending_tool_call_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True), - sa.Column('home_snapshot_id', sa.UUID(), autoincrement=False, nullable=True), - sa.PrimaryKeyConstraint('id', name=op.f('agent_runtime_session_pkey')) + op.create_table( + "agent_runtime_sessions", + sa.Column( + "id", + models.types.StringUUID(), + server_default=sa.text("uuidv7()") if op.get_bind().dialect.name == "postgresql" else None, + autoincrement=False, + nullable=False, + ), + sa.Column("tenant_id", models.types.StringUUID(), autoincrement=False, nullable=False), + sa.Column("app_id", models.types.StringUUID(), autoincrement=False, nullable=False), + sa.Column("owner_type", sa.VARCHAR(length=32), autoincrement=False, nullable=False), + sa.Column("agent_id", models.types.StringUUID(), autoincrement=False, nullable=False), + sa.Column("backend_run_id", sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column("session_snapshot", models.types.LongText(), autoincrement=False, nullable=False), + sa.Column("workflow_id", models.types.StringUUID(), autoincrement=False, nullable=True), + sa.Column("workflow_run_id", models.types.StringUUID(), autoincrement=False, nullable=True), + sa.Column("node_id", sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column("node_execution_id", sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column("binding_id", models.types.StringUUID(), autoincrement=False, nullable=True), + sa.Column("agent_config_snapshot_id", models.types.StringUUID(), autoincrement=False, nullable=True), + sa.Column("composition_layer_specs", models.types.LongText(), autoincrement=False, nullable=False), + sa.Column("conversation_id", models.types.StringUUID(), autoincrement=False, nullable=True), + sa.Column( + "status", sa.VARCHAR(length=32), server_default=sa.text("'active'"), autoincrement=False, nullable=False + ), + sa.Column("cleaned_at", sa.DateTime(), autoincrement=False, nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + autoincrement=False, + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + autoincrement=False, + nullable=False, + ), + sa.Column("pending_form_id", models.types.StringUUID(), autoincrement=False, nullable=True), + sa.Column("pending_tool_call_id", sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column("home_snapshot_id", models.types.StringUUID(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("agent_runtime_session_pkey")), ) - with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op: - batch_op.create_index(batch_op.f('agent_runtime_session_workflow_scope_unique'), ['tenant_id', 'workflow_run_id', 'node_id', 'binding_id', 'agent_id'], unique=True, postgresql_where='(workflow_run_id IS NOT NULL)') - batch_op.create_index(batch_op.f('agent_runtime_session_workflow_lookup_idx'), ['tenant_id', 'workflow_run_id', 'node_id', 'status'], unique=False) - batch_op.create_index(batch_op.f('agent_runtime_session_conversation_scope_unique'), ['tenant_id', 'conversation_id', 'agent_id', 'agent_config_snapshot_id', 'home_snapshot_id'], unique=True, postgresql_where='(conversation_id IS NOT NULL)') - batch_op.create_index(batch_op.f('agent_runtime_session_conversation_lookup_idx'), ['tenant_id', 'conversation_id', 'status'], unique=False) - batch_op.create_index(batch_op.f('agent_runtime_session_backend_run_idx'), ['backend_run_id'], unique=False) + with op.batch_alter_table("agent_runtime_sessions", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("agent_runtime_session_workflow_scope_unique"), + ["tenant_id", "workflow_run_id", "node_id", "binding_id", "agent_id"], + unique=True, + postgresql_where="(workflow_run_id IS NOT NULL)", + ) + batch_op.create_index( + batch_op.f("agent_runtime_session_workflow_lookup_idx"), + ["tenant_id", "workflow_run_id", "node_id", "status"], + unique=False, + ) + batch_op.create_index( + batch_op.f("agent_runtime_session_conversation_scope_unique"), + ["tenant_id", "conversation_id", "agent_id", "agent_config_snapshot_id", "home_snapshot_id"], + unique=True, + postgresql_where="(conversation_id IS NOT NULL)", + ) + batch_op.create_index( + batch_op.f("agent_runtime_session_conversation_lookup_idx"), + ["tenant_id", "conversation_id", "status"], + unique=False, + ) + batch_op.create_index(batch_op.f("agent_runtime_session_backend_run_idx"), ["backend_run_id"], unique=False) - with op.batch_alter_table('agent_workspaces', schema=None) as batch_op: - batch_op.drop_index('agent_workspace_tenant_status_idx') - batch_op.drop_index('agent_workspace_tenant_app_status_idx') - batch_op.drop_index('agent_workspace_status_retired_idx') - batch_op.drop_index('agent_workspace_owner_active_unique') + with op.batch_alter_table("agent_workspaces", schema=None) as batch_op: + batch_op.drop_index("agent_workspace_tenant_status_idx") + batch_op.drop_index("agent_workspace_tenant_app_status_idx") + batch_op.drop_index("agent_workspace_status_retired_idx") + batch_op.drop_index("agent_workspace_owner_active_unique") - op.drop_table('agent_workspaces') - with op.batch_alter_table('agent_workspace_bindings', schema=None) as batch_op: - batch_op.drop_index('agent_workspace_binding_workspace_status_idx') - batch_op.drop_index('agent_workspace_binding_status_retired_idx') - batch_op.drop_index('agent_workspace_binding_agent_status_idx') + op.drop_table("agent_workspaces") + with op.batch_alter_table("agent_workspace_bindings", schema=None) as batch_op: + batch_op.drop_index("agent_workspace_binding_workspace_status_idx") + batch_op.drop_index("agent_workspace_binding_status_retired_idx") + batch_op.drop_index("agent_workspace_binding_agent_status_idx") - op.drop_table('agent_workspace_bindings') + op.drop_table("agent_workspace_bindings") # ### end Alembic commands ### diff --git a/api/migrations/versions/2026_08_23_1200-df1b0114de40_rename_workflow_copilot_to_dify_builder.py b/api/migrations/versions/2026_08_23_1200-df1b0114de40_rename_workflow_copilot_to_dify_builder.py index f205264e26c..64c4f9bceaa 100644 --- a/api/migrations/versions/2026_08_23_1200-df1b0114de40_rename_workflow_copilot_to_dify_builder.py +++ b/api/migrations/versions/2026_08_23_1200-df1b0114de40_rename_workflow_copilot_to_dify_builder.py @@ -3,7 +3,7 @@ Project rename: "Workflow Copilot" -> "Dify Builder". Renames the 7 feature tables created in b28a1b2fbf4d, plus their primary-key/unique constraints and indexes, so the schema matches the renamed ORM models (``models.dify_builder``). -Pure metadata renames (ALTER ... RENAME) — no data is moved. Postgres. +Pure metadata renames (ALTER ... RENAME) — no data is moved. Revision ID: df1b0114de40 Revises: b28a1b2fbf4d @@ -64,20 +64,39 @@ def _un_rename(new: str) -> str: return new.replace(_NEW, _OLD, 1) +# MySQL index names are local to their table; primary keys are always named PRIMARY. +_INDEX_TABLES = dict(zip(_INDEXES, [_rename(_TABLES[0]), *map(_rename, _TABLES)], strict=True)) + + +def _rename_index(table: str, old: str, new: str) -> None: + if op.get_bind().dialect.name == "mysql": + op.execute(f"ALTER TABLE {table} RENAME INDEX {old} TO {new}") + else: + op.execute(f'ALTER INDEX "{old}" RENAME TO "{new}"') + + +def _rename_constraint(table: str, old: str, new: str) -> None: + if op.get_bind().dialect.name == "mysql": + if not old.endswith("_pkey"): + _rename_index(table, old, new) + else: + op.execute(f'ALTER TABLE "{table}" RENAME CONSTRAINT "{old}" TO "{new}"') + + def upgrade(): # Tables first, so the constraint renames below can reference the new names. for old in _TABLES: op.rename_table(old, _rename(old)) for idx in _INDEXES: - op.execute(f'ALTER INDEX "{idx}" RENAME TO "{_rename(idx)}"') + _rename_index(_INDEX_TABLES[idx], idx, _rename(idx)) for cname, new_table in _CONSTRAINTS: - op.execute(f'ALTER TABLE "{new_table}" RENAME CONSTRAINT "{cname}" TO "{_rename(cname)}"') + _rename_constraint(new_table, cname, _rename(cname)) def downgrade(): for cname, new_table in _CONSTRAINTS: - op.execute(f'ALTER TABLE "{new_table}" RENAME CONSTRAINT "{_rename(cname)}" TO "{cname}"') + _rename_constraint(new_table, _rename(cname), cname) for idx in _INDEXES: - op.execute(f'ALTER INDEX "{_rename(idx)}" RENAME TO "{idx}"') + _rename_index(_INDEX_TABLES[idx], _rename(idx), idx) for old in _TABLES: op.rename_table(_rename(old), old) diff --git a/api/migrations/versions/2026_08_29_1200-c3f1a9b2e6d4_add_dataset_api_token_bindings.py b/api/migrations/versions/2026_08_29_1200-c3f1a9b2e6d4_add_dataset_api_token_bindings.py index eb500bb7274..fb65c891b6a 100644 --- a/api/migrations/versions/2026_08_29_1200-c3f1a9b2e6d4_add_dataset_api_token_bindings.py +++ b/api/migrations/versions/2026_08_29_1200-c3f1a9b2e6d4_add_dataset_api_token_bindings.py @@ -15,7 +15,7 @@ Both foreign keys cascade on delete, so removing a key or a dataset drops its bi import sqlalchemy as sa from alembic import op -import models as models +import models # revision identifiers, used by Alembic. revision = "c3f1a9b2e6d4" @@ -46,7 +46,6 @@ def upgrade(): def downgrade(): - with op.batch_alter_table("dataset_api_token_bindings", schema=None) as batch_op: - batch_op.drop_index("dataset_api_token_binding_dataset_idx") - batch_op.drop_index("dataset_api_token_binding_token_idx") + # DROP TABLE removes its indexes and foreign keys together. MySQL refuses + # to drop a supporting index separately while its foreign key still exists. op.drop_table("dataset_api_token_bindings") diff --git a/api/migrations/versions/2026_10_14_1200-c67e12a904bf_add_builder_native_testing.py b/api/migrations/versions/2026_10_14_1200-c67e12a904bf_add_builder_native_testing.py index 7597c97ffba..3b0f329a481 100644 --- a/api/migrations/versions/2026_10_14_1200-c67e12a904bf_add_builder_native_testing.py +++ b/api/migrations/versions/2026_10_14_1200-c67e12a904bf_add_builder_native_testing.py @@ -7,6 +7,7 @@ Revises: a61e9b2c7d40 import sqlalchemy as sa from alembic import op +from migrations.native_testing import function_schema_prefix, is_mysql, mysql_immutable_triggers, require_empty_table from models.types import AdjustedJSON, StringUUID revision = "c67e12a904bf" @@ -61,19 +62,20 @@ def _json(name): def _ledger_function(): - connection = op.get_bind() - schema = connection.execute(sa.text("SELECT current_schema()")).scalar_one() - quoted = connection.dialect.identifier_preparer.quote(schema) - return f"{quoted}.builder_native_immutable_ledger" + quoted = function_schema_prefix() + return f"{quoted}builder_native_immutable_ledger" def upgrade(): op.drop_constraint("builder_execution_request_state", "dify_builder_execution_requests", type_="check") + policy_version = ( + "JSON_UNQUOTE(JSON_EXTRACT(context, '$.policy_version'))" if is_mysql() else "context->>'policy_version'" + ) op.create_check_constraint( "builder_execution_request_state", "dify_builder_execution_requests", "state IN ('prepared','running','sealed') OR " - "(state = 'waiting' AND COALESCE(context->>'policy_version', '') = 'builder-native-test-v1')", + f"(state = 'waiting' AND COALESCE({policy_version}, '') = 'builder-native-test-v1')", ) op.create_index( "builder_native_waiting_idx", @@ -203,6 +205,12 @@ def upgrade(): sa.CheckConstraint("generation >= 0", name="native_pending_generation"), ) # Keep immutable evidence immutable even when another worker bypasses ORM. + if is_mysql(): + for suffix in ("attempts", "outcomes"): + mysql_immutable_triggers( + "dify_builder_native_" + suffix, "native_" + suffix + "_immutable", "native ledger is append-only" + ) + return op.execute(f"""CREATE FUNCTION {_ledger_function()}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'native ledger is append-only'; END; $$""") for table in ("dify_builder_native_attempts", "dify_builder_native_outcomes"): @@ -215,8 +223,13 @@ def upgrade(): def downgrade(): # Refuse to erase a resumable wait; operators must terminate through the # native lifecycle before downgrading this feature. - op.execute("""DO $$ BEGIN IF EXISTS (SELECT 1 FROM dify_builder_execution_requests WHERE state='waiting') - THEN RAISE EXCEPTION 'terminate native waits before downgrade'; END IF; END $$""") + if is_mysql(): + require_empty_table( + "dify_builder_execution_requests", "terminate native waits before downgrade", predicate="state='waiting'" + ) + else: + op.execute("""DO $$ BEGIN IF EXISTS (SELECT 1 FROM dify_builder_execution_requests WHERE state='waiting') + THEN RAISE EXCEPTION 'terminate native waits before downgrade'; END IF; END $$""") for table in ( "pending_actions", "continuations", @@ -228,7 +241,8 @@ def downgrade(): "scopes", ): op.drop_table("dify_builder_native_" + table) - op.execute(f"DROP FUNCTION {_ledger_function()}()") + if not is_mysql(): + op.execute(f"DROP FUNCTION {_ledger_function()}()") op.drop_index("builder_native_waiting_idx", table_name="dify_builder_execution_requests") op.drop_constraint("builder_execution_request_state", "dify_builder_execution_requests", type_="check") op.create_check_constraint( diff --git a/api/migrations/versions/2026_10_14_1500-d28f0e3c912a_add_native_continuation_dispatch.py b/api/migrations/versions/2026_10_14_1500-d28f0e3c912a_add_native_continuation_dispatch.py index 4e3e4843482..e6e026a0aeb 100644 --- a/api/migrations/versions/2026_10_14_1500-d28f0e3c912a_add_native_continuation_dispatch.py +++ b/api/migrations/versions/2026_10_14_1500-d28f0e3c912a_add_native_continuation_dispatch.py @@ -7,6 +7,7 @@ Revises: c67e12a904bf import sqlalchemy as sa from alembic import op +from migrations.native_testing import function_schema_prefix, is_mysql, mysql_immutable_triggers, require_empty_table from models.types import AdjustedJSON, StringUUID revision = "d28f0e3c912a" @@ -82,22 +83,28 @@ def upgrade(): "state IN ('queued','published','claimed','completed','unknown','cancelled')", name="native_dispatch_state" ), ) - connection = op.get_bind() - schema = connection.execute(sa.text("SELECT current_schema()")).scalar_one() - quoted = connection.dialect.identifier_preparer.quote(schema) + if is_mysql(): + for suffix in ("payload_objects", "continuations"): + mysql_immutable_triggers( + "dify_builder_native_" + suffix, "native_" + suffix + "_immutable", "native ledger is append-only" + ) + return + quoted = function_schema_prefix() for table in ("payload_objects", "continuations"): op.execute( sa.text( f"CREATE TRIGGER native_immutable BEFORE UPDATE OR DELETE ON dify_builder_native_{table} " - f"FOR EACH ROW EXECUTE FUNCTION {quoted}.builder_native_immutable_ledger()" + f"FOR EACH ROW EXECUTE FUNCTION {quoted}builder_native_immutable_ledger()" ) ) def downgrade(): - connection = op.get_bind() - if connection.execute(sa.text("SELECT 1 FROM dify_builder_native_invocation_states LIMIT 1")).first(): - raise RuntimeError("native continuation recovery data must be retained") - op.execute("DROP TRIGGER native_immutable ON dify_builder_native_continuations") + require_empty_table("dify_builder_native_invocation_states", "native continuation recovery data must be retained") + if is_mysql(): + for event in ("update", "delete"): + op.execute(f"DROP TRIGGER native_continuations_immutable_{event}") + else: + op.execute("DROP TRIGGER native_immutable ON dify_builder_native_continuations") for table in ("dispatches", "invocation_states", "payload_objects"): op.drop_table("dify_builder_native_" + table) diff --git a/api/migrations/versions/2026_10_15_0900-f47a1c90d2e8_bind_native_invocation_read_owners.py b/api/migrations/versions/2026_10_15_0900-f47a1c90d2e8_bind_native_invocation_read_owners.py index 44638b3d801..2a71f42ecb8 100644 --- a/api/migrations/versions/2026_10_15_0900-f47a1c90d2e8_bind_native_invocation_read_owners.py +++ b/api/migrations/versions/2026_10_15_0900-f47a1c90d2e8_bind_native_invocation_read_owners.py @@ -7,6 +7,13 @@ Revises: d28f0e3c912a import sqlalchemy as sa from alembic import op +from migrations.native_testing import ( + function_schema_prefix, + is_mysql, + mysql_changed, + mysql_program, + require_empty_table, +) from models.types import StringUUID revision = "f47a1c90d2e8" @@ -20,12 +27,30 @@ def upgrade(): op.add_column(table, sa.Column("app_id", StringUUID(), nullable=True)) op.add_column(table, sa.Column("workflow_id", StringUUID(), nullable=True)) op.create_check_constraint("native_state_read_owner_pair", table, "(app_id IS NULL) = (workflow_id IS NULL)") - connection = op.get_bind() - schema = connection.execute(sa.text("SELECT current_schema()")).scalar_one() - quoted = connection.dialect.identifier_preparer.quote(schema) + if is_mysql(): + changed = mysql_changed( + ( + "id", + "tenant_id", + "request_id", + "invocation_id", + "app_id", + "workflow_id", + "context_object_id", + "parent_invocation_id", + "parent_operation_digest", + "child_identity_digest", + ) + ) + mysql_program(f"""CREATE TRIGGER native_read_owner_immutable BEFORE UPDATE ON {table} + FOR EACH ROW BEGIN IF {changed} THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'native invocation read owner is immutable'; + END IF; END""") + return + quoted = function_schema_prefix() op.execute( sa.text(f""" - CREATE FUNCTION {quoted}.builder_native_immutable_read_owner() RETURNS trigger + CREATE FUNCTION {quoted}builder_native_immutable_read_owner() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF ROW(NEW.id, NEW.tenant_id, NEW.request_id, NEW.invocation_id, NEW.app_id, NEW.workflow_id, NEW.context_object_id, NEW.parent_invocation_id, @@ -43,19 +68,21 @@ def upgrade(): op.execute( sa.text( f"CREATE TRIGGER native_read_owner_immutable BEFORE UPDATE ON {table} " - f"FOR EACH ROW EXECUTE FUNCTION {quoted}.builder_native_immutable_read_owner()" + f"FOR EACH ROW EXECUTE FUNCTION {quoted}builder_native_immutable_read_owner()" ) ) def downgrade(): - connection = op.get_bind() - if connection.execute(sa.text("SELECT 1 FROM dify_builder_native_invocation_states LIMIT 1")).first(): - raise RuntimeError("native invocation read owner evidence must be retained") - schema = connection.execute(sa.text("SELECT current_schema()")).scalar_one() - quoted = connection.dialect.identifier_preparer.quote(schema) - op.execute("DROP TRIGGER native_read_owner_immutable ON dify_builder_native_invocation_states") - op.execute(sa.text(f"DROP FUNCTION {quoted}.builder_native_immutable_read_owner()")) + require_empty_table( + "dify_builder_native_invocation_states", "native invocation read owner evidence must be retained" + ) + if is_mysql(): + op.execute("DROP TRIGGER native_read_owner_immutable") + else: + quoted = function_schema_prefix() + op.execute("DROP TRIGGER native_read_owner_immutable ON dify_builder_native_invocation_states") + op.execute(sa.text(f"DROP FUNCTION {quoted}builder_native_immutable_read_owner()")) op.drop_constraint("native_state_read_owner_pair", "dify_builder_native_invocation_states", type_="check") op.drop_column("dify_builder_native_invocation_states", "workflow_id") op.drop_column("dify_builder_native_invocation_states", "app_id") diff --git a/api/migrations/versions/2026_10_15_1200-e59b7d32a6c1_add_native_effect_dispatch_claims.py b/api/migrations/versions/2026_10_15_1200-e59b7d32a6c1_add_native_effect_dispatch_claims.py index 6a4a68e0089..38fe2c5f839 100644 --- a/api/migrations/versions/2026_10_15_1200-e59b7d32a6c1_add_native_effect_dispatch_claims.py +++ b/api/migrations/versions/2026_10_15_1200-e59b7d32a6c1_add_native_effect_dispatch_claims.py @@ -7,6 +7,13 @@ Revises: f47a1c90d2e8 import sqlalchemy as sa from alembic import op +from migrations.native_testing import ( + function_schema_prefix, + is_mysql, + mysql_immutable_triggers, + require_empty_table, + unlock_tables, +) from models.types import StringUUID revision = "e59b7d32a6c1" @@ -29,22 +36,23 @@ def upgrade(): sa.UniqueConstraint("attempt_id", name="native_effect_dispatch_claim_attempt"), sa.UniqueConstraint("operation_id", name="native_effect_dispatch_claim_operation"), ) - connection = op.get_bind() - schema = connection.execute(sa.text("SELECT current_schema()")).scalar_one() - quoted = connection.dialect.identifier_preparer.quote(schema) + if is_mysql(): + mysql_immutable_triggers(table, "native_effect_dispatch_claims_immutable", "native ledger is append-only") + return + quoted = function_schema_prefix() op.execute( sa.text( f"CREATE TRIGGER native_ledger_immutable BEFORE UPDATE OR DELETE ON {table} " - f"FOR EACH ROW EXECUTE FUNCTION {quoted}.builder_native_immutable_ledger()" + f"FOR EACH ROW EXECUTE FUNCTION {quoted}builder_native_immutable_ledger()" ) ) def downgrade(): # Deleting evidence could let a lost response or restarted owner replay IO. - connection = op.get_bind() # Serialize the empty check and DROP with every concurrent claim transaction. - connection.execute(sa.text("LOCK TABLE dify_builder_native_effect_dispatch_claims IN ACCESS EXCLUSIVE MODE")) - if connection.execute(sa.text("SELECT 1 FROM dify_builder_native_effect_dispatch_claims LIMIT 1")).first(): - raise RuntimeError("native dispatch claim evidence must be retained") + require_empty_table( + "dify_builder_native_effect_dispatch_claims", "native dispatch claim evidence must be retained", lock=True + ) op.drop_table("dify_builder_native_effect_dispatch_claims") + unlock_tables() diff --git a/api/migrations/versions/2026_10_16_1800-7d2b9c4e61a8_add_native_code_physical_owners.py b/api/migrations/versions/2026_10_16_1800-7d2b9c4e61a8_add_native_code_physical_owners.py index 60150c5b953..e462c011499 100644 --- a/api/migrations/versions/2026_10_16_1800-7d2b9c4e61a8_add_native_code_physical_owners.py +++ b/api/migrations/versions/2026_10_16_1800-7d2b9c4e61a8_add_native_code_physical_owners.py @@ -7,6 +7,14 @@ Revises: e59b7d32a6c1 import sqlalchemy as sa from alembic import op +from migrations.native_testing import ( + function_schema_prefix, + is_mysql, + mysql_changed, + mysql_program, + require_empty_table, + unlock_tables, +) from models.types import StringUUID revision = "7d2b9c4e61a8" @@ -16,8 +24,62 @@ depends_on = None TABLE = "dify_builder_native_code_physical_operations" +def _regex(field: str, pattern: str) -> str: + return f"REGEXP_LIKE({field}, '{pattern}', 'c')" if is_mysql() else f"{field} ~ '{pattern}'" + + +def _mysql_monotone(all_fields: tuple[str, ...]) -> None: + identity = mysql_changed( + ( + "id", + "tenant_id", + "actor_id", + "request_id", + "invocation_id", + "operation_id", + "attempt_id", + "effect_claim_id", + "host_dispatch_id", + "host_acquisition_id", + "root_invocation_id", + "host_generation", + "invocation_generation", + "native_execution_id", + "operation_digest", + "intent_digest", + "scope_id", + "scope_generation", + "scope_spec_digest", + "environment_digest", + "payload_digest", + "profile_digest", + "base_snapshot_ref", + "base_snapshot_digest", + "scope_expires_at", + ) + ) + changed = mysql_changed(all_fields) + ambiguous_changed = mysql_changed(("scope_create_acked", "job_reserved", "job_id", "input_may_have_been_sent")) + mysql_program(f"""CREATE TRIGGER code_physical_monotone_update BEFORE UPDATE ON {TABLE} + FOR EACH ROW BEGIN + IF {identity} THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Code physical identity is immutable'; END IF; + IF (OLD.scope_create_acked AND NOT NEW.scope_create_acked) + OR (OLD.job_reserved AND NOT NEW.job_reserved) + OR (OLD.input_may_have_been_sent AND NOT NEW.input_may_have_been_sent) + OR (OLD.ambiguous AND NOT NEW.ambiguous) + OR (OLD.job_id IS NOT NULL AND NOT (BINARY NEW.job_id <=> BINARY OLD.job_id)) + OR NEW.observed_output_offset < OLD.observed_output_offset + OR (OLD.destroyed_at IS NOT NULL AND ({changed})) + OR (OLD.ambiguous AND ({ambiguous_changed})) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = + 'Code physical evidence cannot regress or dispatch after settlement'; + END IF; END""") + mysql_program(f"""CREATE TRIGGER code_physical_monotone_delete BEFORE DELETE ON {TABLE} + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Code physical evidence must be retained'""") + + def upgrade(): - op.create_table( + table = op.create_table( TABLE, sa.Column("id", StringUUID(), primary_key=True), sa.Column("tenant_id", StringUUID(), nullable=False), @@ -85,20 +147,28 @@ def upgrade(): name="code_physical_input", ), sa.CheckConstraint("NOT job_reserved OR scope_create_acked", name="code_physical_reserve"), - sa.CheckConstraint("scope_id ~ '^code_[0-9a-f]{32}$'", name="code_physical_scope"), + sa.CheckConstraint(_regex("scope_id", "^code_[0-9a-f]{32}$"), name="code_physical_scope"), sa.CheckConstraint( - "job_id IS NULL OR job_id ~ '^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$'", + "job_id IS NULL OR " + _regex("job_id", "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$"), name="code_physical_job_shape", ), sa.CheckConstraint( - "base_snapshot_ref ~ '^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$'", + _regex("base_snapshot_ref", "^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$"), name="code_physical_snapshot", ), sa.CheckConstraint( - "operation_digest ~ '^[0-9a-f]{64}$' AND intent_digest ~ '^[0-9a-f]{64}$'" - " AND scope_spec_digest ~ '^[0-9a-f]{64}$' AND environment_digest ~ '^[0-9a-f]{64}$'" - " AND payload_digest ~ '^[0-9a-f]{64}$' AND profile_digest ~ '^[0-9a-f]{64}$'" - " AND base_snapshot_digest ~ '^[0-9a-f]{64}$'", + " AND ".join( + _regex(field, "^[0-9a-f]{64}$") + for field in ( + "operation_digest", + "intent_digest", + "scope_spec_digest", + "environment_digest", + "payload_digest", + "profile_digest", + "base_snapshot_digest", + ) + ), name="code_physical_digests", ), sa.ForeignKeyConstraint( @@ -136,12 +206,13 @@ def upgrade(): ondelete="RESTRICT", ), ) - connection = op.get_bind() - schema = connection.execute(sa.text("SELECT current_schema()")).scalar_one() - quoted = connection.dialect.identifier_preparer.quote(schema) + if is_mysql(): + _mysql_monotone(tuple(column.name for column in table.columns)) + return + quoted = function_schema_prefix() op.execute( sa.text(f""" - CREATE FUNCTION {quoted}.builder_code_physical_monotone() RETURNS trigger + CREATE FUNCTION {quoted}builder_code_physical_monotone() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Code physical evidence must be retained'; @@ -195,17 +266,15 @@ def upgrade(): op.execute( sa.text( f"CREATE TRIGGER code_physical_monotone BEFORE UPDATE OR DELETE ON {TABLE} " - f"FOR EACH ROW EXECUTE FUNCTION {quoted}.builder_code_physical_monotone()" + f"FOR EACH ROW EXECUTE FUNCTION {quoted}builder_code_physical_monotone()" ) ) def downgrade(): - connection = op.get_bind() - connection.execute(sa.text(f"LOCK TABLE {TABLE} IN ACCESS EXCLUSIVE MODE")) - if connection.execute(sa.text(f"SELECT 1 FROM {TABLE} LIMIT 1")).first(): - raise RuntimeError("Code physical evidence must be retained") + require_empty_table(TABLE, "Code physical evidence must be retained", lock=True) op.drop_table(TABLE) - schema = connection.execute(sa.text("SELECT current_schema()")).scalar_one() - quoted = connection.dialect.identifier_preparer.quote(schema) - op.execute(sa.text(f"DROP FUNCTION {quoted}.builder_code_physical_monotone()")) + unlock_tables() + if not is_mysql(): + quoted = function_schema_prefix() + op.execute(sa.text(f"DROP FUNCTION {quoted}builder_code_physical_monotone()")) diff --git a/api/models/dify_builder.py b/api/models/dify_builder.py index e40fd93ed3a..13c963ca22d 100644 --- a/api/models/dify_builder.py +++ b/api/models/dify_builder.py @@ -6,11 +6,19 @@ from sqlalchemy.orm import Mapped, mapped_column from libs.uuid_utils import uuidv7 -from .base import Base +from .base import TypeBase from .types import AdjustedJSON, StringUUID -class DifyBuilderSession(Base): +class BuilderRowIdentity: + """Preserve ORM object identity instead of TypeBase's inherited dataclass equality.""" + + __eq__ = object.__eq__ + __hash__ = object.__hash__ + __repr__ = object.__repr__ + + +class DifyBuilderSession(BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """ DifyBuilder Session Model @@ -27,21 +35,28 @@ class DifyBuilderSession(Base): sa.Index("dify_builder_session_app_idx", "app_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + # Allocation stays at INSERT; the repository can supply a domain ID before flush. + id: Mapped[str] = mapped_column(StringUUID, insert_default=lambda: str(uuidv7()), default=None) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) owner_account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) entry_mode: Mapped[str] = mapped_column(String(50), nullable=False) current_state: Mapped[str] = mapped_column(String(50), nullable=False) - version: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("1")) + version: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("1"), default=None) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), default=None + ) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + default=None, ) -class DifyBuilderSessionCommit(Base): +class DifyBuilderSessionCommit(BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """ DifyBuilder Session Commit Model @@ -56,17 +71,19 @@ class DifyBuilderSessionCommit(Base): sa.Index("dify_builder_session_commit_session_idx", "session_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + id: Mapped[str] = mapped_column(StringUUID, insert_default=lambda: str(uuidv7()), default=None) session_id: Mapped[str] = mapped_column(StringUUID, nullable=False) version: Mapped[int] = mapped_column(sa.Integer, nullable=False) state: Mapped[str] = mapped_column(String(50), nullable=False) context: Mapped[dict] = mapped_column(AdjustedJSON, nullable=False) actor: Mapped[str] = mapped_column(String(50), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), default=None + ) -class DifyBuilderSnapshot(Base): +class DifyBuilderSnapshot(BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """ DifyBuilder Snapshot Model @@ -80,15 +97,17 @@ class DifyBuilderSnapshot(Base): sa.Index("dify_builder_snapshot_session_idx", "session_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + id: Mapped[str] = mapped_column(StringUUID, insert_default=lambda: str(uuidv7()), default=None) session_id: Mapped[str] = mapped_column(StringUUID, nullable=False) hash: Mapped[str] = mapped_column(String(255), nullable=False) graph: Mapped[dict] = mapped_column(AdjustedJSON, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), default=None + ) -class DifyBuilderCheckpoint(Base): +class DifyBuilderCheckpoint(BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """ DifyBuilder Checkpoint Model @@ -102,15 +121,17 @@ class DifyBuilderCheckpoint(Base): sa.Index("dify_builder_checkpoint_session_idx", "session_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + id: Mapped[str] = mapped_column(StringUUID, insert_default=lambda: str(uuidv7()), default=None) session_id: Mapped[str] = mapped_column(StringUUID, nullable=False) state: Mapped[str] = mapped_column(String(50), nullable=False) snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), default=None + ) -class DifyBuilderRun(Base): +class DifyBuilderRun(BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """ DifyBuilder Run Model @@ -124,25 +145,27 @@ class DifyBuilderRun(Base): sa.Index("dify_builder_run_session_idx", "session_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + id: Mapped[str] = mapped_column(StringUUID, insert_default=lambda: str(uuidv7()), default=None) session_id: Mapped[str] = mapped_column(StringUUID, nullable=False) kind: Mapped[str] = mapped_column(String(50), nullable=False) - dify_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + dify_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) status: Mapped[str] = mapped_column(String(50), nullable=False) - per_node: Mapped[list[dict] | None] = mapped_column(AdjustedJSON, nullable=True) - verification: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True) - culprit_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + per_node: Mapped[list[dict] | None] = mapped_column(AdjustedJSON, nullable=True, default=None) + verification: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True, default=None) + culprit_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) # Launch-failure text captured when a verify run throws before any node runs. - error: Mapped[str | None] = mapped_column(sa.Text, nullable=True) - inputs_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) - tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) - elapsed_ms: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) - immutable: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) + error: Mapped[str | None] = mapped_column(sa.Text, nullable=True, default=None) + inputs_ref: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None) + elapsed_ms: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None) + immutable: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=None) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), default=None + ) -class DifyBuilderConversationItem(Base): +class DifyBuilderConversationItem(BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """ DifyBuilder Conversation Item Model @@ -157,17 +180,19 @@ class DifyBuilderConversationItem(Base): sa.Index("dify_builder_conversation_item_session_idx", "session_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + id: Mapped[str] = mapped_column(StringUUID, insert_default=lambda: str(uuidv7()), default=None) session_id: Mapped[str] = mapped_column(StringUUID, nullable=False) seq: Mapped[int] = mapped_column(sa.Integer, nullable=False) kind: Mapped[str] = mapped_column(String(50), nullable=False) payload: Mapped[dict] = mapped_column(AdjustedJSON, nullable=False) at_version: Mapped[int] = mapped_column(sa.Integer, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), default=None + ) -class DifyBuilderTestInput(Base): +class DifyBuilderTestInput(BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """ DifyBuilder Test Input Model @@ -182,17 +207,19 @@ class DifyBuilderTestInput(Base): sa.Index("dify_builder_test_input_session_idx", "session_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + id: Mapped[str] = mapped_column(StringUUID, insert_default=lambda: str(uuidv7()), default=None) session_id: Mapped[str] = mapped_column(StringUUID, nullable=False) source: Mapped[str] = mapped_column(String(50), nullable=False) inputs: Mapped[dict] = mapped_column(AdjustedJSON, nullable=False) start_schema_hash: Mapped[str] = mapped_column(String(255), nullable=False) - http_fixtures: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True) + http_fixtures: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True, default=None) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), default=None + ) -class DifyBuilderExecutionRequest(Base): +class DifyBuilderExecutionRequest(BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """Immutable prepared policy and bounded append-only runtime observations. Launch and append mutations require a row lock. Native Run rows remain @@ -224,16 +251,18 @@ class DifyBuilderExecutionRequest(Base): context: Mapped[dict] = mapped_column(AdjustedJSON, nullable=False) context_digest: Mapped[str] = mapped_column(String(64), nullable=False) state: Mapped[str] = mapped_column(String(16), nullable=False, default="prepared") - native_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) - task_id: Mapped[str | None] = mapped_column(String(128), nullable=True) - observations: Mapped[list] = mapped_column(AdjustedJSON, nullable=False, default=list) - completion_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True) - completion_summary: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) - sealed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + native_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) + observations: Mapped[list] = mapped_column(AdjustedJSON, nullable=False, insert_default=list, default_factory=list) + completion_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None) + completion_summary: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), default=None + ) + sealed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) -class DifyBuilderRealCallConsent(Base): +class DifyBuilderRealCallConsent(BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """One human-approved, one-shot real Tool call and its durable spends. ``binding`` and ``disclosure`` are frozen at preparation; ``scope_digest`` @@ -283,7 +312,7 @@ class DifyBuilderRealCallConsent(Base): ), ) - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + id: Mapped[str] = mapped_column(StringUUID, insert_default=lambda: str(uuidv7()), default=None) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False) @@ -298,33 +327,39 @@ class DifyBuilderRealCallConsent(Base): disclosure_digest: Mapped[str] = mapped_column(String(64), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) state: Mapped[str] = mapped_column(String(16), nullable=False) - decision: Mapped[str | None] = mapped_column(String(16), nullable=True) - execution_request_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) - command_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) - reserved_version: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) - reserved_commit_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + decision: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None) + execution_request_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + command_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + reserved_version: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None) + reserved_commit_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) prepare_key: Mapped[str] = mapped_column(String(128), nullable=False) prepare_body_digest: Mapped[str] = mapped_column(String(64), nullable=False) prepare_receipt: Mapped[dict] = mapped_column(AdjustedJSON, nullable=False) - confirm_key: Mapped[str | None] = mapped_column(String(128), nullable=True) - confirm_body_digest: Mapped[str | None] = mapped_column(String(64), nullable=True) - confirm_receipt: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True) - execute_key: Mapped[str | None] = mapped_column(String(128), nullable=True) - execute_body_digest: Mapped[str | None] = mapped_column(String(64), nullable=True) - execute_receipt: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True) - claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - refresh_claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - business_claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + confirm_key: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) + confirm_body_digest: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None) + confirm_receipt: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True, default=None) + execute_key: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) + execute_body_digest: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None) + execute_receipt: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True, default=None) + claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + refresh_claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + business_claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) refresh_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0, server_default=sa.text("0")) business_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0, server_default=sa.text("0")) initial_credential_revision: Mapped[str] = mapped_column(String(64), nullable=False) - derived_credential_revision: Mapped[str | None] = mapped_column(String(64), nullable=True) + derived_credential_revision: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None) oauth_client_revision: Mapped[str] = mapped_column(String(64), nullable=False) - native_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) - native_node_execution_id: Mapped[str | None] = mapped_column(String(128), nullable=True) - outcome_state: Mapped[str | None] = mapped_column(String(16), nullable=True) - reason_code: Mapped[str | None] = mapped_column(String(128), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + native_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + native_node_execution_id: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) + outcome_state: Mapped[str | None] = mapped_column(String(16), nullable=True, default=None) + reason_code: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), default=None + ) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + default=None, ) diff --git a/api/models/dify_builder_native_tests.py b/api/models/dify_builder_native_tests.py index 84c683b7a93..eab393833f2 100644 --- a/api/models/dify_builder_native_tests.py +++ b/api/models/dify_builder_native_tests.py @@ -8,27 +8,29 @@ are a resumable projection. from datetime import datetime import sqlalchemy as sa -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column from libs.uuid_utils import uuidv7 -from models.base import Base +from models.base import TypeBase +from models.dify_builder import BuilderRowIdentity from models.types import AdjustedJSON, StringUUID -class RequestOwner: +class RequestOwner(MappedAsDataclass, kw_only=True, eq=False, repr=False): tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) request_id: Mapped[str] = mapped_column(StringUUID, nullable=False) -class InvocationOwner(RequestOwner): +class InvocationOwner(RequestOwner, kw_only=True, eq=False, repr=False): invocation_id: Mapped[str] = mapped_column(sa.String(128), nullable=False) -class Identity: - id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuidv7())) +class Identity(MappedAsDataclass, kw_only=True, eq=False, repr=False): + # Keep ledger IDs insert-generated while accepting caller-preallocated identities. + id: Mapped[str] = mapped_column(StringUUID, primary_key=True, insert_default=lambda: str(uuidv7()), default=None) -class Quota: +class Quota(MappedAsDataclass, kw_only=True, eq=False, repr=False): max_calls: Mapped[int] = mapped_column(sa.BigInteger, nullable=False) max_dispatches: Mapped[int] = mapped_column(sa.BigInteger, nullable=False) max_cost_units: Mapped[int] = mapped_column(sa.BigInteger, nullable=False) @@ -47,7 +49,7 @@ def quota_check(name: str) -> sa.CheckConstraint: ) -class NativeTestScope(Quota, Base): +class NativeTestScope(Quota, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): __tablename__ = "dify_builder_native_scopes" __table_args__ = ( quota_check("native_scope_quota"), @@ -64,7 +66,7 @@ class NativeTestScope(Quota, Base): profile: Mapped[dict] = mapped_column(AdjustedJSON, nullable=False) -class NativeTestInvocation(Identity, InvocationOwner, Base): +class NativeTestInvocation(Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): __tablename__ = "dify_builder_native_invocations" __table_args__ = (sa.UniqueConstraint("tenant_id", "request_id", "invocation_id", name="native_invocation_owner"),) active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False) @@ -75,7 +77,9 @@ class NativeTestInvocation(Identity, InvocationOwner, Base): bindings: Mapped[dict] = mapped_column(AdjustedJSON, nullable=False) -class NativeEffectGrant(Identity, RequestOwner, Quota, Base): +class NativeEffectGrant( + Identity, RequestOwner, Quota, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False +): __tablename__ = "dify_builder_native_grants" __table_args__ = ( sa.UniqueConstraint("tenant_id", "request_id", "scope_digest", name="native_grant_scope"), @@ -88,7 +92,9 @@ class NativeEffectGrant(Identity, RequestOwner, Quota, Base): actor_id: Mapped[str] = mapped_column(StringUUID, nullable=False) -class NativeEffectOperation(Identity, InvocationOwner, Base): +class NativeEffectOperation( + Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False +): __tablename__ = "dify_builder_native_operations" __table_args__ = ( sa.UniqueConstraint( @@ -103,10 +109,10 @@ class NativeEffectOperation(Identity, InvocationOwner, Base): intent_digest: Mapped[str] = mapped_column(sa.String(64), nullable=False) scope_digest: Mapped[str] = mapped_column(sa.String(64), nullable=False) state: Mapped[str] = mapped_column(sa.String(16), nullable=False) - result_ref: Mapped[str | None] = mapped_column(sa.String(128), nullable=True) + result_ref: Mapped[str | None] = mapped_column(sa.String(128), nullable=True, default=None) -class NativeEffectAttempt(Identity, InvocationOwner, Base): +class NativeEffectAttempt(Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): __tablename__ = "dify_builder_native_attempts" __table_args__ = ( sa.UniqueConstraint("operation_id", "dispatch_index", name="native_attempt_dispatch"), @@ -127,7 +133,9 @@ class NativeEffectAttempt(Identity, InvocationOwner, Base): profile: Mapped[dict] = mapped_column(AdjustedJSON, nullable=False) -class NativeEffectDispatchClaim(Identity, InvocationOwner, Base): +class NativeEffectDispatchClaim( + Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False +): """One physical-owner consumption of an already-spent attempt, never replayed.""" __tablename__ = "dify_builder_native_effect_dispatch_claims" @@ -140,7 +148,7 @@ class NativeEffectDispatchClaim(Identity, InvocationOwner, Base): claimed_at: Mapped[datetime] = mapped_column(sa.DateTime(timezone=True), nullable=False) -class NativeEffectOutcome(Identity, InvocationOwner, Base): +class NativeEffectOutcome(Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): __tablename__ = "dify_builder_native_outcomes" __table_args__ = ( sa.UniqueConstraint("attempt_id", name="native_outcome_attempt"), @@ -150,10 +158,10 @@ class NativeEffectOutcome(Identity, InvocationOwner, Base): operation_id: Mapped[str] = mapped_column(StringUUID, nullable=False) attempt_id: Mapped[str] = mapped_column(StringUUID, nullable=False) outcome: Mapped[str] = mapped_column(sa.String(16), nullable=False) - result_ref: Mapped[str | None] = mapped_column(sa.String(128), nullable=True) + result_ref: Mapped[str | None] = mapped_column(sa.String(128), nullable=True, default=None) -class NativeContinuation(Identity, InvocationOwner, Base): +class NativeContinuation(Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): __tablename__ = "dify_builder_native_continuations" __table_args__ = ( sa.UniqueConstraint("request_id", "invocation_id", "generation", name="native_continuation_generation"), @@ -165,7 +173,7 @@ class NativeContinuation(Identity, InvocationOwner, Base): payload_ref: Mapped[str] = mapped_column(sa.String(128), nullable=False) -class NativePendingAction(Identity, InvocationOwner, Base): +class NativePendingAction(Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): __tablename__ = "dify_builder_native_pending_actions" __table_args__ = ( sa.UniqueConstraint( @@ -190,10 +198,10 @@ class NativePendingAction(Identity, InvocationOwner, Base): reference: Mapped[str] = mapped_column(sa.String(128), nullable=False) owner_ref: Mapped[str] = mapped_column(sa.String(128), nullable=False) owner_version: Mapped[str] = mapped_column(sa.String(128), nullable=False) - operation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + operation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) -class NativePayloadObject(Identity, InvocationOwner, Base): +class NativePayloadObject(Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """SQL acknowledgement of an encrypted immutable object, after storage readback.""" __tablename__ = "dify_builder_native_payload_objects" @@ -209,7 +217,9 @@ class NativePayloadObject(Identity, InvocationOwner, Base): value_digest: Mapped[str] = mapped_column(sa.String(64), nullable=False) -class NativeInvocationState(Identity, InvocationOwner, Base): +class NativeInvocationState( + Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False +): """Current local execution facts. While running, a real wait_reason with validated current pending_ids is an @@ -231,24 +241,24 @@ class NativeInvocationState(Identity, InvocationOwner, Base): name="native_state_status", ), ) - app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) - workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) context_object_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - parent_invocation_id: Mapped[str | None] = mapped_column(sa.String(128), nullable=True) - parent_operation_digest: Mapped[str | None] = mapped_column(sa.String(64), nullable=True) - child_identity_digest: Mapped[str | None] = mapped_column(sa.String(64), nullable=True) + parent_invocation_id: Mapped[str | None] = mapped_column(sa.String(128), nullable=True, default=None) + parent_operation_digest: Mapped[str | None] = mapped_column(sa.String(64), nullable=True, default=None) + child_identity_digest: Mapped[str | None] = mapped_column(sa.String(64), nullable=True, default=None) generation: Mapped[int] = mapped_column(sa.BigInteger, nullable=False) state: Mapped[str] = mapped_column(sa.String(16), nullable=False) - checkpoint_id: Mapped[str | None] = mapped_column(sa.String(128), nullable=True) + checkpoint_id: Mapped[str | None] = mapped_column(sa.String(128), nullable=True, default=None) pending_ids: Mapped[list] = mapped_column(AdjustedJSON, nullable=False) - wait_reason: Mapped[str | None] = mapped_column(sa.String(16), nullable=True) - outcome: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True) + wait_reason: Mapped[str | None] = mapped_column(sa.String(16), nullable=True, default=None) + outcome: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True, default=None) resolution: Mapped[list] = mapped_column(AdjustedJSON, nullable=False) - cancellation_key: Mapped[str | None] = mapped_column(sa.String(64), nullable=True) + cancellation_key: Mapped[str | None] = mapped_column(sa.String(64), nullable=True, default=None) cleanup_complete: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False) -class NativeDispatch(Identity, InvocationOwner, Base): +class NativeDispatch(Identity, InvocationOwner, BuilderRowIdentity, TypeBase, kw_only=True, eq=False, repr=False): """Durable outbox and single worker claim. Publish acknowledgements are advisory.""" __tablename__ = "dify_builder_native_dispatches" @@ -263,5 +273,5 @@ class NativeDispatch(Identity, InvocationOwner, Base): generation: Mapped[int] = mapped_column(sa.BigInteger, nullable=False) idempotency_digest: Mapped[str] = mapped_column(sa.String(64), nullable=False) state: Mapped[str] = mapped_column(sa.String(16), nullable=False) - checkpoint_id: Mapped[str | None] = mapped_column(sa.String(128), nullable=True) - claim_expires_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True), nullable=True) + checkpoint_id: Mapped[str | None] = mapped_column(sa.String(128), nullable=True, default=None) + claim_expires_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True), nullable=True, default=None) diff --git a/api/services/dify_builder/agent/llm.py b/api/services/dify_builder/agent/llm.py index 69f4995154d..b294579bb1b 100644 --- a/api/services/dify_builder/agent/llm.py +++ b/api/services/dify_builder/agent/llm.py @@ -76,11 +76,15 @@ def _complete(model_instance, messages, model_parameters, stop, on_reasoning: Re raise native_reasoning = _direct_reasoning(result) _emit_reasoning(on_reasoning, native_reasoning) - message = getattr(result, "message", None) - if message is None or not hasattr(message, "get_text_content"): + try: + get_text_content = result.message.get_text_content + except AttributeError: + diagnostics.report("response_schema_invalid") + raise LlmError("model returned an invalid blocking response") from None + if not callable(get_text_content): diagnostics.report("response_schema_invalid") raise LlmError("model returned an invalid blocking response") - clean_text, tagged_reasoning = split_reasoning(message.get_text_content(), "separated") + clean_text, tagged_reasoning = split_reasoning(get_text_content(), "separated") if tagged_reasoning and tagged_reasoning != native_reasoning: _emit_reasoning(on_reasoning, tagged_reasoning) if not clean_text.strip(): diff --git a/api/services/dify_builder/agent/resources.py b/api/services/dify_builder/agent/resources.py index 37ba8fabd26..5c1733d9b2d 100644 --- a/api/services/dify_builder/agent/resources.py +++ b/api/services/dify_builder/agent/resources.py @@ -19,6 +19,7 @@ configuration proves runtime health or a supplier lifecycle state. import logging from dataclasses import dataclass, field +from enum import Enum from typing import Any from pydantic import JsonValue @@ -28,10 +29,12 @@ from sqlalchemy.orm import Session from core.workflow.generator.tool_catalogue import ToolCatalogueEntry, build_tool_catalogue, tool_capability_text from extensions.ext_application_services import application_services from extensions.ext_database import db +from graphon.model_runtime.entities.model_entities import ModelPropertyKey from models.dataset import Dataset from models.provider_ids import ToolProviderID from runtime_contracts.builder.execution import canonical_digest from services.dify_builder import trace_diagnostics as diagnostics +from services.entities.model_provider_entities import ProviderWithModelsResponse from services.model_provider.service import ModelProviderService logger = logging.getLogger(__name__) @@ -78,7 +81,7 @@ class TenantResources: tool_source_error: str | None = None -def _list_models(tenant_id: str) -> list[Any]: +def _list_models(tenant_id: str) -> list[ProviderWithModelsResponse]: return ModelProviderService().get_models_by_model_type(tenant_id=tenant_id, model_type="llm") @@ -234,24 +237,26 @@ def list_tenant_resources( def build_models(): models: list[ResourceRef] = [] for prov in _list_models(tenant_id): - for m in getattr(prov, "models", []): - name = getattr(m, "model", "") - if not name or getattr(m, "deprecated", False): + for m in prov.models: + name = m.model + if not name or m.deprecated: continue - raw_status = getattr(m, "status", "unknown") - status = str(getattr(raw_status, "value", raw_status)) + raw_status = m.status + status = str(raw_status.value if isinstance(raw_status, Enum) else raw_status) if status in {"disabled", "deprecated", "retired"}: continue model_id = f"{prov.provider}/{name}" - properties = getattr(m, "model_properties", {}) or {} - raw_mode = properties.get("mode") if isinstance(properties, dict) else None - mode = str(getattr(raw_mode, "value", raw_mode)) if raw_mode is not None else "" + properties = m.model_properties or {} + raw_mode = properties.get(ModelPropertyKey.MODE) if isinstance(properties, dict) else None + mode = str(raw_mode.value if isinstance(raw_mode, Enum) else raw_mode) if raw_mode is not None else "" contract: dict[str, JsonValue] = {} if mode in {"chat", "completion"}: contract["mode"] = mode - raw_features = getattr(m, "features", None) + raw_features = m.features if isinstance(raw_features, list): - contract["features"] = [str(getattr(feature, "value", feature)) for feature in raw_features] + contract["features"] = [ + str(feature.value if isinstance(feature, Enum) else feature) for feature in raw_features + ] models.append( ResourceRef( id=model_id, diff --git a/api/services/dify_builder/application/ports.py b/api/services/dify_builder/application/ports.py index 008d4455f14..eef01f32259 100644 --- a/api/services/dify_builder/application/ports.py +++ b/api/services/dify_builder/application/ports.py @@ -161,6 +161,20 @@ class DifyBuilderAgent(Protocol): def model_or_none(self) -> object | None: ... +@runtime_checkable +class DiagnosticReportingAgent(Protocol): + """Optional capability for operation-scoped diagnostic events.""" + + def set_diagnostic_callback(self, callback: Callable[[dict], None] | None) -> None: ... + + +@runtime_checkable +class ContinuationClassifyingAgent(Protocol): + """Optional capability for continuing a completed Builder session.""" + + def continuation_intent(self, context: DifyBuilderContext, graph: Graph, text: str) -> str: ... + + @runtime_checkable class ReasoningStreamingAgent(Protocol): """Optional capability for agents that expose model-provided reasoning.""" diff --git a/api/services/dify_builder/application/runner.py b/api/services/dify_builder/application/runner.py index 11b9abd9234..299fd17d520 100644 --- a/api/services/dify_builder/application/runner.py +++ b/api/services/dify_builder/application/runner.py @@ -49,7 +49,13 @@ from core.dify_builder.state import PcState, is_terminal, is_waiting, is_working from runtime_contracts.builder.copy_ids import BuilderCopyId from runtime_contracts.builder.presentation import BuilderText, copy_text from services.dify_builder import recovery, trace_diagnostics -from services.dify_builder.application.ports import DifyBuilderAgent, DifyPort, ReasoningStreamingAgent, Repository +from services.dify_builder.application.ports import ( + DiagnosticReportingAgent, + DifyBuilderAgent, + DifyPort, + ReasoningStreamingAgent, + Repository, +) __all__ = ["Env", "Handler", "Runner", "StepResult"] @@ -139,15 +145,14 @@ class Env: self.reasoning_text = "" self.assistant_streams.clear() self.diagnostics.clear() - diagnostic_setter = getattr(self.agent, "set_diagnostic_callback", None) - if callable(diagnostic_setter): + if isinstance(self.agent, DiagnosticReportingAgent): operation_id = self.operation_id def record_current(record: dict) -> None: if self.operation_id == operation_id: self.record_diagnostic(record) - diagnostic_setter(record_current) + self.agent.set_diagnostic_callback(record_current) if isinstance(self.agent, ReasoningStreamingAgent): self.agent.set_reasoning_callback(self.record_reasoning) diff --git a/api/services/dify_builder/condition_evidence.py b/api/services/dify_builder/condition_evidence.py index e8ed1e747ec..ba9a97a1a82 100644 --- a/api/services/dify_builder/condition_evidence.py +++ b/api/services/dify_builder/condition_evidence.py @@ -8,10 +8,10 @@ Only this projection can survive a step boundary; raw native rows stay local. import json import math from collections import Counter -from collections.abc import Collection, Iterator, Mapping +from collections.abc import Callable, Collection, Iterator, Mapping from copy import deepcopy from dataclasses import replace -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from core.dify_builder.models import Actor, Diagnosis, Graph, MutationIntent, NodeOutput, Risk, Run from services.dify_builder import diagnostic_context @@ -21,6 +21,28 @@ from services.dify_builder.revision import executable_graph_revision if TYPE_CHECKING: from services.dify_builder.application.runner import Env + +@runtime_checkable +class HistoricalRunReader(Protocol): + def failed_run_context( + self, app_id: str, actor: Actor, run_id: str + ) -> tuple[Graph | None, list[NodeOutput], str]: ... + + +@runtime_checkable +class EvidenceDiagnoser(Protocol): + def diagnose_with_evidence( + self, run: Run, graph: Graph, outputs: list[NodeOutput], evidence: dict[str, Any] + ) -> Diagnosis: ... + + +@runtime_checkable +class EvidenceRepairProposer(Protocol): + def propose_with_evidence( + self, diagnosis: Diagnosis, current: Graph, safe_graph: Graph, redact_text: Callable[[str], str] + ) -> tuple[list[MutationIntent], Risk]: ... + + MAX_CONTEXT_BYTES = 16_000 _MAX_CONDITIONS = 32 _UNKNOWN = "unknown" @@ -299,9 +321,8 @@ def build_evidence( def _read_context(env: "Env", app_id: str, actor: Actor, failed_run: Run) -> tuple[Graph | None, list[NodeOutput], Run]: """Optional native capability; older/test ports truthfully remain unbound.""" - read = getattr(env.dify, "failed_run_context", None) - if callable(read): - historical, outputs, native_error = read(app_id, actor, failed_run.dify_run_id) + if isinstance(env.dify, HistoricalRunReader): + historical, outputs, native_error = env.dify.failed_run_context(app_id, actor, failed_run.dify_run_id) else: historical, outputs, native_error = None, env.dify.node_outputs(app_id, actor, failed_run.dify_run_id), "" if not outputs and historical is None: @@ -316,9 +337,8 @@ def diagnose_failure(env: "Env", app_id: str, actor: Actor, failed_run: Run, cur """Keep authorized native evidence local while handing only its projection on.""" historical, outputs, raw_run = _read_context(env, app_id, actor, failed_run) safe_run, safe_graph, safe_outputs, evidence = build_evidence(raw_run, current, historical, outputs) - diagnose = getattr(env.agent, "diagnose_with_evidence", None) - if callable(diagnose): - diagnosis = diagnose(safe_run, safe_graph, safe_outputs, evidence) + if isinstance(env.agent, EvidenceDiagnoser): + diagnosis = env.agent.diagnose_with_evidence(safe_run, safe_graph, safe_outputs, evidence) else: diagnosis = env.agent.diagnose(safe_run, safe_graph, safe_outputs) diagnosis.condition_evidence = evidence @@ -429,7 +449,6 @@ def propose_failure( text, raw_run, {"current": current, "historical": historical}, outputs ) - propose = getattr(env.agent, "propose_with_evidence", None) - if callable(propose): - return propose(diagnosis, current, safe_graph, redact_text) + if isinstance(env.agent, EvidenceRepairProposer): + return env.agent.propose_with_evidence(diagnosis, current, safe_graph, redact_text) return env.agent.propose_repair(diagnosis, current) diff --git a/api/services/dify_builder/conversation_tasks.py b/api/services/dify_builder/conversation_tasks.py index 82f4441cef7..6e28c199903 100644 --- a/api/services/dify_builder/conversation_tasks.py +++ b/api/services/dify_builder/conversation_tasks.py @@ -12,6 +12,7 @@ from core.dify_builder.models import Action, DifyBuilderContext, EntryMode, Sess from core.dify_builder.state import PcState, is_terminal from runtime_contracts.builder.copy_ids import BuilderCopyId from runtime_contracts.builder.presentation import copy_text +from services.dify_builder.application.ports import ContinuationClassifyingAgent if TYPE_CHECKING: from services.dify_builder.application.runner import Env, StepResult @@ -42,10 +43,9 @@ def prepare_continuation( or context.recovery_class ): return None - classifier = getattr(env.agent, "continuation_intent", None) - if not callable(classifier): + if not isinstance(env.agent, ContinuationClassifyingAgent): return None - intent = classifier(context, graph, text) + intent = env.agent.continuation_intent(context, graph, text) if intent not in {"edit", "test"}: return None diff --git a/api/services/dify_builder/dify_port.py b/api/services/dify_builder/dify_port.py index c16cd16ced6..cea59cf427c 100644 --- a/api/services/dify_builder/dify_port.py +++ b/api/services/dify_builder/dify_port.py @@ -51,6 +51,7 @@ import logging import threading from collections.abc import Callable, Mapping, Sequence from dataclasses import asdict, dataclass, field +from enum import Enum from typing import TYPE_CHECKING, Any, Literal from uuid import NAMESPACE_URL, uuid4, uuid5 @@ -201,7 +202,7 @@ def _unwrap_status(status: Any) -> Any: Mirrors ``run_mapping._status_value`` locally so this module doesn't reach into another module's private helper. """ - return getattr(status, "value", status) + return status.value if isinstance(status, Enum) else status def _load_draft_workflow_or_raise(app: App, *, session: Session) -> Workflow: @@ -295,7 +296,12 @@ def _retained_native_agent_selections(*, session: Session, actor: Actor, workflo by_node = {row.node_id: row for row in rows} for node_id, data in unchanged.items(): row = by_node.get(node_id) - if row is None or row.binding_type != WorkflowAgentBindingType.ROSTER_AGENT: + if ( + row is None + or row.binding_type != WorkflowAgentBindingType.ROSTER_AGENT + or row.agent_id is None + or row.current_snapshot_id is None + ): raise BuilderExecutionPolicyError("agent_binding_not_prepared") expected = native_node_data( agent_id=row.agent_id, @@ -308,11 +314,13 @@ def _retained_native_agent_selections(*, session: Session, actor: Actor, workflo tenant_id=actor.tenant_id, actor_id=actor.account_id, app_id=workflow.app_id, - agent_ids=frozenset(row.agent_id for row in rows), + agent_ids=frozenset(row.agent_id for row in rows if row.agent_id is not None), ) by_agent = {candidate.agent_id: candidate for candidate in candidates} retained, stamps = {}, {} for node_id, row in by_node.items(): + if row.agent_id is None or row.current_snapshot_id is None: + raise BuilderExecutionPolicyError("agent_binding_not_prepared") candidate = by_agent.get(row.agent_id) if candidate is None or candidate.snapshot_id != row.current_snapshot_id: raise BuilderExecutionPolicyError("agent_not_available") @@ -872,8 +880,7 @@ class WorkflowServiceDifyPort: error=node_exec.error or "", inputs=node_exec.inputs_dict or {}, outputs=node_exec.outputs_dict or {}, - outputs_available=isinstance(node_exec.outputs_dict, Mapping) - and not getattr(node_exec, "outputs_truncated", False), + outputs_available=isinstance(node_exec.outputs_dict, Mapping) and not node_exec.outputs_truncated, ) for node_exec in node_execs ] @@ -1827,7 +1834,10 @@ class WorkflowServiceDifyPort: final = {} error_frame = None finally: - close = getattr(response, "close", None) + try: + close = response.close if response is not None else None + except AttributeError: + close = None if callable(close): close() @@ -1857,7 +1867,7 @@ class WorkflowServiceDifyPort: # to diagnose/rebuild it. C04 exposes waiting only after the native # WorkflowPause has actually persisted; Builder retains its same Run. native_row = result[1] - native_graph = getattr(native_row, "graph_dict", {}) + native_graph = native_row.graph_dict if native_row is not None else {} has_agent = ( any( isinstance(node, dict) and isinstance(node.get("data"), dict) and node["data"].get("type") == "agent" @@ -2070,7 +2080,10 @@ class WorkflowServiceDifyPort: raise finally: try: - close = getattr(response, "close", None) + try: + close = response.close if response is not None else None + except AttributeError: + close = None if callable(close): close() except BaseException: @@ -2217,7 +2230,7 @@ class WorkflowServiceDifyPort: if execution_recorder is not None and not execution_recorder.healthy: run.execution_refusal = BuilderExecutionRefusal(reason_code="execution_recorder_unhealthy") return run - native_graph = getattr(row, "graph_dict", None) + native_graph = row.graph_dict if row is not None else None evidence = run.verification if evidence is None or not isinstance(native_graph, dict) or not isinstance(native_graph.get("nodes"), list): return run @@ -2345,6 +2358,6 @@ class WorkflowServiceDifyPort: session.commit() return PublishResult( - version_name=getattr(workflow, "marked_name", None) or f"# {workflow.version_number}", + version_name=workflow.marked_name or f"# {workflow.version_number}", status="live", ) diff --git a/api/services/dify_builder/execution_policy_service.py b/api/services/dify_builder/execution_policy_service.py index 64301c7290c..f4dc12e1975 100644 --- a/api/services/dify_builder/execution_policy_service.py +++ b/api/services/dify_builder/execution_policy_service.py @@ -288,9 +288,22 @@ def _bound_request(db: Session, context: ExecutionContext, *, lock: bool) -> Dif row = db.scalar(statement) if row is None or row.context_digest != context.context_digest or row.context != context.model_dump(mode="json"): raise BuilderExecutionPolicyError("execution_request_mismatch") - for name in ("tenant_id", "app_id", "workflow_id", "actor_id", "session_id", "test_input_id"): - if getattr(row, name) != getattr(context, name): - raise BuilderExecutionPolicyError("execution_owner_mismatch") + if ( + row.tenant_id, + row.app_id, + row.workflow_id, + row.actor_id, + row.session_id, + row.test_input_id, + ) != ( + context.tenant_id, + context.app_id, + context.workflow_id, + context.actor_id, + context.session_id, + context.test_input_id, + ): + raise BuilderExecutionPolicyError("execution_owner_mismatch") return row @@ -375,9 +388,7 @@ def _json_owner_state(owner: ExecutionOwnerSnapshot, snapshot: RestrictedAdmissi def _json_binding_digest(context: JsonToolExecutionContext) -> str: - return InstalledJsonParse( - **{field: getattr(context.qualified_tool, field) for field in InstalledJsonParse.__dataclass_fields__} - ).binding_digest + return InstalledJsonParse.from_binding(context.qualified_tool).binding_digest class BuilderExecutionPolicyService: @@ -480,10 +491,12 @@ class BuilderExecutionPolicyService: assert json_state is not None installed = resolve_installed_json_parse(tenant_id=actor.tenant_id) check_execution_permission(actor, app_id, self._session_factory) - qualified = QualifiedJsonToolBinding( - node_id=json_values["admitted_nodes"][1].node_id, - **{name: getattr(installed, name) for name in installed.__dataclass_fields__}, - final_parameters_digest=json_values.pop("final_parameters_digest"), + qualified = QualifiedJsonToolBinding.model_validate( + { + "node_id": json_values["admitted_nodes"][1].node_id, + **installed.binding_fields(), + "final_parameters_digest": json_values.pop("final_parameters_digest"), + } ) json_values["qualified_tool"] = qualified initial_json = JsonToolExecutionContext.model_validate({**json_values, "context_digest": "0" * 64}) @@ -891,55 +904,62 @@ class BuilderExecutionPolicyService: ): raise BuilderExecutionPolicyError("native_execution_owner_mismatch") # Raw SQL projections avoid offloaded payload I/O under the short sidecar lock. - run_fields = ( - "id", - "tenant_id", - "app_id", - "workflow_id", - "created_by", - "created_by_role", - "triggered_from", - "type", - "version", - "graph", - "inputs", - "status", - "outputs", - "error", - "finished_at", - "total_steps", - "exceptions_count", - ) - node_fields = ( - "id", - "node_execution_id", - "node_id", - "node_type", - "index", - "predecessor_node_id", - "status", - "finished_at", - "process_data", - "execution_metadata", - "inputs", - "outputs", - "error", + run_values = ( + { + "id": native.id, + "tenant_id": native.tenant_id, + "app_id": native.app_id, + "workflow_id": native.workflow_id, + "created_by": native.created_by, + "created_by_role": native.created_by_role, + "triggered_from": native.triggered_from, + "type": native.type, + "version": native.version, + "graph": native.graph, + "inputs": native.inputs, + "status": native.status, + "outputs": native.outputs, + "error": native.error, + "finished_at": native.finished_at, + "total_steps": native.total_steps, + "exceptions_count": native.exceptions_count, + } + if native is not None + else None ) + node_values = [ + { + "id": node.id, + "node_execution_id": node.node_execution_id, + "node_id": node.node_id, + "node_type": node.node_type, + "index": node.index, + "predecessor_node_id": node.predecessor_node_id, + "status": node.status, + "finished_at": node.finished_at, + "process_data": node.process_data, + "execution_metadata": node.execution_metadata, + "inputs": node.inputs, + "outputs": node.outputs, + "error": node.error, + } + for node in sorted(nodes, key=lambda node: (node.index, node.id)) + ] - def projection(value, fields): + def projection(values): return { key: ( - getattr(value, key).isoformat() - if key == "finished_at" and getattr(value, key) - else getattr(value, key).value - if isinstance(getattr(value, key), Enum) - else getattr(value, key) + value.isoformat() + if key == "finished_at" and value + else value.value + if isinstance(value, Enum) + else value ) - for key in fields + for key, value in values.items() } - native_projection = projection(native, run_fields) if native is not None else None - node_projection = [projection(n, node_fields) for n in sorted(nodes, key=lambda n: (n.index, n.id))] + native_projection = projection(run_values) if run_values is not None else None + node_projection = [projection(values) for values in node_values] fingerprint = canonical_digest( { "completion": completion.model_dump(mode="json"), diff --git a/api/services/dify_builder/json_tool_metadata.py b/api/services/dify_builder/json_tool_metadata.py index acb3e2a457a..85244ca9d6f 100644 --- a/api/services/dify_builder/json_tool_metadata.py +++ b/api/services/dify_builder/json_tool_metadata.py @@ -70,9 +70,43 @@ class InstalledJsonParse: runtime_type: str qualification_digest: str + def binding_fields(self) -> dict[str, str]: + """Project only the reviewed installation identity, without reflective access.""" + return { + "tenant_id": self.tenant_id, + "installation_id": self.installation_id, + "plugin_id": self.plugin_id, + "provider_id": self.provider_id, + "tool_name": self.tool_name, + "unique_identifier": self.unique_identifier, + "version": self.version, + "checksum": self.checksum, + "provider_schema_digest": self.provider_schema_digest, + "tool_schema_digest": self.tool_schema_digest, + "runtime_type": self.runtime_type, + "qualification_digest": self.qualification_digest, + } + + @classmethod + def from_binding(cls, binding: QualifiedJsonToolBinding) -> InstalledJsonParse: + return cls( + tenant_id=binding.tenant_id, + installation_id=binding.installation_id, + plugin_id=binding.plugin_id, + provider_id=binding.provider_id, + tool_name=binding.tool_name, + unique_identifier=binding.unique_identifier, + version=binding.version, + checksum=binding.checksum, + provider_schema_digest=binding.provider_schema_digest, + tool_schema_digest=binding.tool_schema_digest, + runtime_type=binding.runtime_type, + qualification_digest=binding.qualification_digest, + ) + @property def binding_digest(self) -> str: - return canonical_digest({field: getattr(self, field) for field in self.__dataclass_fields__}) + return canonical_digest(self.binding_fields()) def _validate_declaration(declaration: ToolProviderEntityWithPlugin) -> ToolEntity: @@ -178,19 +212,5 @@ def resolve_installed_json_parse(*, tenant_id: str) -> InstalledJsonParse: def require_same_json_installation(expected: QualifiedJsonToolBinding, actual: InstalledJsonParse) -> None: """Require the persisted expected identity to match this exact metadata read.""" - for field in ( - "tenant_id", - "installation_id", - "plugin_id", - "provider_id", - "tool_name", - "unique_identifier", - "version", - "checksum", - "provider_schema_digest", - "tool_schema_digest", - "runtime_type", - "qualification_digest", - ): - if getattr(expected, field) != getattr(actual, field): - raise BuilderExecutionPolicyError("json_tool_installation_changed") + if InstalledJsonParse.from_binding(expected) != actual: + raise BuilderExecutionPolicyError("json_tool_installation_changed") diff --git a/api/services/dify_builder/native_testing/agent_binding.py b/api/services/dify_builder/native_testing/agent_binding.py index 0a3c75601a0..d58d18e6005 100644 --- a/api/services/dify_builder/native_testing/agent_binding.py +++ b/api/services/dify_builder/native_testing/agent_binding.py @@ -271,7 +271,7 @@ class AgentBindingService: current_snapshot_id=snapshot.id, node_job_config=node_job, ) - graph = deepcopy(workflow.graph_dict) + graph = deepcopy(dict(workflow.graph_dict)) nodes = graph.setdefault("nodes", []) nodes[:] = [item for item in nodes if item.get("id") != node_id] nodes.append({"id": node_id, "data": data}) diff --git a/api/services/dify_builder/native_testing/agent_bridge.py b/api/services/dify_builder/native_testing/agent_bridge.py index bae421d8d57..fb9f64456b4 100644 --- a/api/services/dify_builder/native_testing/agent_bridge.py +++ b/api/services/dify_builder/native_testing/agent_bridge.py @@ -146,12 +146,16 @@ class NativeAgentCredentialMaterializer: from core.helper.provider_cache import NoOpProviderCredentialCache, ToolProviderCredentialsCache from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.oauth import OAuthHandler + from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.tool_manager import ToolManager from core.tools.utils.encryption import create_provider_encrypter from models.provider_ids import ToolProviderID controller = ToolManager.get_builtin_provider(binding.provider_id, binding.actor.tenant_id) - if getattr(controller, "plugin_unique_identifier", None) != binding.implementation_revision: + if ( + not isinstance(controller, PluginToolProviderController) + or controller.plugin_unique_identifier != binding.implementation_revision + ): raise BuilderExecutionPolicyError("installed_tool_unavailable") credential_type = CredentialType.of(selection.snapshot.reference.credential_type) # Refresh is the sole physical owner of this permit; the claim commits @@ -532,8 +536,8 @@ def claim_agent_endpoint( from flask import g, request context = ( - getattr(g, "agent_runtime_context", None) - if getattr(g, "agent_runtime_request", None) is cast(LocalProxy, request)._get_current_object() + g.get("agent_runtime_context") + if g.get("agent_runtime_request") is cast(LocalProxy, request)._get_current_object() else None ) if context is None: @@ -566,8 +570,8 @@ def require_agent_service_envelope(envelope: AgentPhysicalDispatchPayload | None if ( has_request_context() - and getattr(g, "agent_runtime_request", None) is cast(LocalProxy, request)._get_current_object() - and isinstance(getattr(g, "agent_runtime_context", None), NativeAgentEndpointContext) + and g.get("agent_runtime_request") is cast(LocalProxy, request)._get_current_object() + and isinstance(g.get("agent_runtime_context"), NativeAgentEndpointContext) and envelope is None ): raise BuilderExecutionPolicyError("native_endpoint_owner_mismatch") diff --git a/api/services/dify_builder/native_testing/agent_config_access.py b/api/services/dify_builder/native_testing/agent_config_access.py index 79178f6b414..95cba94f279 100644 --- a/api/services/dify_builder/native_testing/agent_config_access.py +++ b/api/services/dify_builder/native_testing/agent_config_access.py @@ -40,8 +40,10 @@ def validate_config_read( if not isinstance(layers, list): raise BuilderExecutionPolicyError("native_agent_config_mismatch") + checked_layers = layers + def layer(name: str) -> dict[str, Any]: - matches = [v.get("config") for v in layers if isinstance(v, dict) and v.get("name") == name] + matches = [v.get("config") for v in checked_layers if isinstance(v, dict) and v.get("name") == name] if len(matches) != 1 or not isinstance(matches[0], dict): raise BuilderExecutionPolicyError("native_agent_config_mismatch") return matches[0] diff --git a/api/services/dify_builder/native_testing/agent_confirmed_workspace.py b/api/services/dify_builder/native_testing/agent_confirmed_workspace.py index 8bc30a3ed7f..09a943fbff4 100644 --- a/api/services/dify_builder/native_testing/agent_confirmed_workspace.py +++ b/api/services/dify_builder/native_testing/agent_confirmed_workspace.py @@ -268,7 +268,7 @@ def build_confirmed_agent_preparation(*, actor, backend): ) -def load_confirmed_agent_parent(*, service, ref, execution_id): +def load_confirmed_agent_parent(*, state_store, ref, execution_id): """Find original typed input identity; absence never overwrites a saved input. The independent node-input logical key exists before child-create reservation. @@ -277,7 +277,7 @@ def load_confirmed_agent_parent(*, service, ref, execution_id): from models.dify_builder_native_tests import NativePayloadObject from runtime_contracts.builder.native.agent_contracts import AgentParentNodeRef - loader = service.state_store.loader + loader = state_store.loader owned = loader.load(ref=ref) with loader.session_factory() as session: row = session.scalar( @@ -307,12 +307,12 @@ def load_confirmed_agent_parent(*, service, ref, execution_id): if configs: raise BuilderExecutionPolicyError("native_agent_input_unavailable") return None - inputs = service.state_store.load_result(ref=ref, result_ref=input_ref) + inputs = state_store.load_result(ref=ref, result_ref=input_ref) parent = AgentParentNodeRef( ref=ref, execution_id=execution_id, input_result_ref=input_ref, input_digest=canonical_digest(inputs) ) if configs: - config = service.state_store.load_prepared_config(ref=ref, parent_node=parent, config_ref=None) + config = state_store.load_prepared_config(ref=ref, parent_node=parent, config_ref=None) if config.parent_node != parent: raise BuilderExecutionPolicyError("native_agent_owner_mismatch") loader.load(ref=ref) diff --git a/api/services/dify_builder/native_testing/agent_lineage.py b/api/services/dify_builder/native_testing/agent_lineage.py index bcc3b448ca2..4d71e4729d9 100644 --- a/api/services/dify_builder/native_testing/agent_lineage.py +++ b/api/services/dify_builder/native_testing/agent_lineage.py @@ -154,5 +154,5 @@ class NativeAgentEndpointLineage: ref=AgentNativeTestRef.model_validate(parent.ref.model_dump(mode="json")), config=config, association=association, - native_test_profile=getattr(actual, "native_test_profile", "isolated"), + native_test_profile=actual.native_test_profile, ) diff --git a/api/services/dify_builder/native_testing/agent_operations.py b/api/services/dify_builder/native_testing/agent_operations.py index be52d2ff364..0922c024542 100644 --- a/api/services/dify_builder/native_testing/agent_operations.py +++ b/api/services/dify_builder/native_testing/agent_operations.py @@ -7,6 +7,7 @@ HTTP response; response loss therefore cannot turn a completed effect unknown. from collections.abc import Callable from dataclasses import dataclass +from typing import NotRequired, TypedDict from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload from dify_agent.protocol.native_testing import AgentEffectIntent, AgentNativeTestRef, AgentOperationKey @@ -59,6 +60,8 @@ from services.dify_builder.native_testing.repository import ( native_workflow_revision, ) from services.dify_builder.native_testing.state_store import NativeExecutionStateStore, ObjectReceipt, owner_query +from services.entities.agent_llm_inner import AgentLLMInvokeCaller +from services.entities.agent_tool_inner import AgentToolInvokeCaller from services.tools.builtin_tools_manage_service import BuiltinToolManageService @@ -121,6 +124,13 @@ _RESOURCE_KEY = "_agent_operations_v1" _OPACITY = "Approval covers one bounded tool invocation. Provider internal effects remain opaque." +class _ShellLayerConfigs(TypedDict): + shell: dict[str, JsonValue] + runtime: dict[str, JsonValue] + config: dict[str, JsonValue] + workspace_stamp: NotRequired[str] + + @dataclass(frozen=True) class AgentResolvedTool: """Inert current configured declaration and explicit account, never credentials.""" @@ -146,7 +156,7 @@ def _sql_resource(*, session, owned, descriptor): workflow = validate_invocation_workflow( session, tenant_id=owned.tenant_id, app_id=owned.app_id, workflow_id=owned.workflow_id ) - revision_context = getattr(owned, "revision_context", None) + revision_context = owned.revision_context revision = ( admitted_workflow_revision(workflow, revision_context) if revision_context @@ -250,7 +260,7 @@ class AgentOperationService: self.disclosure = self def _endpoint(self, ref): - endpoint = getattr(g, "agent_runtime_context", None) + endpoint = g.get("agent_runtime_context") if not isinstance(endpoint, NativeAgentEndpointContext) or api_ref(endpoint.ref) != ref: raise BuilderExecutionPolicyError("native_endpoint_owner_mismatch") if endpoint.native_test_profile != "confirmed": @@ -501,15 +511,24 @@ class AgentOperationService: layers = composition.get("layers") if isinstance(composition, dict) else None if not isinstance(layers, list): raise BuilderExecutionPolicyError("native_agent_config_mismatch") - configs = {} - for name in ("shell", "runtime", "config"): - selected = [item.get("config") for item in layers if isinstance(item, dict) and item.get("name") == name] + checked_layers = layers + + def selected_config(name: str) -> dict[str, JsonValue]: + selected = [ + item.get("config") for item in checked_layers if isinstance(item, dict) and item.get("name") == name + ] if len(selected) != 1 or not isinstance(selected[0], dict): raise BuilderExecutionPolicyError("native_agent_config_mismatch") - configs[name] = selected[0] + return selected[0] + + configs: _ShellLayerConfigs = { + "shell": selected_config("shell"), + "runtime": selected_config("runtime"), + "config": selected_config("config"), + } if configs["runtime"].get("backend_binding_ref") != prepared.workspace.backend_binding_ref: raise BuilderExecutionPolicyError("native_workspace_owner_mismatch") - expected = {"shell": "shell"} + expected: dict[str, JsonValue] = {"shell": "shell"} if boundary == "bootstrap": expected["runtime"] = configs["shell"].get("runtime") elif boundary == "config_pull": @@ -739,20 +758,27 @@ class AgentOperationService: ref=ref, intent=intent, proposal=proposal, assessment=assessment, descriptor=descriptor, binding=binding ) - def validate_caller(self, *, ref, caller): + def validate_caller(self, *, ref, caller: AgentToolInvokeCaller | AgentLLMInvokeCaller): endpoint = self._endpoint(ref) context = self.state_store.load_context(ref=ref) - for field, value in ( - ("tenant_id", context.tenant_id), - ("app_id", context.app_id), - ("user_id", context.actor_id), - ("user_from", "account"), - ("workflow_id", context.workflow_id), - ("workflow_run_id", context.native_run_id), - ("node_execution_id", endpoint.config.parent_node.execution_id), + if not isinstance(caller, (AgentToolInvokeCaller, AgentLLMInvokeCaller)) or ( + caller.tenant_id, + caller.app_id, + caller.user_id, + caller.user_from, + caller.workflow_id, + caller.workflow_run_id, + caller.node_execution_id, + ) != ( + context.tenant_id, + context.app_id, + context.actor_id, + "account", + context.workflow_id, + context.native_run_id, + endpoint.config.parent_node.execution_id, ): - if getattr(caller, field, None) != value: - raise BuilderExecutionPolicyError("native_endpoint_owner_mismatch") + raise BuilderExecutionPolicyError("native_endpoint_owner_mismatch") def validate_model(self, *, envelope): self.bridge.authorization.validate_dispatch( @@ -852,7 +878,7 @@ class AgentOperationService: ) if row is None: raise BuilderExecutionPolicyError("native_agent_intent_unavailable") - receipt = ObjectReceipt(**{name: getattr(row, name) for name in ObjectReceipt.__dataclass_fields__}) + receipt = ObjectReceipt.from_payload_object(row) payload = self.state_store._read_object(receipt) self.loader.load(ref=native_ref) return ( diff --git a/api/services/dify_builder/native_testing/agent_preparation.py b/api/services/dify_builder/native_testing/agent_preparation.py index b4e72ee5cbe..c75e939222b 100644 --- a/api/services/dify_builder/native_testing/agent_preparation.py +++ b/api/services/dify_builder/native_testing/agent_preparation.py @@ -82,7 +82,7 @@ class AgentPreparationService: bound_data = node.node_data.model_dump(mode="json") if node.node_data != expected_node: raise BuilderExecutionPolicyError("native_agent_config_mismatch") - inputs = self.config_builder.typed_inputs(association=association, definition=definition) + inputs = self.config_builder.typed_inputs(runtime_state=association.runtime_state, definition=definition) execution_id = association.execution_id input_ref = self.state_store.store_node_inputs( ref=context.ref, @@ -113,7 +113,8 @@ class AgentPreparationService: or association.resolve() != context or association.execution_id != execution_id or self.config_builder.load_definition(context=context, node_id=association.node_id) != definition - or self.config_builder.typed_inputs(association=association, definition=definition) != inputs + or self.config_builder.typed_inputs(runtime_state=association.runtime_state, definition=definition) + != inputs ): raise BuilderExecutionPolicyError("native_agent_owner_mismatch") config = request.model_dump(mode="json", include={"composition", "session_snapshot"}) diff --git a/api/services/dify_builder/native_testing/agent_runtime_config.py b/api/services/dify_builder/native_testing/agent_runtime_config.py index 62c853d4451..f06375f2f75 100644 --- a/api/services/dify_builder/native_testing/agent_runtime_config.py +++ b/api/services/dify_builder/native_testing/agent_runtime_config.py @@ -47,13 +47,11 @@ from services.dify_builder.native_testing.private_reads import ( ) if TYPE_CHECKING: + from graphon.runtime import RuntimeState from runtime_contracts.builder.native.runtime_contracts import ( ResolvedNativeTestContext, ) from services.dify_builder.native_testing.repository import OwnedNativeTestLoader - from services.dify_builder.native_testing.runtime_bridge import ( - NativeNodePreparation, - ) @dataclass(frozen=True) @@ -182,14 +180,14 @@ class NativeAgentRuntimeConfigBuilder: return NativeAgentDefinition(node_id, agent_id, snapshot_id, soul, job, stamp) @staticmethod - def typed_inputs(*, association: "NativeNodePreparation", definition: NativeAgentDefinition) -> dict: + def typed_inputs(*, runtime_state: "RuntimeState", definition: NativeAgentDefinition) -> dict: from core.workflow.system_variables import SystemVariableKey, get_system_value from services.agent.prompt_mentions import ( extract_workflow_node_output_selectors, normalize_previous_node_output_selector, ) - pool = association.runtime_state.variable_pool + pool = runtime_state.variable_pool selectors = list(extract_workflow_node_output_selectors(definition.node_job.workflow_prompt)) if definition.node_job.output_routes.enabled: for route in definition.node_job.output_routes.routes: diff --git a/api/services/dify_builder/native_testing/agent_tool_declarations.py b/api/services/dify_builder/native_testing/agent_tool_declarations.py index 2cf3abe1cc9..5b97e23d964 100644 --- a/api/services/dify_builder/native_testing/agent_tool_declarations.py +++ b/api/services/dify_builder/native_testing/agent_tool_declarations.py @@ -16,6 +16,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from core.tools.entities.tool_entities import ToolEntity, ToolProviderType +from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.tool_manager import ToolManager from extensions.ext_database import db from models.agent_config_entities import AgentSoulToolsConfig @@ -55,7 +56,7 @@ def _metadata(*, tenant_id: str, provider_type: ToolProviderType, provider_id: s else ToolManager.get_builtin_provider(provider_id, tenant_id) ) declared = controller.get_tools() or [] - revision = getattr(controller, "plugin_unique_identifier", None) + revision = controller.plugin_unique_identifier if isinstance(controller, PluginToolProviderController) else None needs_credentials = controller.need_credentials elif provider_type == ToolProviderType.API: controller, _encrypted = ToolManager.get_api_provider_controller(tenant_id, provider_id) diff --git a/api/services/dify_builder/native_testing/authorization.py b/api/services/dify_builder/native_testing/authorization.py index 895dc781ad2..abf066d078b 100644 --- a/api/services/dify_builder/native_testing/authorization.py +++ b/api/services/dify_builder/native_testing/authorization.py @@ -54,9 +54,18 @@ from services.dify_builder.native_testing.transitions import dispatch_readiness, def validate_binding(intent: EffectIntent, binding: NativeBinding) -> None: - if canonical_digest(intent.model_dump(mode="json")) != binding.intent_digest or any( - getattr(intent, field) != getattr(binding, field) - for field in ("kind", "resource_ref", "implementation_revision", "account_ref", "capability_digest") + if canonical_digest(intent.model_dump(mode="json")) != binding.intent_digest or ( + intent.kind, + intent.resource_ref, + intent.implementation_revision, + intent.account_ref, + intent.capability_digest, + ) != ( + binding.kind, + binding.resource_ref, + binding.implementation_revision, + binding.account_ref, + binding.capability_digest, ): raise BuilderExecutionPolicyError("native_binding_mismatch") if binding.destructive: @@ -416,7 +425,13 @@ class EffectAuthorizationService: "expires_at": expires_at, } if grant is not None: - if any(getattr(grant, name) != value for name, value in fields.items()): + if (grant.decision, grant.max_calls, grant.max_dispatches, grant.max_cost_units, grant.expires_at) != ( + decision, + max_calls, + max_dispatches, + max_cost_units, + expires_at, + ): raise BuilderExecutionPolicyError("native_grant_conflict") if pending_guard is not None: return pending_guard.save(session=session, request_id=ref.request_id) @@ -529,7 +544,7 @@ class EffectAuthorizationService: workflow = validate_invocation_workflow( session, tenant_id=owned.tenant_id, app_id=authority.app_id, workflow_id=authority.workflow_id ) - revision_context = getattr(authority, "revision_context", None) + revision_context = authority.revision_context revision = ( admitted_workflow_revision(workflow, revision_context) if revision_context diff --git a/api/services/dify_builder/native_testing/child_invocations.py b/api/services/dify_builder/native_testing/child_invocations.py index 5e64abf2bd0..3c5608984dc 100644 --- a/api/services/dify_builder/native_testing/child_invocations.py +++ b/api/services/dify_builder/native_testing/child_invocations.py @@ -228,6 +228,8 @@ class NativeChildInvocationService: if outcome.ref != child_ref or outcome.native_run_id != context.native_run_id: raise BuilderExecutionPolicyError("native_child_owner_mismatch") if outcome.status == "waiting": + if outcome.checkpoint_ref is None: + raise BuilderExecutionPolicyError("native_child_owner_mismatch") from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService context = self.store.load_context(ref=child_ref) diff --git a/api/services/dify_builder/native_testing/confirmed_agent_cancellation.py b/api/services/dify_builder/native_testing/confirmed_agent_cancellation.py index aa30c2bc9e9..a09eab3f448 100644 --- a/api/services/dify_builder/native_testing/confirmed_agent_cancellation.py +++ b/api/services/dify_builder/native_testing/confirmed_agent_cancellation.py @@ -48,7 +48,7 @@ class ConfirmedAgentCancellation: def _objects(self, sql, ref, tenant_id): return sorted( ( - asdict(ObjectReceipt(**{name: getattr(row, name) for name in ObjectReceipt.__dataclass_fields__})) + asdict(ObjectReceipt.from_payload_object(row)) for row in sql.scalars(owner_query(NativePayloadObject, ref, tenant_id)) ), key=itemgetter("id"), diff --git a/api/services/dify_builder/native_testing/confirmed_agent_commands.py b/api/services/dify_builder/native_testing/confirmed_agent_commands.py index 00019e5f718..171802439ee 100644 --- a/api/services/dify_builder/native_testing/confirmed_agent_commands.py +++ b/api/services/dify_builder/native_testing/confirmed_agent_commands.py @@ -6,7 +6,6 @@ never admits another parent or child execution. """ import logging -from types import SimpleNamespace from typing import Literal, cast from dify_agent.protocol.native_continuation import RedisStreamID @@ -16,6 +15,7 @@ from pydantic import Field, TypeAdapter from sqlalchemy import select from core.dify_builder.models import Actor +from graphon.entities.pause_reason import SchedulingPause from models.dify_builder import DifyBuilderExecutionRequest from models.dify_builder_native_tests import ( NativeContinuation, @@ -352,9 +352,7 @@ class ConfirmedAgentCommands: ) if response is None: return False - parent = load_confirmed_agent_parent( - service=SimpleNamespace(state_store=self.api.store), ref=ref, execution_id=scope.execution_id - ) + parent = load_confirmed_agent_parent(state_store=self.api.store, ref=ref, execution_id=scope.execution_id) if parent is None: raise BuilderExecutionPolicyError("native_agent_input_unavailable") protected = self.api.store.load_agent_association(ref=ref, parent_node=parent) @@ -766,7 +764,10 @@ class ConfirmedAgentCommands: } if status == "paused": paused = self.continuation.read_parent_pause(ref=ref) - reasons = {getattr(reason, "message", None) for reason in paused.runtime.graph_execution.pause_reasons} + reasons = { + reason.message if isinstance(reason, SchedulingPause) else None + for reason in paused.runtime.graph_execution.pause_reasons + } if reasons & { "agent_create_ack_unknown", "native_agent_continuation_unknown", @@ -909,7 +910,7 @@ def _dispatch_owner(payload, *, generate_entity=None): def guard_confirmed_agent_resume(*, payload, generate_entity) -> bool: payload.pop("_builder_agent_claimed", None) - if getattr(generate_entity, "builder_agent_execution", None) is None: + if generate_entity.builder_agent_execution is None: if payload.get("builder_agent_dispatch") is not None: raise BuilderExecutionPolicyError("native_dispatch_mismatch") return True diff --git a/api/services/dify_builder/native_testing/confirmed_agent_completion.py b/api/services/dify_builder/native_testing/confirmed_agent_completion.py index 0b8d1efbb97..8b83e99b8d1 100644 --- a/api/services/dify_builder/native_testing/confirmed_agent_completion.py +++ b/api/services/dify_builder/native_testing/confirmed_agent_completion.py @@ -57,16 +57,21 @@ def _request(sql, *, actor, ref): or canonical_digest(request.context) != ref.context_digest or request.context.get("policy_version") != ref.policy_version or request.context.get("invocation_id") != ref.invocation_id - or any( - request.context.get(key) != getattr(request, key) - for key in ( - "tenant_id", - "actor_id", - "app_id", - "workflow_id", - "session_id", - "test_input_id", - ) + or ( + request.context.get("tenant_id"), + request.context.get("actor_id"), + request.context.get("app_id"), + request.context.get("workflow_id"), + request.context.get("session_id"), + request.context.get("test_input_id"), + ) + != ( + request.tenant_id, + request.actor_id, + request.app_id, + request.workflow_id, + request.session_id, + request.test_input_id, ) or _native_run_id(request) is None or request.context.get("profile", {}).get("profile_id") != "builder-agent-confirmed-v1" diff --git a/api/services/dify_builder/native_testing/confirmed_agent_continuation.py b/api/services/dify_builder/native_testing/confirmed_agent_continuation.py index b9f962e4f17..f8a0bcf55f8 100644 --- a/api/services/dify_builder/native_testing/confirmed_agent_continuation.py +++ b/api/services/dify_builder/native_testing/confirmed_agent_continuation.py @@ -17,6 +17,7 @@ from sqlalchemy import select from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext from extensions.ext_storage import storage +from graphon.entities.pause_reason import SchedulingPause from graphon.runtime import RuntimeState from models.dify_builder import DifyBuilderExecutionRequest from models.dify_builder_native_tests import NativeEffectOperation, NativePayloadObject, NativePendingAction @@ -199,10 +200,7 @@ class ConfirmedAgentContinuation: NativePayloadObject.kind == "result", NativePayloadObject.logical_key.like("agent-cp:%") ) ).all() - receipts = [ - ObjectReceipt(**{name: getattr(row, name) for name in ObjectReceipt.__dataclass_fields__}) - for row in rows - ] + receipts = [ObjectReceipt.from_payload_object(row) for row in rows] latest = {} for receipt in receipts: value = self.store._read_object(receipt) @@ -227,7 +225,10 @@ class ConfirmedAgentContinuation: """Call after consuming the actual ordinary paused stream, before disclosure.""" context = self.store.load_context(ref=ref) paused = self.read_parent_pause(ref=ref) - reasons = {getattr(reason, "message", None) for reason in paused.runtime.graph_execution.pause_reasons} + reasons = { + reason.message if isinstance(reason, SchedulingPause) else None + for reason in paused.runtime.graph_execution.pause_reasons + } allowed = {"agent_tool_approval"} if allow_ack_recovery: allowed |= { diff --git a/api/services/dify_builder/native_testing/context_runtime.py b/api/services/dify_builder/native_testing/context_runtime.py index a3b2502fa3e..c3c2eef0c6b 100644 --- a/api/services/dify_builder/native_testing/context_runtime.py +++ b/api/services/dify_builder/native_testing/context_runtime.py @@ -79,6 +79,11 @@ def native_flask_worker_scope(*, host: NativeHostLaunch, flask_app: Flask, conte variable.reset(token) +class _NativeContextManagers(local): + def __init__(self) -> None: + self.stack: list[AbstractContextManager[None]] = [] + + class NativeExecutionContext: """Reentrant public execution context; every entry restores prior tokens.""" @@ -88,7 +93,7 @@ class NativeExecutionContext: raise NativeObservationError() from None self._captured = cast(ExecutionContext | FlaskExecutionContext, captured) self._guard = guard - self._local = local() + self._local = _NativeContextManagers() @contextmanager def enter(self) -> Generator[None]: @@ -108,10 +113,7 @@ class NativeExecutionContext: def __enter__(self) -> NativeExecutionContext: manager = self.enter() manager.__enter__() - stack = getattr(self._local, "stack", None) - if stack is None: - stack = self._local.stack = [] - stack.append(manager) + self._local.stack.append(manager) return self def __exit__(self, *args: Any) -> None: diff --git a/api/services/dify_builder/native_testing/control.py b/api/services/dify_builder/native_testing/control.py index ff469c123ff..35ef3658781 100644 --- a/api/services/dify_builder/native_testing/control.py +++ b/api/services/dify_builder/native_testing/control.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from datetime import datetime from functools import cache from sys import exc_info -from typing import Any, Literal, Protocol, cast +from typing import Any, Literal, Protocol, cast, runtime_checkable from uuid import uuid4 from pydantic import ValidationError @@ -99,6 +99,7 @@ class NativeControlRefused: """Sink refusal contains no arbitrary diagnostics or resumable authority.""" +@runtime_checkable class NativeControlSink(Protocol): """Trusted synchronous host port, supplied explicitly by composition. @@ -203,8 +204,10 @@ class _NativeControlBoundary: native_control_sink: NativeControlSink, **kwargs: Any, ) -> None: - if type(native_control_owner) is not BoundaryOwnerState or not callable( - getattr(native_control_sink, "record_control", None) + if ( + type(native_control_owner) is not BoundaryOwnerState + or not isinstance(native_control_sink, NativeControlSink) + or not callable(native_control_sink.record_control) ): raise ValueError("native_control_owner_and_sink_required") self._native_control_owner = native_control_owner diff --git a/api/services/dify_builder/native_testing/generation_wiring.py b/api/services/dify_builder/native_testing/generation_wiring.py index e90d8fc34d5..43a282c7d8b 100644 --- a/api/services/dify_builder/native_testing/generation_wiring.py +++ b/api/services/dify_builder/native_testing/generation_wiring.py @@ -14,13 +14,13 @@ from services.dify_builder.native_testing.context_binding import NativeContextUn from services.dify_builder.native_testing.observation_repository import bind_native_repository_observation if TYPE_CHECKING: - from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository from graphon.engine.filter import ResponseStreamFilter from runtime_contracts.builder.native.contracts import NativeTestContextRef from services.dify_builder.native_testing.host_execution import NativeHostLaunch from services.dify_builder.native_testing.runtime_bridge import NativeRuntimeBridge + from services.workflow_runtime.workflow_queue import WorkflowAppQueueManager def validate_native_host( @@ -92,7 +92,7 @@ def bind_native_generation( *, host: NativeHostLaunch, entity: WorkflowAppGenerateEntity, - queue: AppQueueManager, + queue: WorkflowAppQueueManager, response_filter: ResponseStreamFilter, workflow_repository: WorkflowExecutionRepository, node_repository: WorkflowNodeExecutionRepository, diff --git a/api/services/dify_builder/native_testing/host_capacity.py b/api/services/dify_builder/native_testing/host_capacity.py index 56dd8e11d9f..d547333633a 100644 --- a/api/services/dify_builder/native_testing/host_capacity.py +++ b/api/services/dify_builder/native_testing/host_capacity.py @@ -4,7 +4,7 @@ No ordinary workflow limiter, physical host, provider I/O or default cleanup pro """ from contextlib import contextmanager -from typing import Protocol +from typing import Protocol, runtime_checkable from sqlalchemy import and_, select @@ -28,6 +28,7 @@ from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleSe from services.dify_builder.native_testing.state_store import invocation_state, owner_query +@runtime_checkable class NativeHostCleanupVerifier(Protocol): def consume_cleanup(self, *, ref: NativeTestContextRef, generation: int, dispatch_id: str) -> None: """Consume exact retained acquisition's genuine complete cleanup once. @@ -44,7 +45,9 @@ class NativeHostCapacity: def __init__( self, lifecycle: NativeTestLifecycleService, *, cleanup_verifier: NativeHostCleanupVerifier, code_cleanup=None ): - if cleanup_verifier is None or not callable(getattr(cleanup_verifier, "consume_cleanup", None)): + if not isinstance(cleanup_verifier, NativeHostCleanupVerifier) or not callable( + cleanup_verifier.consume_cleanup + ): raise BuilderExecutionPolicyError("native_cleanup_verifier_required") if lifecycle._capacity is not None: raise BuilderExecutionPolicyError("native_capacity_already_bound") diff --git a/api/services/dify_builder/native_testing/host_execution.py b/api/services/dify_builder/native_testing/host_execution.py index 8de8a762570..657da0fe30c 100644 --- a/api/services/dify_builder/native_testing/host_execution.py +++ b/api/services/dify_builder/native_testing/host_execution.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from services.dify_builder.native_testing.sandbox.qualification import ( NativeCodeBindingProducer, ) + from services.workflow_runtime.workflow_queue import WorkflowAppQueueManager from collections.abc import Callable, Generator from contextlib import AbstractContextManager, contextmanager, suppress @@ -22,9 +23,8 @@ from dataclasses import dataclass from math import isfinite from threading import Lock, RLock, Thread, current_thread from time import monotonic -from typing import Literal, Protocol, cast, override +from typing import Literal, Protocol, cast, override, runtime_checkable -from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity from core.repositories.sqlalchemy_workflow_execution_repository import ( SQLAlchemyWorkflowExecutionRepository, @@ -78,6 +78,7 @@ from services.dify_builder.native_testing.control import ( NativeControlRecord, NativeControlRefused, NativePolicyStop, + _NativeControlBoundary, ) from services.dify_builder.native_testing.host_lifetime import ( CloseReason, @@ -116,6 +117,7 @@ class NativePendingFence(Protocol): ) -> None: ... +@runtime_checkable class DurableNativeStopSealer(Protocol): """Required committed C03 stop seal; no implementation/default in this phase.""" @@ -138,7 +140,7 @@ def _timeout(value: HttpTimeout) -> HttpTimeout: @dataclass(frozen=True, slots=True) class NativeHostGenerationBindings: entity: WorkflowAppGenerateEntity - queue: AppQueueManager + queue: WorkflowAppQueueManager response_filter: ResponseStreamFilter workflow_repository: SQLAlchemyWorkflowExecutionRepository node_repository: SQLAlchemyWorkflowNodeExecutionRepository @@ -208,7 +210,7 @@ class _HookScope: node: Node thread: Thread execution_id: str - context: AbstractContextManager[None] + context: AbstractContextManager[object] class _HostObservationLayer(Layer): @@ -306,7 +308,8 @@ class NativeHostLaunch: or type(journal) is not NodeOperationJournal or type(checkpoints) is not NativeCheckpointBridge or not callable(fence_pending) - or not callable(getattr(stop_sealer, "seal_stop", None)) + or not isinstance(stop_sealer, DurableNativeStopSealer) + or not callable(stop_sealer.seal_stop) ): raise ValueError("native_host_explicit_owners_required") self._lifetime = lifetime @@ -866,15 +869,17 @@ class NativeHostLaunch: self, *, entity: WorkflowAppGenerateEntity, - queue: AppQueueManager, + queue: WorkflowAppQueueManager, response_filter: ResponseStreamFilter, workflow_repository: SQLAlchemyWorkflowExecutionRepository, node_repository: SQLAlchemyWorkflowNodeExecutionRepository, ) -> None: + from services.workflow_runtime.workflow_queue import WorkflowAppQueueManager + context = self.lifetime.context if ( type(entity) is not WorkflowAppGenerateEntity - or not isinstance(queue, AppQueueManager) + or not isinstance(queue, WorkflowAppQueueManager) or type(response_filter) is not ResponseStreamFilter or type(workflow_repository) is not SQLAlchemyWorkflowExecutionRepository or type(node_repository) is not SQLAlchemyWorkflowNodeExecutionRepository @@ -1003,6 +1008,7 @@ class NativeHostLaunch: NodeRunLoopSucceededEvent, NodeRunSucceededEvent, ) + from graphon.engine_events.base import NodeEvent from graphon.enums import WorkflowNodeExecutionMetadataKey as Metadata from services.dify_builder.native_testing.observation_owner import ( _stored_profile, @@ -1014,10 +1020,22 @@ class NativeHostLaunch: NativeCodeBindingResolver, ) - result = getattr(event, "node_run_result", None) - metadata = getattr(event, "metadata", None) - result_metadata = getattr(result, "metadata", None) - execution_metadata = getattr(event, "execution_metadata", None) + result = event.node_run_result if isinstance(event, NodeEvent) else None + metadata = ( + event.metadata + if isinstance( + event, + ( + NodeRunIterationSucceededEvent, + NodeRunLoopSucceededEvent, + queue.QueueIterationCompletedEvent, + queue.QueueLoopCompletedEvent, + ), + ) + else None + ) + result_metadata = result.metadata if result is not None else None + execution_metadata = event.execution_metadata if isinstance(event, queue.QueueNodeSucceededEvent) else None sources = [value for value in (metadata, result_metadata, execution_metadata) if type(value) is dict] if not any(type(value.get(Metadata.TOTAL_PRICE)) is Decimal for value in sources): return event @@ -1055,11 +1073,12 @@ class NativeHostLaunch: or node.execution_id != execution_id or node.node_type != event.node_type or node.runtime_state is not engine.runtime_state - or getattr(node, "_native_control_owner", None) is not self.boundary_owner - or getattr(node, "_native_control_sink", None) is not self.control_sink + or not isinstance(node, _NativeControlBoundary) + or node._native_control_owner is not self.boundary_owner + or node._native_control_sink is not self.control_sink ): guard.fail() - updates = {} + updates: dict[str, object] = {} for name, value in ( ("metadata", metadata), ("node_run_result", result_metadata), @@ -1067,9 +1086,9 @@ class NativeHostLaunch: ): if type(value) is dict and type(value.get(Metadata.TOTAL_PRICE)) is Decimal: price = value[Metadata.TOTAL_PRICE] - if not price.is_finite() or price != 0: + if not isinstance(price, Decimal) or not price.is_finite() or price != 0: guard.fail() - copied = {**value, Metadata.TOTAL_PRICE: 0} + copied = {**dict(value), Metadata.TOTAL_PRICE: 0} if name == "node_run_result": if result is None: guard.fail() @@ -1088,8 +1107,8 @@ class NativeHostLaunch: if ( guard is not self.native_observation or queue is not generation.queue - or getattr(queue, "native_host", None) is not self - or getattr(queue, "native_observation", None) is not guard + or queue.native_host is not self + or queue.native_observation is not guard or generation.entity is not restored.entity or (entity is not None and entity is not restored.entity) or generation.response_filter is not restored.response_filter @@ -1137,9 +1156,11 @@ class NativeHostLaunch: self._cancellation_fence = fence def wrap_commands(self, *, channel: CommandChannel) -> CommandChannel: - if not callable(getattr(channel, "fetch_commands", None)) or not callable( - getattr(channel, "send_command", None) - ): + try: + valid = callable(channel.fetch_commands) and callable(channel.send_command) + except AttributeError: + valid = False + if not valid: raise ValueError("native_host_command_channel_invalid") with self._registration: if self._channel is not None: @@ -1186,8 +1207,9 @@ class NativeHostLaunch: """Observe actual marked native root/frame state, never a caller list.""" if ( not isinstance(node, Node) - or getattr(node, "_native_control_owner", None) is not self.boundary_owner - or getattr(node, "_native_control_sink", None) is not self.control_sink + or not isinstance(node, _NativeControlBoundary) + or node._native_control_owner is not self.boundary_owner + or node._native_control_sink is not self.control_sink ): raise ValueError("native_host_node_required") runtime = node.runtime_state diff --git a/api/services/dify_builder/native_testing/host_lifetime.py b/api/services/dify_builder/native_testing/host_lifetime.py index fc794cbc132..11df7a95029 100644 --- a/api/services/dify_builder/native_testing/host_lifetime.py +++ b/api/services/dify_builder/native_testing/host_lifetime.py @@ -17,7 +17,7 @@ from dataclasses import dataclass from math import isfinite from threading import Condition, RLock, Thread, current_thread from time import monotonic -from typing import TYPE_CHECKING, Literal, Protocol +from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable from graphon.nodes.base.node import Node from graphon.runtime import RuntimeState @@ -44,6 +44,7 @@ def _key(ref: NativeTestContextRef) -> RefKey: return ref.policy_version, ref.request_id, ref.invocation_id, ref.context_digest +@runtime_checkable class _Closable(Protocol): def close(self) -> None: ... @@ -170,7 +171,11 @@ class NativeHostCallTicket: def own_stream(self, *, stream: _Closable) -> None: with self._lifetime._condition: self._require_owner() - if not callable(getattr(stream, "close", None)) or any(item is stream for item in self._streams): + if ( + not isinstance(stream, _Closable) + or not callable(stream.close) + or any(item is stream for item in self._streams) + ): raise ValueError("native_host_stream_invalid") self._streams.append(stream) diff --git a/api/services/dify_builder/native_testing/http_binding.py b/api/services/dify_builder/native_testing/http_binding.py index 8e98dd49c62..469181a0bbe 100644 --- a/api/services/dify_builder/native_testing/http_binding.py +++ b/api/services/dify_builder/native_testing/http_binding.py @@ -166,21 +166,21 @@ def _supported(raw: dict) -> HttpRequestNodeData: def _config_projection() -> dict: # Proxy strings may contain credentials. Qualify their exact value by digest; # never place a raw proxy URL in the manifest or a public notice. - names = ( - "SSRF_PROXY_ALL_URL", - "SSRF_PROXY_HTTP_URL", - "SSRF_PROXY_HTTPS_URL", - "SSRF_DEFAULT_TIME_OUT", - "SSRF_DEFAULT_CONNECT_TIME_OUT", - "SSRF_DEFAULT_READ_TIME_OUT", - "SSRF_DEFAULT_WRITE_TIME_OUT", - "SSRF_POOL_MAX_CONNECTIONS", - "SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS", - "SSRF_POOL_KEEPALIVE_EXPIRY", - ) + settings = { + "SSRF_PROXY_ALL_URL": dify_config.SSRF_PROXY_ALL_URL, + "SSRF_PROXY_HTTP_URL": dify_config.SSRF_PROXY_HTTP_URL, + "SSRF_PROXY_HTTPS_URL": dify_config.SSRF_PROXY_HTTPS_URL, + "SSRF_DEFAULT_TIME_OUT": dify_config.SSRF_DEFAULT_TIME_OUT, + "SSRF_DEFAULT_CONNECT_TIME_OUT": dify_config.SSRF_DEFAULT_CONNECT_TIME_OUT, + "SSRF_DEFAULT_READ_TIME_OUT": dify_config.SSRF_DEFAULT_READ_TIME_OUT, + "SSRF_DEFAULT_WRITE_TIME_OUT": dify_config.SSRF_DEFAULT_WRITE_TIME_OUT, + "SSRF_POOL_MAX_CONNECTIONS": dify_config.SSRF_POOL_MAX_CONNECTIONS, + "SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS": dify_config.SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS, + "SSRF_POOL_KEEPALIVE_EXPIRY": dify_config.SSRF_POOL_KEEPALIVE_EXPIRY, + } return { "native": asdict(native_http_config()), - "ssrf_settings_digest": canonical_digest({name: getattr(dify_config, name) for name in names}), + "ssrf_settings_digest": canonical_digest(settings), } @@ -459,7 +459,14 @@ class NativeHttpBindingProducer: raise BuilderExecutionPolicyError("native_http_node_unsupported") raw = _node(context.graph, self.association.node_id) expected = _supported(raw) - nodes_by_id = {item["id"]: item for item in context.graph["nodes"]} + raw_nodes = context.graph.get("nodes") + if not isinstance(raw_nodes, list): + raise BuilderExecutionPolicyError("native_http_node_drift") + nodes_by_id: dict[str, dict] = {} + for item in raw_nodes: + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + raise BuilderExecutionPolicyError("native_http_node_drift") + nodes_by_id[item["id"]] = item owned_config = nodes_by_id[self.association.node_id] def normalized(data: dict) -> dict: @@ -703,7 +710,15 @@ class DeferredNativeHttpClient: max_redirect_hops=policy["max_redirect_hops"], max_response_bytes=policy["max_response_bytes"], ) - result = getattr(client, method)(url, max_retries=max_retries, **kwargs) + methods = { + "get": client.get, + "head": client.head, + "post": client.post, + "put": client.put, + "patch": client.patch, + "delete": client.delete, + } + result = methods[method](url, max_retries=max_retries, **kwargs) self._producer.finish() return result diff --git a/api/services/dify_builder/native_testing/lifecycle.py b/api/services/dify_builder/native_testing/lifecycle.py index 399abbe5496..1f09c494eee 100644 --- a/api/services/dify_builder/native_testing/lifecycle.py +++ b/api/services/dify_builder/native_testing/lifecycle.py @@ -246,7 +246,7 @@ class NativeTestLifecycleService: workflow = validate_invocation_workflow( session, tenant_id=owned.tenant_id, app_id=authority.app_id, workflow_id=authority.workflow_id ) - revision_context = getattr(authority, "revision_context", None) + revision_context = authority.revision_context revision = ( admitted_workflow_revision(workflow, revision_context) if revision_context @@ -863,7 +863,7 @@ class NativeTestLifecycleService: or (state.app_id, state.workflow_id) != (context.app_id, context.workflow_id) or (context.tenant_id, context.actor_id, context.app_id, context.workflow_id) != (owned.tenant_id, owned.actor_id, owned.app_id, owned.workflow_id) - or any(getattr(scope, name) != 0 for name in ("spent_calls", "spent_dispatches", "spent_cost_units")) + or any(value != 0 for value in (scope.spent_calls, scope.spent_dispatches, scope.spent_cost_units)) or scope.budget_spent != {} ): raise BuilderExecutionPolicyError("native_initial_conflict") @@ -880,7 +880,7 @@ class NativeTestLifecycleService: session, tenant_id=owned.tenant_id, app_id=context.app_id, workflow_id=context.workflow_id ) inputs = session.get(DifyBuilderTestInput, owned.test_input_id) - revision_context = getattr(owned, "revision_context", None) + revision_context = owned.revision_context if ( ( admitted_workflow_revision(workflow, revision_context) diff --git a/api/services/dify_builder/native_testing/native_execution.py b/api/services/dify_builder/native_testing/native_execution.py index 21b8488b581..8574e172d30 100644 --- a/api/services/dify_builder/native_testing/native_execution.py +++ b/api/services/dify_builder/native_testing/native_execution.py @@ -8,7 +8,7 @@ back to an ordinary generator or synthesizes a successful Run. from __future__ import annotations from collections.abc import Callable, Generator, Iterator, Mapping -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from sqlalchemy.orm import Session, sessionmaker @@ -25,6 +25,11 @@ if TYPE_CHECKING: from services.dify_builder.native_testing.host_execution import NativeHostLaunch +@runtime_checkable +class _ClosableNativeSource(Protocol): + def close(self) -> None: ... + + class NativeWorkflowOwner(Protocol): """Adapter to WorkflowAppGenerator; owns native stream and persistence semantics.""" @@ -278,7 +283,6 @@ class NativeGeneratorStream: try: self._events.close() finally: - close = getattr(self._source, "close", None) - if close is not None: - close() + if isinstance(self._source, _ClosableNativeSource): + self._source.close() self._closed = True diff --git a/api/services/dify_builder/native_testing/observation_owner.py b/api/services/dify_builder/native_testing/observation_owner.py index ca9dea88dfe..d5a7337cdc7 100644 --- a/api/services/dify_builder/native_testing/observation_owner.py +++ b/api/services/dify_builder/native_testing/observation_owner.py @@ -24,8 +24,10 @@ def _checked_context(context: ResolvedNativeTestContext) -> ResolvedNativeTestCo raise NativeObservationError() if context.ref.model_extra or set(context.ref.__dict__) != set(NativeTestContextRef.model_fields): raise NativeObservationError() - data = {name: getattr(context, name) for name in ResolvedNativeTestContext.model_fields} - data["ref"] = {name: getattr(context.ref, name) for name in NativeTestContextRef.model_fields} + # Exact DTO types above make model iteration a raw field read: no serializer + # can render unchecked values before the shape validator rejects them. + data = dict(context) + data["ref"] = dict(context.ref) _shape(data) return ResolvedNativeTestContext.model_validate(data, strict=True) diff --git a/api/services/dify_builder/native_testing/observation_privacy.py b/api/services/dify_builder/native_testing/observation_privacy.py index b9757849cc8..482a3b95f18 100644 --- a/api/services/dify_builder/native_testing/observation_privacy.py +++ b/api/services/dify_builder/native_testing/observation_privacy.py @@ -268,7 +268,7 @@ class NativeHttpObservationPolicy: _refuse() # Unchecked frozen DTO copies can contain arbitrary objects. Inspect # plain fields before Pydantic serialization can warn with repr(data). - profile_data = {name: getattr(profile, name) for name in NativeRuntimeProfile.model_fields} + profile_data = dict(profile) _shape(profile_data) from services.dify_builder.native_testing.sandbox.qualification import ( PROFILE_ID as CODE_PROFILE_ID, @@ -285,8 +285,8 @@ class NativeHttpObservationPolicy: code_qualification.validate_profile(NativeRuntimeProfile.model_validate(profile_data)) else: validate_native_http_profile(NativeRuntimeProfile.model_validate(profile_data)) - context_data = {name: getattr(context, name) for name in ResolvedNativeTestContext.model_fields} - context_data["ref"] = {name: getattr(context.ref, name) for name in NativeTestContextRef.model_fields} + context_data = dict(context) + context_data["ref"] = dict(context.ref) _shape(context_data) checked = ResolvedNativeTestContext.model_validate(context_data) if ( @@ -578,8 +578,9 @@ class NativeHttpObservationPolicy: if not any((source, target) == (tail, head) for _, tail, head in self._edges): _refuse() fields: dict[str, Any] = {} + raw_fields = dict(event) for name in cls.model_fields: - value = getattr(event, name) + value = raw_fields[name] if name == "container_id": fields[name] = "" if value == "" else _identity(value) elif name == "frame_id": diff --git a/api/services/dify_builder/native_testing/observation_repository.py b/api/services/dify_builder/native_testing/observation_repository.py index 18868581381..42ad243a23a 100644 --- a/api/services/dify_builder/native_testing/observation_repository.py +++ b/api/services/dify_builder/native_testing/observation_repository.py @@ -121,7 +121,7 @@ class NativeRepositoryObservation: @classmethod def bind(cls, *, repository: Any, cache: dict, context: ResolvedNativeTestContext, guard: NativeObservationGuard): - previous = getattr(repository, "_native_observation", None) + previous = repository._native_observation if previous is not None: if type(previous) is not cls: guard.fail() @@ -132,7 +132,7 @@ class NativeRepositoryObservation: if type(guard) is not NativeObservationGuard: raise NativeObservationError() from None with guard.persistence_scope(): - if cache or getattr(repository, "_native_observation_started", False): + if cache or repository._native_observation_started: guard.fail() return cls(repository=repository, context=context, guard=guard) @@ -264,12 +264,12 @@ class NativeRepositoryObservation: self.guard.fail() WorkflowExecutionStatus(row.status) _identity(row.version) - for name in ("total_tokens", "total_steps", "exceptions_count"): - _count(getattr(row, name)) + for value in (row.total_tokens, row.total_steps, row.exceptions_count): + _count(value) if canonical_json(_json(row.graph)) != canonical_json(self._graph): self.guard.fail() - for name in ("inputs", "outputs"): - self._values(_json(getattr(row, name))) + for value in (row.inputs, row.outputs): + self._values(_json(value)) if row.error is not None and (type(row.error) is not str or self._error(row.error) != row.error): self.guard.fail() return row @@ -340,8 +340,8 @@ class NativeRepositoryObservation: or row.triggered_from != WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN ): self.guard.fail() - for name in ("inputs", "outputs", "process_data"): - self._values(_json(getattr(row, name)), process=name == "process_data") + for name, value in (("inputs", row.inputs), ("outputs", row.outputs), ("process_data", row.process_data)): + self._values(_json(value), process=name == "process_data") metadata = _json(row.execution_metadata) or {} if canonical_json(self.guard._policy._metadata(metadata)) != canonical_json(metadata): self.guard.fail() @@ -426,7 +426,7 @@ class NativeRepositoryObservation: self._node_row(row) copy = WorkflowNodeExecutionModel() for column in inspect(WorkflowNodeExecutionModel).column_attrs: - setattr(copy, column.key, deepcopy(getattr(row, column.key))) + setattr(copy, column.key, deepcopy(inspect(row).attrs[column.key].value)) seen = set() for link in row.offload_data: if link.type_ in seen: @@ -481,7 +481,7 @@ class NativeRepositoryObservation: self.run_row(row) copied = WorkflowRun() for column in inspect(WorkflowRun).column_attrs: - setattr(copied, column.key, deepcopy(getattr(row, column.key))) + setattr(copied, column.key, deepcopy(inspect(row).attrs[column.key].value)) result = repository._ordinary_to_domain_model(copied) self.check(repository) repository._execution_cache[copied.id] = copied @@ -528,7 +528,7 @@ class NativeRepositoryObservation: return None if type(row) is not model: raise NativeObservationError() - values = tuple(getattr(row, column.key) for column in inspect(model).column_attrs) + values = tuple(inspect(row).attrs[column.key].value for column in inspect(model).column_attrs) allowed = ( str, int, @@ -587,8 +587,12 @@ class NativeRepositoryObservation: signature = self._signature(previous) row.offload_data = [] if data: - for name in ("inputs", "outputs", "process_data"): - values = _json(getattr(row, name)) + for name, value in ( + ("inputs", row.inputs), + ("outputs", row.outputs), + ("process_data", row.process_data), + ): + values = _json(value) with sensitive_http_telemetry(additional_emitters=_EMITTERS): result = repository._ordinary_truncate_and_upload(values, row.id, ExecutionOffLoadType(name)) if result is not None: diff --git a/api/services/dify_builder/native_testing/observation_stream.py b/api/services/dify_builder/native_testing/observation_stream.py index c813a9715c1..1aba0d2437d 100644 --- a/api/services/dify_builder/native_testing/observation_stream.py +++ b/api/services/dify_builder/native_testing/observation_stream.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from typing import Any, cast from core.app.entities import queue_entities as q +from core.app.entities.app_invoke_entities import AppGenerateEntity from graphon.entities import WorkflowStartReason from graphon.entities.pause_reason import SchedulingPause from graphon.runtime import RuntimeState @@ -37,11 +38,11 @@ _SUPPORTED: set[type[q.AppQueueEvent]] = { } -def require_native_observation(*, guard: NativeObservationGuard | None, entity: Any = None) -> None: +def require_native_observation( + *, guard: NativeObservationGuard | None, entity: AppGenerateEntity | None = None +) -> None: """Require an exact healthy guard before any marked adapter setup.""" - marked = entity is not None and ( - getattr(entity, "native_test_required", False) or getattr(entity, "native_test_ref", None) is not None - ) + marked = entity is not None and (entity.native_test_required or entity.native_test_ref is not None) if guard is None and not marked: return if type(guard) is not NativeObservationGuard: @@ -194,8 +195,9 @@ def project_native_queue_event[E: q.AppQueueEvent]( if cls.__name__.startswith("QueueLoop") and node[0] != "loop": guard.fail() fields: dict[str, Any] = {} + raw_fields = dict(event) for name in cls.model_fields: - value = getattr(event, name) + value = raw_fields[name] if name == "event": continue if name == "node_execution_snapshots" and cls is q.QueueWorkflowStartedEvent: diff --git a/api/services/dify_builder/native_testing/pending_intents.py b/api/services/dify_builder/native_testing/pending_intents.py index a01a168d2e1..4943e877690 100644 --- a/api/services/dify_builder/native_testing/pending_intents.py +++ b/api/services/dify_builder/native_testing/pending_intents.py @@ -244,7 +244,7 @@ class PendingDecisionGuard(FrozenDTO): workflow = validate_invocation_workflow( session, tenant_id=owned.tenant_id, app_id=self.invocation_app_id, workflow_id=self.invocation_workflow_id ) - revision_context = getattr(owned, "revision_context", None) + revision_context = owned.revision_context revision = ( admitted_workflow_revision(workflow, revision_context) if revision_context @@ -319,7 +319,11 @@ class PendingDecisionGuard(FrozenDTO): receipt = self.receipt.model_copy( update={ "pending": tuple( - project_pending_decision(item, grant=grants.get(item.scope_digest), now=utc_now()) + project_pending_decision( + item, + grant=grants.get(item.scope_digest) if item.scope_digest is not None else None, + now=utc_now(), + ) for item in self.receipt.pending ), "decision_public_revision": decision_public_revision(request.observations) + 1, diff --git a/api/services/dify_builder/native_testing/runtime.py b/api/services/dify_builder/native_testing/runtime.py index 42d84e4acba..7ce6ffd0c9d 100644 --- a/api/services/dify_builder/native_testing/runtime.py +++ b/api/services/dify_builder/native_testing/runtime.py @@ -854,8 +854,8 @@ def validate_projection_host(*, host, guard, entity=None, queue=None, task_id=No if ( entity is not generation.entity or queue is not generation.queue - or getattr(queue, "native_host", None) is not host - or getattr(queue, "native_observation", None) is not guard + or queue.native_host is not host + or queue.native_observation is not guard ): raise BuilderExecutionPolicyError("native_resume_identity_mismatch") if context.generation > 0: diff --git a/api/services/dify_builder/native_testing/runtime_bridge.py b/api/services/dify_builder/native_testing/runtime_bridge.py index 8a47705b8da..7f1558b1aaf 100644 --- a/api/services/dify_builder/native_testing/runtime_bridge.py +++ b/api/services/dify_builder/native_testing/runtime_bridge.py @@ -16,7 +16,12 @@ from graphon.nodes.base.node import Node from runtime_contracts.builder.native.contracts import NativeTestContextRef from runtime_contracts.builder.native.runtime_contracts import NativeStatePort, ResolvedNativeTestContext from services.dify_builder.native_testing.context_binding import NativeContextUnavailable, resolve_native_context -from services.dify_builder.native_testing.control import BoundaryOwnerState, NativeControlSink, marked_native_node_class +from services.dify_builder.native_testing.control import ( + BoundaryOwnerState, + NativeControlSink, + _NativeControlBoundary, + marked_native_node_class, +) @dataclass(frozen=True, slots=True) @@ -69,8 +74,10 @@ class NativeRuntimeBridge: node_arguments: NativeNodeArguments, prepare_inputs: Callable[[ResolvedNativeTestContext], NativePreparedInputs] | None = None, ): - if type(boundary_owner) is not BoundaryOwnerState or not callable( - getattr(control_sink, "record_control", None) + if ( + type(boundary_owner) is not BoundaryOwnerState + or not isinstance(control_sink, NativeControlSink) + or not callable(control_sink.record_control) ): raise NativeContextUnavailable("native_control_unavailable") self._boundary_owner = boundary_owner @@ -116,7 +123,19 @@ class NativeRuntimeBridge: ) bundle = self._collaborators(association=prepared) if not isinstance(bundle, NativeCollaborators) or any( - getattr(bundle, field) is None for field in NativeCollaborators.__dataclass_fields__ + value is None + for value in ( + bundle.http, + bundle.model, + bundle.tool, + bundle.retrieval, + bundle.file, + bundle.code, + bundle.jinja, + bundle.agent, + bundle.human_input, + bundle.data_source, + ) ): raise NativeContextUnavailable("native_collaborator_unavailable") arguments = dict( @@ -198,8 +217,9 @@ class NativeNodePreparation: or node.id != self.node_id or node.runtime_state is not self._runtime_state # These are Dify control-mixin fields, never Graphon worker/frame internals. - or getattr(node, "_native_control_owner", None) is not self._bridge._boundary_owner - or getattr(node, "_native_control_sink", None) is not self._bridge._control_sink + or not isinstance(node, _NativeControlBoundary) + or node._native_control_owner is not self._bridge._boundary_owner + or node._native_control_sink is not self._bridge._control_sink ): raise NativeContextUnavailable("native_node_mismatch") if self.ref != self._bridge._boundary_owner.ref: diff --git a/api/services/dify_builder/native_testing/sandbox/contracts.py b/api/services/dify_builder/native_testing/sandbox/contracts.py index b13836da729..1b187b44114 100644 --- a/api/services/dify_builder/native_testing/sandbox/contracts.py +++ b/api/services/dify_builder/native_testing/sandbox/contracts.py @@ -1,7 +1,7 @@ """Immutable reference-only Code state. DTO validation never grants authority.""" from datetime import datetime -from typing import Annotated, Protocol +from typing import Annotated, Protocol, runtime_checkable from uuid import UUID from pydantic import AfterValidator, Field, StrictBool, model_validator @@ -36,6 +36,7 @@ class CodeAcquisitionRef(FrozenDTO): acquisition_id: UUIDString +@runtime_checkable class CodeAcquisitionOwner(Protocol): def check_current(self, acquisition: CodeAcquisitionRef) -> None: """Local retained-object/registry check, before and after every transaction.""" diff --git a/api/services/dify_builder/native_testing/sandbox/qualification.py b/api/services/dify_builder/native_testing/sandbox/qualification.py index 195ee32860a..76edfccb85a 100644 --- a/api/services/dify_builder/native_testing/sandbox/qualification.py +++ b/api/services/dify_builder/native_testing/sandbox/qualification.py @@ -15,7 +15,7 @@ from dataclasses import asdict, dataclass from importlib.metadata import version from inspect import getsource from types import MappingProxyType -from typing import Annotated, NoReturn, Protocol, cast +from typing import Annotated, NoReturn, Protocol, cast, runtime_checkable from urllib.parse import urlsplit from uuid import NAMESPACE_URL, uuid5 @@ -126,6 +126,7 @@ class NativeCodeCapability(FrozenDTO): node_executable: Annotated[str, Field(strict=True, pattern=r"^/[A-Za-z0-9_./-]+$")] +@runtime_checkable class NativeCodeQualificationOwner(Protocol): def validate(self, *, capability: NativeCodeCapability) -> None: """Revalidate actual complete qualification; local only, no IO/RPC. @@ -143,8 +144,8 @@ class NativeCodeQualification: try: if ( type(capability) is not NativeCodeCapability - or owner is None - or not callable(getattr(owner, "validate", None)) + or not isinstance(owner, NativeCodeQualificationOwner) + or not callable(owner.validate) ): refuse() cap = NativeCodeCapability.model_validate(capability.model_dump(mode="python"), strict=True) diff --git a/api/services/dify_builder/native_testing/sandbox/repository.py b/api/services/dify_builder/native_testing/sandbox/repository.py index ce291992a00..6e029302446 100644 --- a/api/services/dify_builder/native_testing/sandbox/repository.py +++ b/api/services/dify_builder/native_testing/sandbox/repository.py @@ -14,7 +14,7 @@ from time import monotonic from typing import Never, Protocol from pydantic import TypeAdapter -from sqlalchemy import or_, select, text +from sqlalchemy import inspect, or_, select, text from sqlalchemy.orm import Session from models.dify_builder_native_code import NativeCodePhysicalOperation @@ -92,7 +92,7 @@ class NativeCodePhysicalRepository: acquisition_owner: CodeAcquisitionOwner, transaction_deadline: Callable[[bool], float], ): - if acquisition_owner is None or not callable(getattr(acquisition_owner, "check_current", None)): + if not isinstance(acquisition_owner, CodeAcquisitionOwner) or not callable(acquisition_owner.check_current): raise BuilderExecutionPolicyError("native_code_acquisition_owner_required") if not callable(transaction_deadline): raise BuilderExecutionPolicyError("native_code_transaction_deadline_required") @@ -247,7 +247,7 @@ class NativeCodePhysicalRepository: @staticmethod def _snapshot(row: NativeCodePhysicalOperation, ref: NativeTestContextRef) -> CodePhysicalRef: - data = {column.name: getattr(row, column.name) for column in row.__table__.columns} + data = {attribute.key: attribute.value for attribute in inspect(row).attrs} acquisition = CodeAcquisitionRef( ref=ref.model_copy(update={"invocation_id": data.pop("root_invocation_id")}), root_dispatch_id=data.pop("host_dispatch_id"), @@ -386,7 +386,7 @@ class NativeCodePhysicalRepository: _refuse() actual = self._snapshot(row, owned.ref) for name in CodePhysicalRef.model_fields: - if name not in _MUTABLE and getattr(actual, name) != getattr(physical, name): + if name not in _MUTABLE and dict(actual)[name] != dict(physical)[name]: _refuse() if (row.tenant_id, row.actor_id, row.request_id, row.invocation_id) != ( owned.tenant_id, diff --git a/api/services/dify_builder/native_testing/state_store.py b/api/services/dify_builder/native_testing/state_store.py index f5dd9b55109..4d3e40d6859 100644 --- a/api/services/dify_builder/native_testing/state_store.py +++ b/api/services/dify_builder/native_testing/state_store.py @@ -156,6 +156,21 @@ class ObjectReceipt: payload_digest: str value_digest: str + @classmethod + def from_payload_object(cls, row: NativePayloadObject) -> "ObjectReceipt": + """Detach the persisted payload identity from its SQLAlchemy session.""" + return cls( + id=row.id, + tenant_id=row.tenant_id, + request_id=row.request_id, + invocation_id=row.invocation_id, + kind=row.kind, + logical_key=row.logical_key, + storage_key=row.storage_key, + payload_digest=row.payload_digest, + value_digest=row.value_digest, + ) + @dataclass class _AgentPreparation: @@ -305,7 +320,7 @@ class NativeExecutionStateStore: with self.loader.session_factory() as session: workflow = session.get(Workflow, owned.workflow_id) test_input = session.get(DifyBuilderTestInput, owned.test_input_id) - revision_context = getattr(owned, "revision_context", None) + revision_context = owned.revision_context if ( workflow is None or test_input is None @@ -498,7 +513,7 @@ class NativeExecutionStateStore: if payload.generation != continuation.generation: raise BuilderExecutionPolicyError("native_generation_conflict") prepared = self._agent_checkpoint_preparations(ref, payload) - outer = ObjectReceipt(**{name: getattr(row, name) for name in ObjectReceipt.__dataclass_fields__}) + outer = ObjectReceipt.from_payload_object(row) with self.loader.locked(ref=ref) as (session, current, scope): selected_continuation = session.scalar( owner_query(NativeContinuation, ref, current.tenant_id).where( @@ -516,10 +531,7 @@ class NativeExecutionStateStore: if ( selected_object is None or selected_continuation.payload_ref != outer.storage_key - or any( - getattr(selected_object, name) != getattr(outer, name) - for name in ObjectReceipt.__dataclass_fields__ - ) + or ObjectReceipt.from_payload_object(selected_object) != outer ): raise BuilderExecutionPolicyError("native_agent_owner_mismatch") for selected in prepared: @@ -644,7 +656,7 @@ class NativeExecutionStateStore: workflow = validate_invocation_workflow( session, tenant_id=owned.tenant_id, app_id=context.app_id, workflow_id=context.workflow_id ) - revision_context = getattr(owned, "revision_context", None) + revision_context = owned.revision_context revision = ( admitted_workflow_revision(workflow, revision_context) if revision_context @@ -916,7 +928,7 @@ class NativeExecutionStateStore: if required: raise BuilderExecutionPolicyError("native_agent_object_unavailable") return None - receipt = ObjectReceipt(**{name: getattr(row, name) for name in ObjectReceipt.__dataclass_fields__}) + receipt = ObjectReceipt.from_payload_object(row) if ( receipt.tenant_id, receipt.request_id, @@ -1011,9 +1023,7 @@ class NativeExecutionStateStore: NativePayloadObject.kind == "result", ) ) - if row is None or any( - getattr(row, name) != getattr(receipt, name) for name in ObjectReceipt.__dataclass_fields__ - ): + if row is None or ObjectReceipt.from_payload_object(row) != receipt: raise BuilderExecutionPolicyError("native_agent_owner_mismatch") def _agent_ack(self, prepared: _AgentPreparation, *, logical_key: str, value: JsonValue) -> str: diff --git a/api/services/dify_builder/native_testing/stop_settlement.py b/api/services/dify_builder/native_testing/stop_settlement.py index 34e238572f7..ebb3f543917 100644 --- a/api/services/dify_builder/native_testing/stop_settlement.py +++ b/api/services/dify_builder/native_testing/stop_settlement.py @@ -32,6 +32,11 @@ if TYPE_CHECKING: from services.dify_builder.native_testing.host_execution import NativeHostLaunch +class _EvidenceReadDepth(local): + def __init__(self) -> None: + self.depth = 0 + + class NativeEvidenceReadGate: """Drain active evidence reads before seal; wait for exact ACK afterward. @@ -43,7 +48,7 @@ class NativeEvidenceReadGate: def __init__(self, host: NativeHostLaunch) -> None: self._host = host self._condition = Condition() - self._local = local() + self._local = _EvidenceReadDepth() self._readers = 0 self._record: NativeControlRecord | None = None self._deadline: float | None = None @@ -61,7 +66,7 @@ class NativeEvidenceReadGate: @contextmanager def read(self) -> Generator[None]: - nested = getattr(self._local, "depth", 0) > 0 + nested = self._local.depth > 0 with self._condition: if not nested: while self._record is not None: @@ -75,7 +80,7 @@ class NativeEvidenceReadGate: break self._wait() self._readers += 1 - self._local.depth = getattr(self._local, "depth", 0) + 1 + self._local.depth = self._local.depth + 1 try: yield finally: @@ -87,7 +92,7 @@ class NativeEvidenceReadGate: def begin_stop(self, *, record: NativeControlRecord, deadline: float) -> None: with self._condition: - if self._record is not None or getattr(self._local, "depth", 0): + if self._record is not None or self._local.depth: raise ValueError("native_stop_settlement_replayed") self._record, self._deadline = record, deadline while self._readers: diff --git a/api/services/dify_builder/resource_grounding.py b/api/services/dify_builder/resource_grounding.py index 47bb84aaa30..bdbde7a8c21 100644 --- a/api/services/dify_builder/resource_grounding.py +++ b/api/services/dify_builder/resource_grounding.py @@ -280,7 +280,10 @@ def resolve_edit_choices(fields: list[dict], rules: dict, catalogue: TenantResou def resolve_plan_resources(agent: Any, context: Any, graph: dict) -> None: - resolver = getattr(agent, "resolve_edit_bindings", None) + try: + resolver = agent.resolve_edit_bindings + except AttributeError: + return if not callable(resolver): return selection = resolver(context.form_fields, context.edit_rules, graph) @@ -323,7 +326,10 @@ def selection_failure_step( validation_code=error.reason_code.upper(), node_id=error.node_id, ) - refresh = getattr(env.agent, "refresh_edit_bindings", None) + try: + refresh = env.agent.refresh_edit_bindings + except AttributeError: + refresh = None if callable(refresh): impact = refresh( context.form_fields, context.edit_rules, graph, node_id=error.node_id, kind=error.resource_kind diff --git a/api/services/dify_builder/run_mapping.py b/api/services/dify_builder/run_mapping.py index 44d755f06ba..be26f59ebcf 100644 --- a/api/services/dify_builder/run_mapping.py +++ b/api/services/dify_builder/run_mapping.py @@ -23,9 +23,11 @@ read as green to the user, even if it's technically "still running" import json from collections.abc import Mapping, Sequence from dataclasses import replace +from enum import Enum from typing import Any from core.dify_builder.models import NodeEvent, NodeOutput, Run, RunVerification +from models.workflow import WorkflowRun _FAILED_NODE_STATUSES = {"failed", "exception"} @@ -97,7 +99,7 @@ def _status_value(status: Any) -> Any: ``None``), so callers don't need to import ``graphon`` to use this module. """ - return getattr(status, "value", status) + return status.value if isinstance(status, Enum) else status def _is_paused_shape(data: Mapping[str, Any]) -> bool: @@ -169,20 +171,19 @@ def run_id_from_stream_chunk(chunk: Mapping[str, Any]) -> str: return "" -def run_result_data_from_run_row(run_row: Any) -> dict[str, Any]: +def run_result_data_from_run_row(run_row: WorkflowRun) -> dict[str, Any]: """A ``WorkflowRun`` row shaped the way ``map_run_result`` expects it. The stream is the fast path, not the authority: when it ends without a - terminal frame the row is what actually says how the run went. Duck-typed - like the node-execution rows, so tests can pass stand-ins. + terminal frame the row is what actually says how the run went. Read only declared native WorkflowRun fields. """ return { - "id": str(getattr(run_row, "id", "") or ""), - "status": _status_value(getattr(run_row, "status", "")) or "", - "outputs": (getattr(run_row, "outputs_dict", None) if getattr(run_row, "outputs", True) is not None else None), - "error": getattr(run_row, "error", "") or "", - "elapsed_time": getattr(run_row, "elapsed_time", 0) or 0, - "total_tokens": getattr(run_row, "total_tokens", 0) or 0, + "id": str(run_row.id or ""), + "status": _status_value(run_row.status) or "", + "outputs": run_row.outputs_dict if run_row.outputs is not None else None, + "error": run_row.error or "", + "elapsed_time": run_row.elapsed_time or 0, + "total_tokens": run_row.total_tokens or 0, } @@ -243,9 +244,9 @@ def map_run_result(data: Mapping[str, Any], node_execs: Sequence[Any]) -> Run: title=node.title, status=_status_value(node.status), error=node.error or "", - inputs=dict(node.inputs_dict) if isinstance(getattr(node, "inputs_dict", None), Mapping) else {}, + inputs=dict(node.inputs_dict) if isinstance(node.inputs_dict, Mapping) else {}, outputs=dict(node.outputs_dict) if isinstance(node.outputs_dict, Mapping) else {}, - outputs_available=isinstance(node.outputs_dict, Mapping) and not getattr(node, "outputs_truncated", False), + outputs_available=isinstance(node.outputs_dict, Mapping) and not node.outputs_truncated, ) for node in node_execs ] diff --git a/api/services/dify_builder/template_execution.py b/api/services/dify_builder/template_execution.py index b5b39442bb6..35460cee1c5 100644 --- a/api/services/dify_builder/template_execution.py +++ b/api/services/dify_builder/template_execution.py @@ -134,8 +134,11 @@ def _children(node: nodes.Node, declared: frozenset[str]) -> Iterator[nodes.Node _refuse() yield node.node return + fields = dict(nodes.Node.iter_fields(node)) for field in _FIELDS[kind]: - value = getattr(node, field) + if field not in fields: + _refuse() + value = fields[field] if isinstance(value, nodes.Node): yield value elif type(value) is list: diff --git a/api/services/dify_builder/trace_diagnostics.py b/api/services/dify_builder/trace_diagnostics.py index 618410a7b3a..e08b1e767e6 100644 --- a/api/services/dify_builder/trace_diagnostics.py +++ b/api/services/dify_builder/trace_diagnostics.py @@ -241,14 +241,26 @@ def cognition(method): def correlated(record): instance = self._model_instance - metadata = {"provider": getattr(instance, "provider", None), "model": getattr(instance, "model_name", None)} + try: + provider = instance.provider + except AttributeError: + provider = None + try: + model = instance.model_name + except AttributeError: + model = None + metadata = {"provider": provider, "model": model} if callback is not None: callback(project({**record, **metadata})) with scope(correlated if callback is not None else None, method.__name__.replace("_", "-")): result = method(self, *args, **kwargs) if callback is not None and method.__name__ == "build_nodes": - generation(getattr(result, "diagnostics", ())) + try: + records = result.diagnostics + except AttributeError: + records = () + generation(records) return result return wrapped diff --git a/api/services/workflow_runtime/agent_node.py b/api/services/workflow_runtime/agent_node.py index 377f7279dc0..d59ae990b4b 100644 --- a/api/services/workflow_runtime/agent_node.py +++ b/api/services/workflow_runtime/agent_node.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging from collections.abc import Generator, Mapping, Sequence -from types import SimpleNamespace from typing import TYPE_CHECKING, Any, cast, override from dify_agent.protocol import CancelRunRequest @@ -63,7 +62,6 @@ if TYPE_CHECKING: from services.agent.workflow_run_recovery import AgentCreateRecoveryClient, WorkflowAgentRunRecovery from services.dify_builder.native_testing.agent_preparation import AgentPreparationService from services.dify_builder.native_testing.confirmed_agent_commands import ConfirmedAgentCommands - from services.dify_builder.native_testing.runtime_bridge import NativeNodePreparation logger = logging.getLogger(__name__) @@ -630,7 +628,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): raise BuilderExecutionPolicyError("native_agent_preparation_unavailable") definition = builder.load_definition(context=context, node_id=self._node_id) typed_inputs = builder.typed_inputs( - association=cast("NativeNodePreparation", SimpleNamespace(runtime_state=self.runtime_state)), + runtime_state=self.runtime_state, definition=definition, ) input_ref = self._agent_preparation.state_store.store_node_inputs( diff --git a/api/services/workflow_runtime/node_factory.py b/api/services/workflow_runtime/node_factory.py index 4c85510d467..8de4f3d63ab 100644 --- a/api/services/workflow_runtime/node_factory.py +++ b/api/services/workflow_runtime/node_factory.py @@ -956,7 +956,15 @@ class DifyNodeFactory(NodeFactory): adapt_node_config_for_graph(raw_node_config), resolve_node_class=self._resolve_node_class, validate_resolved_node_data=self._validate_resolved_node_data, - pre_validate_node_data=validator, + # Compatibility adaptation can move wrapped Tool configurations into + # parameters. Bind the saved raw configuration to the actual resolved + # implementation, rather than comparing that adapted representation + # with the raw digest admitted before graph initialization. + pre_validate_node_data=( + (lambda _adapted_config, resolved_class: validator(raw_node_config, resolved_class)) + if validator is not None + else None + ), ) if isinstance(context, BuilderExecutionContext): validate_restricted_node( diff --git a/api/tests/fixtures/workflow/builder_restricted/gmail_confirmed_read.yml b/api/tests/fixtures/workflow/builder_restricted/gmail_confirmed_read.yml index 3137b8793e1..438cff85fdc 100644 --- a/api/tests/fixtures/workflow/builder_restricted/gmail_confirmed_read.yml +++ b/api/tests/fixtures/workflow/builder_restricted/gmail_confirmed_read.yml @@ -2,48 +2,48 @@ # The CI module replaces the credential placeholder with its seeded workspace # credential row ID; nothing here names a real mailbox, credential or artifact. nodes: -- id: start - data: - type: start - title: Start - variables: - - variable: path - label: Path - type: text-input - required: false -- id: gmail - data: - type: tool - title: Get message - provider_id: langgenius/dify-gmail/dify-gmail - provider_type: builtin - provider_name: langgenius/dify-gmail/dify-gmail - tool_name: get_message - tool_label: Get message - tool_configurations: {} - credential_id: ci-credential-placeholder - tool_node_version: '2' - tool_parameters: - message_id: - type: constant - value: 18cab_1 - include_body: - type: constant - value: false - include_attachments: - type: constant - value: false -- id: end - data: - type: end - title: End - outputs: - - variable: message - value_selector: - - gmail - - json + - id: start + data: + type: start + title: Start + variables: + - variable: path + label: Path + type: text-input + required: false + - id: gmail + data: + type: tool + title: Get message + provider_id: langgenius/dify-gmail/dify-gmail + provider_type: builtin + provider_name: langgenius/dify-gmail/dify-gmail + tool_name: get_message + tool_label: Get message + tool_configurations: {} + credential_id: ci-credential-placeholder + tool_node_version: '2' + tool_parameters: + message_id: + type: constant + value: 18cab_1 + include_body: + type: constant + value: false + include_attachments: + type: constant + value: false + - id: end + data: + type: end + title: End + outputs: + - variable: message + value_selector: + - gmail + - json edges: -- source: start - target: gmail -- source: gmail - target: end + - source: start + target: gmail + - source: gmail + target: end diff --git a/api/tests/fixtures/workflow/builder_restricted/http_default_output.yml b/api/tests/fixtures/workflow/builder_restricted/http_default_output.yml index 1df33bb7b23..90e9a314765 100644 --- a/api/tests/fixtures/workflow/builder_restricted/http_default_output.yml +++ b/api/tests/fixtures/workflow/builder_restricted/http_default_output.yml @@ -1,44 +1,44 @@ nodes: -- id: start - data: - type: start - variables: - - variable: path - label: Path - type: text-input -- id: http - data: - type: http-request - method: GET - url: https://sample.invalid/{{#start.path#}} - authorization: - type: no-auth - body: - type: none - headers: '' - params: '' - error_strategy: default-value - default_value: - - key: status_code - type: number - value: 599 - - key: body - type: string - value: denied default -- id: end - data: - type: end - outputs: - - variable: status - value_selector: - - http - - status_code - - variable: text - value_selector: - - http - - body + - id: start + data: + type: start + variables: + - variable: path + label: Path + type: text-input + - id: http + data: + type: http-request + method: GET + url: https://sample.invalid/{{#start.path#}} + authorization: + type: no-auth + body: + type: none + headers: '' + params: '' + error_strategy: default-value + default_value: + - key: status_code + type: number + value: 599 + - key: body + type: string + value: denied default + - id: end + data: + type: end + outputs: + - variable: status + value_selector: + - http + - status_code + - variable: text + value_selector: + - http + - body edges: -- source: start - target: http -- source: http - target: end + - source: start + target: http + - source: http + target: end diff --git a/api/tests/fixtures/workflow/builder_restricted/http_text.yml b/api/tests/fixtures/workflow/builder_restricted/http_text.yml index d936ae599a5..99c22501fe7 100644 --- a/api/tests/fixtures/workflow/builder_restricted/http_text.yml +++ b/api/tests/fixtures/workflow/builder_restricted/http_text.yml @@ -1,36 +1,36 @@ nodes: -- id: start - data: - type: start - variables: - - variable: path - label: Path - type: text-input -- id: http - data: - type: http-request - method: GET - url: https://sample.invalid/{{#start.path#}} - authorization: - type: no-auth - body: - type: none - headers: '' - params: '' -- id: end - data: - type: end - outputs: - - variable: status - value_selector: - - http - - status_code - - variable: text - value_selector: - - http - - body + - id: start + data: + type: start + variables: + - variable: path + label: Path + type: text-input + - id: http + data: + type: http-request + method: GET + url: https://sample.invalid/{{#start.path#}} + authorization: + type: no-auth + body: + type: none + headers: '' + params: '' + - id: end + data: + type: end + outputs: + - variable: status + value_selector: + - http + - status_code + - variable: text + value_selector: + - http + - body edges: -- source: start - target: http -- source: http - target: end + - source: start + target: http + - source: http + target: end diff --git a/api/tests/fixtures/workflow/builder_restricted/scalar.yml b/api/tests/fixtures/workflow/builder_restricted/scalar.yml index 7da1d93a69c..542cccf8d8e 100644 --- a/api/tests/fixtures/workflow/builder_restricted/scalar.yml +++ b/api/tests/fixtures/workflow/builder_restricted/scalar.yml @@ -1,19 +1,19 @@ nodes: -- id: start - data: - type: start - variables: - - variable: text - label: Text - type: text-input -- id: end - data: - type: end - outputs: - - variable: out - value_selector: - - start - - text + - id: start + data: + type: start + variables: + - variable: text + label: Text + type: text-input + - id: end + data: + type: end + outputs: + - variable: out + value_selector: + - start + - text edges: -- source: start - target: end + - source: start + target: end diff --git a/api/tests/test_containers_integration_tests/conftest.py b/api/tests/test_containers_integration_tests/conftest.py index 29f2363af34..c2d22bb68c8 100644 --- a/api/tests/test_containers_integration_tests/conftest.py +++ b/api/tests/test_containers_integration_tests/conftest.py @@ -24,7 +24,7 @@ from sqlalchemy import inspect as sa_inspect from sqlalchemy.orm import Session from testcontainers.core.container import DockerContainer from testcontainers.core.network import Network -from testcontainers.core.wait_strategies import LogMessageWaitStrategy +from testcontainers.core.wait_strategies import ExecWaitStrategy, LogMessageWaitStrategy from testcontainers.postgres import PostgresContainer from testcontainers.redis import RedisContainer @@ -108,7 +108,19 @@ class DifyTestContainers: # data in bounded tmpfs to reduce I/O from full-schema TRUNCATE cleanup. tmpfs={"/var/lib/postgresql/data": "rw,size=2g"}, ).with_network(self.network) - self.postgres.waiting_for(_wait_for_log_message("is ready to accept connections", 30)) + # The initialization server logs readiness before shutting down and + # accepts Unix sockets only. A real authenticated TCP query witnesses + # the final server; this avoids racing that temporary server's exit. + self.postgres.waiting_for( + ExecWaitStrategy( + [ + "sh", + "-c", + 'PGPASSWORD="$POSTGRES_PASSWORD" psql --username "$POSTGRES_USER" ' + '--dbname "$POSTGRES_DB" --host 127.0.0.1 -tAc "SELECT 1"', + ] + ).with_startup_timeout(30) + ) self.postgres.start() db_host = self.postgres.get_container_host_ip() db_port = self.postgres.get_exposed_port(5432) diff --git a/api/tests/test_containers_integration_tests/core/workflow/builder_native_fixture_records.py b/api/tests/test_containers_integration_tests/core/workflow/builder_native_fixture_records.py new file mode 100644 index 00000000000..12ec269fb6f --- /dev/null +++ b/api/tests/test_containers_integration_tests/core/workflow/builder_native_fixture_records.py @@ -0,0 +1,185 @@ +"""Typed records for real PostgreSQL native-owner test compositions.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from dify_agent.protocol.execution_binding import ( + CreateExecutionBindingRequest, + CreateExecutionBindingResponse, + DestroyExecutionBindingRequest, + ) + from flask import Flask + from httpx import Request + from shellctl.shared.schemas import NativeScopeSpec + from sqlalchemy.orm import Session, sessionmaker + + from core.dify_builder.models import Actor + from runtime_contracts.builder.native.agent_contracts import AgentParentNodeRef, AgentTestWorkspaceRef + from runtime_contracts.builder.native.contracts import NativeTestContextRef + from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext + from services.dify_builder.native_testing.agent_workspace import AgentTestWorkspaceService + from services.dify_builder.native_testing.api_service import NativeTestApiService + from services.dify_builder.native_testing.authorization import EffectAuthorizationService + from services.dify_builder.native_testing.http_adapter import PreparedHttpBinding + from services.dify_builder.native_testing.http_binding import NativeHttpBindingProducer + from services.dify_builder.native_testing.lifecycle import NativePendingResolutionPort, NativeTestLifecycleService + from services.dify_builder.native_testing.operation_journal import NodeOperationJournal + from services.dify_builder.native_testing.pending_intents import PendingDecisionGuard + from services.dify_builder.native_testing.repository import OwnedNativeTestLoader + from services.dify_builder.native_testing.runtime import DifyNativeRuntimeOwner + from services.dify_builder.native_testing.runtime_bridge import NativeNodePreparation + from services.dify_builder.native_testing.sandbox.repository import NativeCodePhysicalRepository + from services.dify_builder.native_testing.state_store import NativeExecutionStateStore + from tests.test_containers_integration_tests.core.workflow.test_builder_native_code_physical_state import ( + CurrentAcquisition, + ) + from tests.test_containers_integration_tests.core.workflow.test_builder_native_effect_ledger import LedgerCase + + +@dataclass +class PhysicalCodeCase: + case: LedgerCase + repo: NativeCodePhysicalRepository + current: CurrentAcquisition + args: dict[str, object] + + +@dataclass(frozen=True) +class CodeDispatchIdentity: + """Identity consumed by the physical repository's CodeIdentity protocol.""" + + operation_ref: str + scope_id: str + generation: int + + +@dataclass +class ObservationHistoryCase: + factory: sessionmaker[Session] + actor: Actor + store: NativeExecutionStateStore + ref: NativeTestContextRef + shape: str + + +@dataclass +class HttpBindingCase: + ref: NativeTestContextRef + loader: OwnedNativeTestLoader + store: NativeExecutionStateStore + context: ResolvedNativeTestContext + factory: sessionmaker[Session] + actor: Actor + auth: EffectAuthorizationService + descriptor: dict[str, object] + + +@dataclass +class PreparedHttpCase: + producer: NativeHttpBindingProducer + binding: PreparedHttpBinding + journal: NodeOperationJournal + association: NativeNodePreparation + + +@dataclass +class HttpConsentCase: + pending_id: str + scope: str + guard: PendingDecisionGuard + command: dict[str, object] + grant_id: str + + +@dataclass +class CapacityOwnerCase: + factory: sessionmaker[Session] + ledger: LedgerCase + ref: NativeTestContextRef + store: NativeExecutionStateStore + context: ResolvedNativeTestContext + resolution: NativePendingResolutionPort + lifecycle: NativeTestLifecycleService + + +@dataclass +class ChildApprovalCase: + ref: NativeTestContextRef + store: NativeExecutionStateStore + factory: sessionmaker[Session] + context: ResolvedNativeTestContext + lifecycle: NativeTestLifecycleService + ledger: LedgerCase + + +@dataclass +class RuntimeDeliveryCase: + factory: sessionmaker[Session] + actor: Actor + app: Flask + session_id: str + api: NativeTestApiService + owner: DifyNativeRuntimeOwner + published: list[dict[str, object]] + sends: list[Request] + request_id: str + kind: str + + +@dataclass(frozen=True) +class ApprovalLedgerSnapshot: + quota: tuple[int, int, int] + budget: dict[str, int] + grants: list[tuple[int, int, int, int]] + attempts: int + claims: int + + +@dataclass +class AgentAssociationCase: + factory: sessionmaker[Session] + loader: OwnedNativeTestLoader + store: NativeExecutionStateStore + ref: NativeTestContextRef + context: ResolvedNativeTestContext + workspace: AgentTestWorkspaceRef + parent: AgentParentNodeRef + allocation_calls: list[str] + + +@dataclass +class NormalAgentParent: + ref: NativeTestContextRef + context: ResolvedNativeTestContext + parent: AgentParentNodeRef + + +class WorkspaceBackendWitness(Protocol): + lose_ack: bool + fail_before_create: bool + cleanup_failure: bool + after_create: Callable[[], None] | None + + def create_execution_binding_sync( + self, request: CreateExecutionBindingRequest + ) -> CreateExecutionBindingResponse: ... + + def destroy_execution_binding_sync(self, request: DestroyExecutionBindingRequest) -> None: ... + + +@dataclass +class AgentWorkspaceCase: + ledger: LedgerCase + service: AgentTestWorkspaceService + ref: NativeTestContextRef + snapshot_id: str + backend: WorkspaceBackendWitness + requests: list[CreateExecutionBindingRequest] + scripts: list[str] + scopes: dict[str, NativeScopeSpec] + cleanups: list[DestroyExecutionBindingRequest] + context: ResolvedNativeTestContext diff --git a/api/tests/test_containers_integration_tests/core/workflow/builder_native_runtime_diagnostics.py b/api/tests/test_containers_integration_tests/core/workflow/builder_native_runtime_diagnostics.py index 5763938e423..09b7144f2be 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/builder_native_runtime_diagnostics.py +++ b/api/tests/test_containers_integration_tests/core/workflow/builder_native_runtime_diagnostics.py @@ -113,7 +113,14 @@ def native_runtime_resume_diagnostics(request, monkeypatch, record_property): # Diagnostic failure cannot suppress/replace the real operation/error. note(stage + ".diagnostic_unavailable", **facts) - ordinary_verbs = tuple(getattr(ssrf_proxy, name) for name in ("get", "post", "put", "patch", "delete", "head")) + ordinary_verbs = ( + ssrf_proxy.get, + ssrf_proxy.post, + ssrf_proxy.put, + ssrf_proxy.patch, + ssrf_proxy.delete, + ssrf_proxy.head, + ) original_create = DifyNodeFactory.create_node @wraps(original_create) @@ -129,10 +136,8 @@ def native_runtime_resume_diagnostics(request, monkeypatch, record_property): required=factory._dify_context.native_test_required, has_ref=factory._dify_context.native_test_ref is not None, native_runtime=type(node.runtime_state) is NativeGraphRuntimeState, - boundary_owner=( - bridge is not None and getattr(node, "_native_control_owner", None) is bridge._boundary_owner - ), - deferred_http=type(getattr(node, "_http_client", None)) is DeferredNativeHttpClient, + boundary_owner=(bridge is not None and node._native_control_owner is bridge._boundary_owner), + deferred_http=hasattr(node, "_http_client") and type(node._http_client) is DeferredNativeHttpClient, ) except Exception: failure("factory_node") @@ -227,8 +232,7 @@ def native_runtime_resume_diagnostics(request, monkeypatch, record_property): monkeypatch.setattr(StopAwareReadyQueue, "_should_reject", reject) - def wrap(owner, name, stage, *, identity=False, decision=False): - original = getattr(owner, name) + def wrap(owner, name, original, stage, *, identity=False, decision=False): @wraps(original) def observed(instance, *args, **kwargs): @@ -256,12 +260,21 @@ def native_runtime_resume_diagnostics(request, monkeypatch, record_property): monkeypatch.setattr(owner, name, observed) - wrap(NativeHttpBindingProducer, "begin", "producer") - wrap(NativeCallAdapter, "call", "scalar", identity=True) - wrap(EffectAuthorizationService, "before_call", "authorization", identity=True, decision=True) - wrap(EffectAuthorizationService, "claim_dispatch", "claim", identity=True) - wrap(NativeHttpClient, "_send_and_buffer", "http_transport") - wrap(httpx.Client, "send", "fixture_send") + wrap(NativeHttpBindingProducer, "begin", NativeHttpBindingProducer.begin, "producer") + wrap(NativeCallAdapter, "call", NativeCallAdapter.call, "scalar", identity=True) + wrap( + EffectAuthorizationService, + "before_call", + EffectAuthorizationService.before_call, + "authorization", + identity=True, + decision=True, + ) + wrap( + EffectAuthorizationService, "claim_dispatch", EffectAuthorizationService.claim_dispatch, "claim", identity=True + ) + wrap(NativeHttpClient, "_send_and_buffer", NativeHttpClient._send_and_buffer, "http_transport") + wrap(httpx.Client, "send", httpx.Client.send, "fixture_send") try: yield finally: @@ -274,6 +287,8 @@ def native_runtime_resume_diagnostics(request, monkeypatch, record_property): "records": list(records), "dropped": dropped, "fixture_send_count": len(case.sends), - "ordinary_verb_call_counts": [getattr(verb, "call_count", None) for verb in ordinary_verbs], + "ordinary_verb_call_counts": [ + verb.call_count if hasattr(verb, "call_count") else None for verb in ordinary_verbs + ], } record_property("native_resume_diagnostics", json.dumps(receipt, sort_keys=True, separators=(",", ":"))) diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_agent_create_recovery.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_agent_create_recovery.py index d0593007e42..7c04e364978 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_agent_create_recovery.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_agent_create_recovery.py @@ -34,8 +34,13 @@ def test_postgres_concurrent_reservations_allow_exactly_one_create(flask_app_wit binding_id=str(uuid4()), process_data={"workflow_agent_binding_id": scope.workflow_binding_id}, ) - for name in ("tenant_id", "app_id", "workflow_id", "node_id"): - setattr(row, name, getattr(scope, name)) + for name, value in ( + ("tenant_id", scope.tenant_id), + ("app_id", scope.app_id), + ("workflow_id", scope.workflow_id), + ("node_id", scope.node_id), + ): + setattr(row, name, value) row.created_by = str(uuid4()) row.node_execution_id = str(uuid4()) with factory.begin() as session: diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_agent_workspace.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_agent_workspace.py index 04899a8a903..adcebcf2692 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_agent_workspace.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_agent_workspace.py @@ -8,7 +8,6 @@ import json from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from threading import Event, get_ident -from types import SimpleNamespace from uuid import uuid4 import pytest @@ -32,6 +31,7 @@ from services.agent.workspace_service import AgentWorkspaceService from services.dify_builder.native_testing.agent_workspace import AgentTestWorkspaceService from services.dify_builder.native_testing.repository import OwnedNativeTestLoader from services.dify_builder.native_testing.state_store import NativeExecutionStateStore +from tests.test_containers_integration_tests.core.workflow.builder_native_fixture_records import AgentWorkspaceCase from tests.test_containers_integration_tests.core.workflow.test_builder_native_continuation import ( migrated_continuation_tables as migrated_continuation_tables, # noqa: PLC0414 - shared pytest fixture ) @@ -206,7 +206,7 @@ def workspace_case(native_owner, no_live, monkeypatch): yield backend monkeypatch.setattr(AgentWorkspaceService, "_client", client) - yield SimpleNamespace( + yield AgentWorkspaceCase( ledger=c, service=service, ref=c.ref, diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_confirmed_tool_native.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_confirmed_tool_native.py index 547241c232a..ac9eb0dcec4 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_confirmed_tool_native.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_confirmed_tool_native.py @@ -1725,9 +1725,8 @@ def test_ordinary_native_gmail_workflow_stays_ordinary(gmail: Gmail) -> None: if payload: native_id = native_id or run_id_from_stream_chunk(payload) finally: - close = getattr(response, "close", None) - if callable(close): - close() + if hasattr(response, "close"): + response.close() runs = gmail.native_runs() assert [run.id for run in runs] == [native_id] assert runs[0].status == "succeeded" @@ -2801,6 +2800,14 @@ def _submit_and_deliver(gmail: Gmail, deliveries: list[Delivery], kind: str) -> def test_dispatched_recovery_survives_a_paused_old_sweep(gmail: Gmail, deliveries: list[Delivery]) -> None: """Real recovery/Runner gates can admit a fresh grant while an old observation is paused.""" + # Use Fix's verification/decision gates: Build's keep_draft now completes + # its task, so it cannot authorize a fresh test in that completed task. + # The continuation mode is persisted in the same CAS context as production. + _state, version, context = gmail.head() + context.active_entry_mode = "fix" + SqlDifyBuilderRepository(gmail.factory).compare_and_advance( + gmail.session_id, version, PcState.FIX_AWAIT_VERIFY, context, [] + ) stranded = _stranded(gmail, deliveries) old_version = gmail.head()[1] @@ -2824,8 +2831,6 @@ def test_dispatched_recovery_survives_a_paused_old_sweep(gmail: Gmail, deliverie assert (context.confirmed_consent_id, context.confirmed_command_id) == ("", "") assert context.test_input_ref == gmail.test_input_id assert context.staged_repair == [] - _submit_and_deliver(gmail, deliveries, "keep_draft") - gates.append(gmail.head()[0]) fresh.append(gmail.execute(gmail.confirmed("fresh"), key="execute-fresh")) return held @@ -2881,11 +2886,11 @@ def test_dispatched_recovery_survives_a_paused_old_sweep(gmail: Gmail, deliverie } facts = run_in_host(gmail, recover_in_host, name="dispatched-recovery") - assert facts["gates"] == [str(PcState.BUILD_AWAIT_REPAIR), str(PcState.BUILD_REVIEW)] + assert facts["gates"] == [str(PcState.FIX_AWAIT_DECISION)] assert facts["settlements"] == [1] assert facts["old"] == ["failed", "launch_stranded", None, 0, 0] assert facts["fresh"] == ["confirmed", True, None] - assert facts["deliveries"] == ["run_confirmed_tool", "recovery_continue", "keep_draft", "run_confirmed_tool"] + assert facts["deliveries"] == ["run_confirmed_tool", "recovery_continue", "run_confirmed_tool"] assert facts["head_advanced"] is True observed = facts["observed"] assert isinstance(observed, Observed) @@ -3081,7 +3086,11 @@ def console(gmail: Gmail, monkeypatch: pytest.MonkeyPatch) -> Iterator[ConsoleHa # Main's account migration binds this actual identity owner in create_app. The HTTP proof # must use that composition, including its real short repository Sessions, never inject g. identity = application_services().accounts.identity - assert getattr(gmail.app.extensions["account_identity_loader"], "__self__", None) is identity + from types import MethodType + + loader = gmail.app.extensions["account_identity_loader"] + assert isinstance(loader, MethodType) + assert loader.__self__ is identity with gmail.factory.begin() as session: if session.scalar(select(DifySetup.version).limit(1)) is None: session.add(DifySetup(version="ci-authenticated-native")) diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_execution_policy_persistence.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_execution_policy_persistence.py index 58ffa5dfd5a..5af4529c270 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_execution_policy_persistence.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_execution_policy_persistence.py @@ -1,11 +1,12 @@ """CI-only PostgreSQL locking and actual Alembic migration controls.""" import importlib.util -from collections.abc import Callable +from collections.abc import Callable, Generator from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager from pathlib import Path from threading import Barrier -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, TypedDict, cast from uuid import uuid4 import pytest @@ -15,7 +16,13 @@ from alembic.migration import MigrationContext from alembic.operations import Operations from alembic.runtime.migration import RevisionStep from alembic.script import ScriptDirectory -from sqlalchemy import inspect, text +from sqlalchemy import Connection, inspect, text +from sqlalchemy.engine.interfaces import ( + ReflectedCheckConstraint, + ReflectedIndex, + ReflectedPrimaryKeyConstraint, + ReflectedUniqueConstraint, +) from dify_app import DifyApp from models.dify_builder import DifyBuilderExecutionRequest @@ -36,6 +43,15 @@ if TYPE_CHECKING: pass +class _TableWitness(TypedDict): + columns: list[tuple[str, str, bool, str | None]] + primary_key: ReflectedPrimaryKeyConstraint + indexes: list[ReflectedIndex] + unique: list[ReflectedUniqueConstraint] + checks: list[ReflectedCheckConstraint] + rows: list[tuple[object, ...]] + + def prepare( owner: NativeOwner, http: bool = False ) -> tuple[BuilderExecutionPolicyService, BuilderExecutionContext | JsonToolExecutionContext]: @@ -198,12 +214,28 @@ def test_real_postgres_execution_migration_upgrade_downgrade(flask_app_with_cont connection.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) +@contextmanager +def _isolated_migration_schema(connection: Connection, schema: str) -> Generator[None]: + """Retain the selected schema across Alembic's commits and always reset the pool connection.""" + connection.execute(text(f'CREATE SCHEMA "{schema}"')) + connection.execute(text(f'SET search_path TO "{schema}"')) + try: + yield + finally: + connection.rollback() + connection.execute(text("SET search_path TO public")) + connection.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')) + connection.commit() + + def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_containers: "DifyApp") -> None: """Real Alembic traversal upgrades both branches, merges/splits heads without changing their data.""" from extensions.ext_database import db merge_revision = "f9a3c62de418" - current_head = "7d2b9c4e61a8" + current_head = "d41e6b8c2a75" + code_head = "7d2b9c4e61a8" + task_head = "c6f4a90d8b31" dispatch_head = "e59b7d32a6c1" owner_head = "f47a1c90d2e8" continuation_head = "d28f0e3c912a" @@ -215,7 +247,14 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont config.set_main_option("script_location", str(Path(__file__).parents[4] / "migrations")) scripts = ScriptDirectory.from_config(config) assert scripts.get_heads() == [current_head] - code_revision = scripts.get_revision(current_head) + merged_head = scripts.get_revision(current_head) + assert merged_head is not None + assert isinstance(merged_head.down_revision, tuple) + assert set(merged_head.down_revision) == {code_head, task_head} + task_revision = scripts.get_revision(task_head) + assert task_revision is not None + assert task_revision.down_revision == "f1d3c5b7a902" + code_revision = scripts.get_revision(code_head) assert code_revision is not None assert code_revision.down_revision == dispatch_head dispatch_revision = scripts.get_revision(dispatch_head) @@ -240,10 +279,12 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont assert set(merged.down_revision) == branch_heads schema = "builder_forward_merge_" + uuid4().hex - with flask_app_with_containers.app_context(), db.engine.begin() as connection: + with ( + flask_app_with_containers.app_context(), + db.engine.connect() as connection, + _isolated_migration_schema(connection, schema), + ): assert connection.dialect.name == "postgresql" - connection.execute(text(f'CREATE SCHEMA "{schema}"')) - connection.execute(text(f'SET LOCAL search_path TO "{schema}"')) # Only the two preexisting tables touched by descendants of 733 are prerequisites. # Their structures come from the real database; remove the later nullable additions. for table in ("dify_builder_test_inputs", "dify_builder_runs"): @@ -283,9 +324,13 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont ) return planner(target, current) + # Main builds its task index concurrently; let Alembic own each transaction + # and keep the isolated schema in the session search path across commits. + connection.commit() with EnvironmentContext(config, scripts, fn=revisions) as environment: environment.configure(connection=connection, version_table_schema=schema) - environment.run_migrations() + with environment.begin_transaction(): + environment.run_migrations() return MigrationContext.configure(connection, opts={"version_table_schema": schema}).get_current_heads() # Execute real b6/c84/d82 and a8 migrations, not metadata.create_all or handwritten branch DDL. @@ -362,9 +407,12 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont include_continuation: bool = False, include_dispatch_claims: bool = False, include_code_physical: bool = False, - ) -> dict[str, object]: + include_workflow_tasks: bool = False, + ) -> dict[str, _TableWitness]: inspector = inspect(connection) expected_tables = {*tables, "alembic_version"} + if include_workflow_tasks: + expected_tables.add("workflow_runs") if include_archive: expected_tables.add("workflow_run_archive_bundles") if include_native: @@ -418,7 +466,7 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont assert "deletion_status" not in archive_fields archive_projection = ",".join(f'"{field}"' for field in archive_fields) - def archive_witness() -> dict[str, object]: + def archive_witness() -> _TableWitness: inspector = inspect(connection) return { "columns": [ @@ -473,9 +521,9 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont assert after_native == before assert archive_witness() == archive_before - def native_ledger_witness() -> dict[str, object]: + def native_ledger_witness() -> dict[str, _TableWitness]: inspector = inspect(connection) - result = {} + result: dict[str, _TableWitness] = {} for table in sorted(native_tables): primary_key = inspector.get_pk_constraint(table, schema=schema) order = ",".join(f'"{name}"' for name in primary_key["constrained_columns"]) @@ -519,7 +567,20 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont } for table in sorted(continuation_tables) } + connection.execute(text("CREATE TABLE workflow_runs (LIKE public.workflow_runs INCLUDING ALL)")) + connection.execute(text("ALTER TABLE workflow_runs DROP COLUMN task_id")) + connection.execute(text("ALTER TABLE workflow_runs DROP COLUMN stop_requested_at")) + workflow_columns = { + column["name"] for column in inspect(connection).get_columns("workflow_runs", schema=schema) + } assert migrate("head") == (current_head,) + workflow_after = inspect(connection).get_columns("workflow_runs", schema=schema) + assert {column["name"] for column in workflow_after} == {*workflow_columns, "task_id", "stop_requested_at"} + assert all(column["nullable"] for column in workflow_after if column["name"] not in workflow_columns) + assert any( + index["name"] == "workflow_run_task_id_idx" + for index in inspect(connection).get_indexes("workflow_runs", schema=schema) + ) assert ( witness( include_archive=True, @@ -527,6 +588,7 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont include_continuation=True, include_dispatch_claims=True, include_code_physical=True, + include_workflow_tasks=True, ) == before_continuation ) @@ -557,8 +619,6 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont text("SELECT app_id,workflow_id FROM dify_builder_native_invocation_states WHERE id=:id"), {"id": identity}, ).one() == (None, None) - connection.execute(text("SET LOCAL search_path TO public")) - connection.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) @pytest.mark.parametrize( diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_json_tool_native.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_json_tool_native.py index 8fba1f74f5d..5214c5b6c38 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_json_tool_native.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_json_tool_native.py @@ -96,7 +96,10 @@ def test_json_caller_explicit_read_session_closes_before_native_spawn( return original_generate(cls, *args, **kwargs) def capture_spawn(thread: threading.Thread) -> None: - if getattr(getattr(thread, "_target", None), "__name__", None) == "_generate_worker": + from types import FunctionType, MethodType + + target = thread._target + if isinstance(target, (FunctionType, MethodType)) and target.__name__ == "_generate_worker": assert caller_session is not None at_spawn.append(caller_session.in_transaction()) return original_start(thread) diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_agent_associations.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_agent_associations.py index 60a1f81da51..c1444f29d1f 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_agent_associations.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_agent_associations.py @@ -7,15 +7,15 @@ cleanup, actual Agent execution or whole-chain qualification. import json from contextlib import contextmanager -from types import SimpleNamespace -from unittest.mock import Mock from uuid import uuid4 import pytest +from dify_agent.client import Client from dify_agent.protocol.execution_binding import CreateExecutionBindingResponse from shellctl.shared.schemas import NativeScopeLimits from sqlalchemy import select +from clients.agent_backend import DifyAgentBackendRunClient from libs.key_providers import generate_key_pair from models.account import Tenant from models.agent import ( @@ -56,6 +56,10 @@ from services.dify_builder.native_testing.repository import ( native_workflow_revision, ) from services.dify_builder.native_testing.state_store import NativeExecutionStateStore +from tests.test_containers_integration_tests.core.workflow.builder_native_fixture_records import ( + AgentAssociationCase, + NormalAgentParent, +) from tests.test_containers_integration_tests.core.workflow.test_builder_agent_workspace import ( native_owner as native_owner, # noqa: PLC0414 ) @@ -79,11 +83,11 @@ CANARY = "PRIVATE_A5_PROTECTED_BYTES_CANARY_81ac" @pytest.fixture -def association_case(native_owner, no_live, migrated_continuation_tables, monkeypatch, request): +def association_case(mocker, native_owner, no_live, migrated_continuation_tables, monkeypatch, request): from extensions.ext_database import db assert migrated_continuation_tables is None - legacy_confirmed = getattr(request, "param", None) == "legacy_confirmed" + legacy_confirmed = hasattr(request, "param") and request.param == "legacy_confirmed" # The workspace fixture supplies only allocation identities. S1 resource # checks require real roster publication and complete native node data. # Establish these rows before raw_ledger_case stamps graph/owner revisions. @@ -198,7 +202,9 @@ def association_case(native_owner, no_live, migrated_continuation_tables, monkey @contextmanager def backend(): - yield SimpleNamespace(create_execution_binding_sync=create) + client = Client(base_url="http://127.0.0.1:1") + mocker.patch.object(client, "create_execution_binding_sync", side_effect=create) + yield client monkeypatch.setattr(AgentWorkspaceService, "_client", backend) service = AgentTestWorkspaceService( @@ -222,7 +228,7 @@ def association_case(native_owner, no_live, migrated_continuation_tables, monkey parent = AgentParentNodeRef( ref=ref, execution_id=execution_id, input_result_ref=input_ref, input_digest=canonical_digest(typed_inputs) ) - return SimpleNamespace( + return AgentAssociationCase( factory=ledger.factory, loader=loader, store=store, @@ -334,14 +340,13 @@ def test_real_encrypted_association_checkpoint_roundtrip_preserves_nonempty_priv @pytest.mark.parametrize("fault", ["changed-run", "foreign-checkpoint"]) -def test_real_immutable_agent_selection_refuses_without_new_private_write(association_case, monkeypatch, fault): +def test_real_immutable_agent_selection_refuses_without_new_private_write(mocker, association_case, fault): from extensions.ext_storage import storage c = association_case association, args = waiting(c) wait = c.store.store_agent_wait(**args) - save = Mock(side_effect=AssertionError("unexpected new encrypted write")) - monkeypatch.setattr(storage, "save", save) + save = mocker.patch.object(storage, "save", side_effect=AssertionError("unexpected new encrypted write")) def refuse(): if fault == "changed-run": @@ -374,7 +379,7 @@ def test_real_current_workspace_retirement_blocks_selected_checkpoint_read(assoc c.store.read_checkpoint(ref=c.ref, checkpoint=cp) -def test_real_post_commit_ack_loss_reuses_private_object_without_rewrite(association_case, monkeypatch): +def test_real_post_commit_ack_loss_reuses_private_object_without_rewrite(mocker, association_case, monkeypatch): from extensions.ext_storage import storage c = association_case @@ -393,8 +398,7 @@ def test_real_post_commit_ack_loss_reuses_private_object_without_rewrite(associa monkeypatch.setattr(c.loader, "locked", commit_then_lose) with pytest.raises(BuilderExecutionPolicyError, match="native_agent_state_unavailable"): c.store.store_agent_wait(**args) - save = Mock(wraps=storage.save) - monkeypatch.setattr(storage, "save", save) + save = mocker.spy(storage, "save") recovered = c.store.store_agent_wait(**args) save.assert_not_called() assert ( @@ -402,7 +406,7 @@ def test_real_post_commit_ack_loss_reuses_private_object_without_rewrite(associa ) -def test_real_second_node_occurrence_cannot_share_temporary_single_workspace(association_case, monkeypatch): +def test_real_second_node_occurrence_cannot_share_temporary_single_workspace(mocker, association_case): from extensions.ext_storage import storage c = association_case @@ -413,8 +417,9 @@ def test_real_second_node_occurrence_cannot_share_temporary_single_workspace(ass parent = AgentParentNodeRef( ref=c.ref, execution_id=second, input_result_ref=input_ref, input_digest=canonical_digest(inputs) ) - save = Mock(side_effect=AssertionError("second occurrence wrote new private Agent state")) - monkeypatch.setattr(storage, "save", save) + save = mocker.patch.object( + storage, "save", side_effect=AssertionError("second occurrence wrote new private Agent state") + ) with pytest.raises(BuilderExecutionPolicyError, match="native_agent_single_workspace_limit"): c.store.store_agent_config( ref=c.ref, parent_node=parent, expected_invocation_generation=0, config={"second": True} @@ -477,7 +482,7 @@ def test_real_queued_agent_checkpoint_is_readable_before_single_dispatch_claim(a @pytest.mark.parametrize("guard_change", ["none", "canvas", "execution", "input", "actor", "target"]) def test_real_normal_blank_home_prepare_and_generation_one_checkpoint_read( - association_case, native_owner, monkeypatch, guard_change + mocker, association_case, native_owner, monkeypatch, guard_change ): """Actual normal SQL/crypto owners; only backend allocation is a no-I/O fixture.""" from core.workflow.nodes.agent_v2.session_store import WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore @@ -564,7 +569,9 @@ def test_real_normal_blank_home_prepare_and_generation_one_checkpoint_read( binding_ref="ordinary:" + request.binding_id, workspace_ref="ordinary:" + request.workspace_id ) - yield SimpleNamespace(create_execution_binding_sync=create) + client = Client(base_url="http://127.0.0.1:1") + mocker.patch.object(client, "create_execution_binding_sync", side_effect=create) + yield client monkeypatch.setattr(AgentWorkspaceService, "_client", normal_backend) scope = WorkflowAgentSessionScope( @@ -579,7 +586,9 @@ def test_real_normal_blank_home_prepare_and_generation_one_checkpoint_read( agent_config_snapshot_id=snapshot_id, ) stored = WorkflowAgentWorkspaceStore().load_or_create_node_execution_session(scope, home_snapshot_id=None) - service = build_confirmed_agent_preparation(actor=actor, backend=SimpleNamespace()) + service = build_confirmed_agent_preparation( + actor=actor, backend=DifyAgentBackendRunClient(Client(base_url="http://127.0.0.1:1")) + ) store, ref = service.state_store, context.ref inputs = {"typed": [True, 1, "1"]} input_ref = store.store_node_inputs(ref=ref, generation=0, native_execution_id=execution_id, inputs=inputs) @@ -633,7 +642,7 @@ def test_real_normal_blank_home_prepare_and_generation_one_checkpoint_read( checkpoint=checkpoint, backend_wait_event_id="1234-0", ) - normal = SimpleNamespace(ref=ref, context=context, parent=parent) + normal = NormalAgentParent(ref=ref, context=context, parent=parent) saved = store.commit_checkpoint(ref=ref, generation=0, payload=payload(normal, wait)) with factory.begin() as session: state = session.scalar(select(NativeInvocationState).where(NativeInvocationState.request_id == ref.request_id)) diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_approval_scope.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_approval_scope.py index 261325d35bb..ec6b1ef46f7 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_approval_scope.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_approval_scope.py @@ -9,7 +9,6 @@ No network/provider transport or Graphon activation is involved. from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime, timedelta -from types import SimpleNamespace from uuid import uuid4 import pytest @@ -38,6 +37,7 @@ from services.dify_builder.native_testing.authorization import EffectAuthorizati from services.dify_builder.native_testing.pending_intents import PendingDecisionGuard, ProtectedPendingIntentReader from services.dify_builder.native_testing.public_contracts import NativeTestPublicReceipt from services.dify_builder.native_testing.repository import NativeBinding, OwnedNativeTestLoader +from tests.test_containers_integration_tests.core.workflow.builder_native_fixture_records import ApprovalLedgerSnapshot from tests.test_containers_integration_tests.core.workflow.test_builder_native_continuation import ( continuation_case as continuation_case, # noqa: PLC0414 - unchanged encrypted owner fixture ) @@ -245,7 +245,7 @@ def _counts(c): ) for model in (NativeEffectAttempt, NativeEffectDispatchClaim) } - return SimpleNamespace( + return ApprovalLedgerSnapshot( quota=(scope.spent_calls, scope.spent_dispatches, scope.spent_cost_units), budget=dict(scope.budget_spent), grants=sorted((g.max_calls, g.max_dispatches, g.max_cost_units, g.spent_calls) for g in grants), diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_claimed_resume_checkpoint.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_claimed_resume_checkpoint.py index 5d7b2ed72ae..936c7c7e2b8 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_claimed_resume_checkpoint.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_claimed_resume_checkpoint.py @@ -8,11 +8,11 @@ immutable continuation/payload owner rows are never weakened to construct them. from copy import deepcopy from datetime import UTC, datetime, timedelta from operator import itemgetter -from types import SimpleNamespace from uuid import uuid4 import pytest from sqlalchemy import inspect, select +from sqlalchemy import inspect as inspect_orm from models.dify_builder import DifyBuilderExecutionRequest from models.dify_builder_native_tests import ( @@ -26,6 +26,7 @@ from models.dify_builder_native_tests import ( from runtime_contracts.builder.execution import BuilderExecutionPolicyError from services.dify_builder.native_testing.operation_journal import NodeOperationJournal from services.dify_builder.native_testing.pending_actions import NativePendingActionService +from services.dify_builder.native_testing.runtime import DifyNativeRuntimeOwner from tests.test_containers_integration_tests.core.workflow.test_builder_native_continuation import ( _root_with_child_dispatch, ) @@ -75,7 +76,9 @@ def _snapshot(c): rows = session.scalars(select(model).where(model.request_id == c.ref.request_id)).all() result[model.__name__] = sorted( [ - deepcopy({column.key: getattr(row, column.key) for column in inspect(model).column_attrs}) + deepcopy( + {column.key: inspect_orm(row).attrs[column.key].value for column in inspect(model).column_attrs} + ) for row in rows ], key=itemgetter("request_id" if model is NativeTestScope else "id"), @@ -112,14 +115,16 @@ def test_actual_claimed_checkpoint_ref_is_acknowledged_and_metadata_only(checkpo assert saved.payload_ref == obj.storage_key -def test_explicit_recovery_reclaims_same_dispatch_and_keeps_authentic_checkpoint(checkpoint_case): +def test_explicit_recovery_reclaims_same_dispatch_and_keeps_authentic_checkpoint(mocker, checkpoint_case): c = checkpoint_case checkpoint, dispatch = _claim(c) with c.factory.begin() as session: session.get(NativeDispatch, dispatch.dispatch_id).claim_expires_at = datetime.now(UTC) - timedelta(seconds=1) c.lifecycle.reconcile() # Existing trusted recovery port double; no physical lifetime qualification. - c.lifecycle.runtime = SimpleNamespace(recovery_evidence=lambda **_: "stopped") + runtime = DifyNativeRuntimeOwner() + mocker.patch.object(runtime, "recovery_evidence", return_value="stopped") + c.lifecycle.runtime = runtime c.lifecycle.recover_dispatch(ref=c.ref, dispatch_id=dispatch.dispatch_id) assert c.lifecycle.claim_dispatch(ref=c.ref, generation=dispatch.generation, dispatch_id=dispatch.dispatch_id) assert ( diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_code_host.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_code_host.py index 1dd8101c3ef..f32257da121 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_code_host.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_code_host.py @@ -26,13 +26,20 @@ def test_real_gevent_cancelled_scope_call_keeps_cleanup_owned( def _launch(session, mode): import json + import os import subprocess import sys from pathlib import Path + # Executing this file directly gives Python its fixture directory rather + # than the API import root. Preserve any caller paths after the API root. + api_root = str(Path(__file__).resolve().parents[4]) + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join(filter(None, (api_root, env.get("PYTHONPATH")))) url = session.get_bind().url.render_as_string(hide_password=False) result = subprocess.run( [sys.executable, str(Path(__file__).resolve()), mode], + env=env, input=json.dumps({"database_url": url}), text=True, capture_output=True, diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_code_physical_state.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_code_physical_state.py index 2998e2dbd1e..fe9689a813f 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_code_physical_state.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_code_physical_state.py @@ -10,7 +10,6 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from threading import Barrier from time import monotonic -from types import SimpleNamespace from uuid import uuid4 import pytest @@ -20,6 +19,7 @@ from alembic.operations import Operations from alembic.script import ScriptDirectory from shellctl.shared.schemas import NativeScopeLimits, NativeScopeSpec from sqlalchemy import func, insert, select, text +from sqlalchemy import inspect as inspect_orm from sqlalchemy.exc import DBAPIError, IntegrityError from models.dify_builder_native_code import NativeCodePhysicalOperation @@ -47,6 +47,10 @@ from services.dify_builder.native_testing.sandbox.contracts import ( from services.dify_builder.native_testing.sandbox.repository import ( NativeCodePhysicalRepository, ) +from tests.test_containers_integration_tests.core.workflow.builder_native_fixture_records import ( + CodeDispatchIdentity, + PhysicalCodeCase, +) from tests.test_containers_integration_tests.core.workflow.test_builder_native_dispatch_claims import ( migrated_dispatch_claims as migrated_dispatch_claims, # noqa: PLC0414 - pytest fixture ) @@ -238,7 +242,7 @@ def code_case(ledger_case, physical_migration): "profile_digest": "d" * 64, "physical_deadline": now + timedelta(seconds=15), } - return SimpleNamespace(case=case, repo=repo, current=current, args=args) + return PhysicalCodeCase(case=case, repo=repo, current=current, args=args) def count_rows(t): @@ -275,7 +279,7 @@ def test_job_association_and_input_fence_are_visible_before_return(code_case): p = t.repo.reserve_scope(**t.args) t.repo.ack_scope(physical=p, scope_id=p.scope_id, generation=1) bound = t.repo.bind(physical=p, intent=t.args["intent"], permit=t.args["permit"]) - identity = SimpleNamespace(operation_ref=p.operation_id, scope_id=p.scope_id, generation=1) + identity = CodeDispatchIdentity(operation_ref=p.operation_id, scope_id=p.scope_id, generation=1) bound.reserve_dispatch(identity, payload_digest=p.payload_digest, profile_digest=p.profile_digest) bound.associate_job(identity, "job_1") with t.case.factory() as session: @@ -447,7 +451,7 @@ def test_foreign_pk_is_enforced_by_postgresql(code_case): # fresh scope and all unique owner references exercises the actual FK. with t.case.factory() as session: row = session.get(NativeCodePhysicalOperation, p.id) - values = {column.name: getattr(row, column.name) for column in row.__table__.columns} + values = {column.name: inspect_orm(row).attrs[column.name].value for column in row.__table__.columns} for name in ( "id", "operation_id", @@ -497,7 +501,7 @@ def test_individually_valid_foreign_keys_do_not_prove_same_owner_chain(code_case p = t.repo.reserve_scope(**t.args) with t.case.factory.begin() as session: original = session.get(NativeCodePhysicalOperation, p.id) - physical_values = {c.name: getattr(original, c.name) for c in original.__table__.columns} + physical_values = {c.name: inspect_orm(original).attrs[c.name].value for c in original.__table__.columns} alternatives = [] for _ in range(2): operation_id, attempt_id, claim_id = (str(uuid4()) for _ in range(3)) @@ -529,7 +533,9 @@ def test_individually_valid_foreign_keys_do_not_prove_same_owner_chain(code_case ), ): original_owner = session.get(model, source_id) - values = {c.name: getattr(original_owner, c.name) for c in original_owner.__table__.columns} + values = { + c.name: inspect_orm(original_owner).attrs[c.name].value for c in original_owner.__table__.columns + } session.execute(insert(model).values(**(values | changes))) alternatives.append((operation_id, attempt_id, claim_id, operation_digest_value)) second, third = alternatives diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_console_api.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_console_api.py index ab22e4d6f34..3fbebe0f759 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_console_api.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_console_api.py @@ -6,6 +6,7 @@ These exercise the API boundary, not a qualified Graphon execution. from datetime import UTC, datetime import pytest +from sqlalchemy import inspect as inspect_orm from sqlalchemy import select from constants import COOKIE_NAME_CSRF_TOKEN, HEADER_NAME_CSRF_TOKEN @@ -13,6 +14,9 @@ from libs.passport import PassportService from libs.token import _real_cookie_name, generate_csrf_token from models.account import Account from models.model import DifySetup +from services.dify_builder.native_testing.http_binding import NativeHttpDisclosure +from services.dify_builder.native_testing.runtime import DifyNativeRuntimeOwner +from services.entities.feature_entities import FeatureModel from tests.test_containers_integration_tests.core.workflow.test_builder_native_continuation import ( continuation_case as continuation_case, # noqa: PLC0414 ) @@ -46,10 +50,9 @@ def console_case(flask_app_with_containers, continuation_case, monkeypatch): session.add(DifySetup(version="native-console-ci")) # Login, CSRF and SQL ownership use real owners. Entitlement and capability # metadata are synthetic; RBAC tests stub the external access RPC explicitly. - from types import SimpleNamespace monkeypatch.setattr( - controller.FeatureService, "get_features", lambda _tenant: SimpleNamespace(dify_builder_enabled=True) + controller.FeatureService, "get_features", lambda _tenant: FeatureModel(dify_builder_enabled=True) ) wraps._is_setup_completed.reset_success() db.session.close() @@ -309,8 +312,7 @@ def test_cross_session_request_is_not_visible(pending_console): @pytest.mark.parametrize("qualified_disclosure", [True, False]) -def test_old_checkpoint_without_full_intent_fails_closed(console_case, monkeypatch, qualified_disclosure): - from types import SimpleNamespace +def test_old_checkpoint_without_full_intent_fails_closed(mocker, console_case, monkeypatch, qualified_disclosure): from controllers.console import dify_builder as controller from runtime_contracts.builder.execution import BuilderExecutionPolicyError @@ -323,6 +325,8 @@ def test_old_checkpoint_without_full_intent_fails_closed(console_case, monkeypat pytest.fail("missing full intent must fail before disclosure") wait = c.pause() + disclosure = NativeHttpDisclosure(loader=c.store.loader, state_store=c.store) + mocker.patch.object(disclosure, "describe", side_effect=never_disclose) service = NativeTestApiService( session_factory=c.factory, actor=c.ledger.actor, @@ -330,7 +334,7 @@ def test_old_checkpoint_without_full_intent_fails_closed(console_case, monkeypat admission=None, resolution=c.resolution, runtime=None, - disclosure=SimpleNamespace(describe=never_disclose) if qualified_disclosure else None, + disclosure=disclosure if qualified_disclosure else None, ) original_load = service.pending.load @@ -399,11 +403,21 @@ def test_actual_child_permission_revocation_blocks_root_public_receipt(console_c c = console_case with c.factory.begin() as session: original = session.get(App, c.context.app_id) - app = App(**{col.key: getattr(original, col.key) for col in inspect(App).column_attrs if col.key != "id"}) + app = App( + **{ + col.key: inspect_orm(original).attrs[col.key].value + for col in inspect(App).column_attrs + if col.key != "id" + } + ) app.id, app.maintainer = str(uuid4()), str(uuid4()) source = session.get(Workflow, c.context.workflow_id) workflow = Workflow( - **{col.key: getattr(source, col.key) for col in inspect(Workflow).column_attrs if col.key != "id"} + **{ + col.key: inspect_orm(source).attrs[col.key].value + for col in inspect(Workflow).column_attrs + if col.key != "id" + } ) workflow.id, workflow.app_id = str(uuid4()), app.id app.workflow_id = workflow.id @@ -623,8 +637,7 @@ def test_cancel_without_cleanup_ack_reports_cancelling(pending_console): assert receipt["status"] == "running" -def test_cleanup_ack_cannot_erase_unknown_dispatched_effect(pending_console): - from types import SimpleNamespace +def test_cleanup_ack_cannot_erase_unknown_dispatched_effect(mocker, pending_console): from runtime_contracts.builder.native.contracts import DispatchPermit @@ -633,7 +646,9 @@ def test_cleanup_ack_cannot_erase_unknown_dispatched_effect(pending_console): _claim_approved_generation(c) permit = c.ledger.guard.before_call(ref=c.ref, intent=c.ledger.intent) assert isinstance(permit, DispatchPermit) - c.api.lifecycle.runtime = SimpleNamespace(cancel=lambda *, refs: tuple(ref.invocation_id for ref in refs)) + runtime = DifyNativeRuntimeOwner() + mocker.patch.object(runtime, "cancel", side_effect=lambda *, refs: tuple(ref.invocation_id for ref in refs)) + c.api.lifecycle.runtime = runtime response = c.client.post( c.url + f"/{c.ref.request_id}/cancel", json={"schema_version": "builder-native-test-v1", "idempotency_key": "cancel-1"}, @@ -748,11 +763,21 @@ def test_final_child_authority_refresh_precedes_decision_write(console_case, mon c = console_case with c.factory.begin() as session: original = session.get(App, c.context.app_id) - child_app = App(**{col.key: getattr(original, col.key) for col in inspect(App).column_attrs if col.key != "id"}) + child_app = App( + **{ + col.key: inspect_orm(original).attrs[col.key].value + for col in inspect(App).column_attrs + if col.key != "id" + } + ) child_app.id, child_app.maintainer = str(uuid4()), str(uuid4()) source = session.get(Workflow, c.context.workflow_id) child_workflow = Workflow( - **{col.key: getattr(source, col.key) for col in inspect(Workflow).column_attrs if col.key != "id"} + **{ + col.key: inspect_orm(source).attrs[col.key].value + for col in inspect(Workflow).column_attrs + if col.key != "id" + } ) child_workflow.id, child_workflow.app_id = str(uuid4()), child_app.id child_app.workflow_id = child_workflow.id @@ -771,7 +796,7 @@ def test_final_child_authority_refresh_precedes_decision_write(console_case, mon def check(_tenant, _actor, *, scene, resource_type, resource_id): assert resource_type is not None checks.append((revoked, resource_id, scene)) - return not (revoked and resource_id == app_id and scene == getattr(RBACPermission, revoked_permission)) + return not (revoked and resource_id == app_id and scene == RBACPermission[revoked_permission]) monkeypatch.setattr(RBACService.CheckAccess, "check", check) # The actual child effect is publicly readable before the final boundary. @@ -789,7 +814,7 @@ def test_final_child_authority_refresh_precedes_decision_write(console_case, mon monkeypatch.setattr(c.api.authorization, "decide", revoke_at_final_boundary) response = c.client.post(c.url + f"/{c.ref.request_id}/decisions", json=c.body) assert response.status_code == 403, response.get_json() - assert (True, app_id, getattr(RBACPermission, revoked_permission)) in checks + assert (True, app_id, RBACPermission[revoked_permission]) in checks with c.factory() as session: assert ( session.scalar( diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_continuation.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_continuation.py index cf840f61fb6..05877ed7425 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_continuation.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_continuation.py @@ -15,6 +15,7 @@ import pytest from alembic.migration import MigrationContext from alembic.operations import Operations from sqlalchemy import inspect, select, text +from sqlalchemy import inspect as inspect_orm from core.dify_builder.models import Actor from models.account import Tenant @@ -592,13 +593,12 @@ def test_node_inputs_storage_and_key_io_are_outside_root_write_transaction(node_ writing = False monkeypatch.setattr(c.store.loader, "locked", tracked_lock) - for target, name in ( - (storage, "save"), - (storage, "load"), - (encrypter, "encrypt_token"), - (encrypter, "decrypt_token"), + for target, name, original in ( + (storage, "save", storage.save), + (storage, "load", storage.load), + (encrypter, "encrypt_token", encrypter.encrypt_token), + (encrypter, "decrypt_token", encrypter.decrypt_token), ): - original = getattr(target, name) def outside_write_transaction(*args, _original=original, **kwargs): assert not writing @@ -1725,12 +1725,18 @@ def test_actual_child_app_permission_checked_before_decrypt(continuation_case, m # Copy actual database-owned app/workflow metadata into a distinct app. with c.factory.begin() as session: original = session.get(App, c.context.app_id) - app = App(**{attr.key: getattr(original, attr.key) for attr in inspect(App).column_attrs if attr.key != "id"}) + app = App( + **{ + attr.key: inspect_orm(original).attrs[attr.key].value + for attr in inspect(App).column_attrs + if attr.key != "id" + } + ) app.id, app.maintainer = str(uuid4()), str(uuid4()) original_workflow = session.get(Workflow, c.context.workflow_id) workflow = Workflow( **{ - attr.key: getattr(original_workflow, attr.key) + attr.key: inspect_orm(original_workflow).attrs[attr.key].value for attr in inspect(Workflow).column_attrs if attr.key != "id" } @@ -1839,7 +1845,7 @@ def test_legacy_null_owner_fails_closed_without_decrypt(continuation_case, monke ) state = NativeInvocationState( **{ - attr.key: getattr(original, attr.key) + attr.key: inspect_orm(original).attrs[attr.key].value for attr in inspect(NativeInvocationState).column_attrs if attr.key != "id" } @@ -1874,7 +1880,7 @@ def test_invocation_read_owner_must_be_paired(continuation_case): ) state = NativeInvocationState( **{ - attr.key: getattr(original, attr.key) + attr.key: inspect_orm(original).attrs[attr.key].value for attr in inspect(NativeInvocationState).column_attrs if attr.key != "id" } @@ -2111,7 +2117,8 @@ def _saved_child_recovery_facts(c, child): query = query.where(model.invocation_id == child.invocation_id) rows = session.scalars(query.order_by(model.request_id if model is NativeTestScope else model.id)) facts[model.__tablename__] = [ - {column.key: getattr(row, column.key) for column in inspect(model).column_attrs} for row in rows + {column.key: inspect_orm(row).attrs[column.key].value for column in inspect(model).column_attrs} + for row in rows ] scope = session.get(NativeTestScope, child.request_id) assert (scope.spent_calls, scope.spent_dispatches, scope.spent_cost_units) == (1, 1, 2) diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_dispatch_claims.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_dispatch_claims.py index 9e07ec83280..fb57eb8794a 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_dispatch_claims.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_dispatch_claims.py @@ -13,7 +13,9 @@ from uuid import uuid4 import pytest from alembic.migration import MigrationContext from alembic.operations import Operations +from psycopg2 import Error as PostgresError from sqlalchemy import create_engine, event, func, select, text +from sqlalchemy import inspect as inspect_orm from sqlalchemy.exc import DBAPIError, IntegrityError from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import NullPool @@ -56,7 +58,7 @@ MIGRATION = "2026_10_15_1200-e59b7d32a6c1_add_native_effect_dispatch_claims.py" @pytest.fixture(scope="module", autouse=True) def migrated_dispatch_claims(migrated_native_tables, flask_app_with_containers): assert migrated_native_tables is None - assert callable(getattr(EffectAuthorizationService, "claim_dispatch", None)), ( + assert callable(EffectAuthorizationService.claim_dispatch), ( "durable physical dispatch claim protocol is unavailable" ) from extensions.ext_database import db @@ -399,7 +401,10 @@ def test_claim_rows_are_unique_append_only_and_block_destructive_downgrade(claim claim = session.scalar( select(NativeEffectDispatchClaim).where(NativeEffectDispatchClaim.attempt_id == permit.attempt_id) ) - values = {column.name: getattr(claim, column.name) for column in NativeEffectDispatchClaim.__table__.columns} + values = { + column.name: inspect_orm(claim).attrs[column.name].value + for column in NativeEffectDispatchClaim.__table__.columns + } for changed in ({"id": str(uuid4())}, {"id": str(uuid4()), "attempt_id": str(uuid4())}): with pytest.raises(IntegrityError), case.factory.begin() as session: session.execute(NativeEffectDispatchClaim.__table__.insert().values({**values, **changed})) @@ -475,7 +480,7 @@ def test_concurrent_downgrade_cannot_erase_a_successfully_committed_claim(claim_ _claim(child, permit, io=io) sender.send(("claimed", None)) except DBAPIError as error: - sender.send(("blocked", getattr(error.orig, "pgcode", None))) + sender.send(("blocked", error.orig.pgcode if isinstance(error.orig, PostgresError) else None)) finally: engine.dispose() sender.close() diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_dispatch_fence.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_dispatch_fence.py index 064cfc52edd..65d50f52ba2 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_dispatch_fence.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_dispatch_fence.py @@ -10,6 +10,7 @@ from uuid import uuid4 import pytest from sqlalchemy import func, select +from sqlalchemy import inspect as inspect_orm from models.dify_builder_native_tests import ( NativeEffectAttempt, @@ -22,8 +23,10 @@ from models.dify_builder_native_tests import ( ) from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest from runtime_contracts.builder.native.contracts import ApprovalWait, DispatchPermit, PolicyStop, RecordedResult +from services.dify_builder.native_testing.http_binding import NativeHttpDisclosure from services.dify_builder.native_testing.pending_actions import NativePendingActionService from services.dify_builder.native_testing.transitions import quarantine_unknown +from tests.test_containers_integration_tests.core.workflow.builder_native_fixture_records import ChildApprovalCase from tests.test_containers_integration_tests.core.workflow.test_builder_native_continuation import ( _bind_child_effect, _child_case, @@ -304,7 +307,6 @@ def test_real_pending_creation_and_fence_use_child_local_generation_after_unrela @pytest.mark.parametrize("stale_local", [False, True]) def test_real_protected_child_approval_local_zero_shared_one_and_stale_local_rejection(fence_case, stale_local): - from types import SimpleNamespace from tests.test_containers_integration_tests.core.workflow.test_builder_native_approval_scope import ( _decide, @@ -320,13 +322,13 @@ def test_real_protected_child_approval_local_zero_shared_one_and_stale_local_rej context = c.store.load_context(ref=child) assert context.generation == 0 effect = _bind_child_effect(c, child, approval_scope="operation", input_digest=canonical_digest({"limit": 1})) - selected = SimpleNamespace( + selected = ChildApprovalCase( ref=child, store=c.store, factory=c.factory, context=context, lifecycle=c.lifecycle, - ledger=SimpleNamespace(actor=c.ledger.actor, guard=c.ledger.guard), + ledger=c.ledger, ) _, guards = _prepare_pending(selected, (effect,)) with c.factory() as session: @@ -406,8 +408,7 @@ def test_history_allows_fresh_pending_then_first_new_fence_replaces_only_old_evi assert (_state(c).pending_ids, _state(c).wait_reason) == ([fresh.pending_operation_id], "approval") -def test_active_private_reason_does_not_publish_a_waiting_receipt(fence_case): - from types import SimpleNamespace +def test_active_private_reason_does_not_publish_a_waiting_receipt(mocker, fence_case): from models.dify_builder import DifyBuilderExecutionRequest from services.dify_builder.native_testing.api_service import NativeTestApiService @@ -417,6 +418,8 @@ def test_active_private_reason_does_not_publish_a_waiting_receipt(fence_case): _fence(c, wait.pending_operation_id) with c.factory() as session: session_id = session.get(DifyBuilderExecutionRequest, c.ref.request_id).session_id + disclosure = NativeHttpDisclosure(loader=c.store.loader, state_store=c.store) + mocker.patch.object(disclosure, "describe", side_effect=lambda **_kw: pytest.fail("running fence is private")) api = NativeTestApiService( session_factory=c.factory, actor=c.ledger.actor, @@ -424,7 +427,7 @@ def test_active_private_reason_does_not_publish_a_waiting_receipt(fence_case): admission=None, resolution=c.resolution, runtime=None, - disclosure=SimpleNamespace(describe=lambda **_kw: pytest.fail("running fence is private")), + disclosure=disclosure, ) receipt = api.get(actor=c.ledger.actor, session_id=session_id, request_id=c.ref.request_id) assert _state(c).state == "running" @@ -537,11 +540,10 @@ def test_concurrent_generation_claim_has_one_owner_and_retains_exact_evidence(fe @pytest.mark.parametrize("child_status", ["running", "completed", "stopped"]) @pytest.mark.parametrize("fault", [None, "permission", "revision", "input"]) def test_real_existing_nested_child_lookup_revalidates_parent_app_and_protected_input( - fence_case, monkeypatch, child_status, fault + mocker, fence_case, monkeypatch, child_status, fault ): """Root A stays authorized; actual parent B must qualify every lookup of C.""" import json - from types import SimpleNamespace from sqlalchemy import inspect @@ -559,11 +561,17 @@ def test_real_existing_nested_child_lookup_revalidates_parent_app_and_protected_ # Copy real owned app/workflow metadata to a distinct invocation app B. with c.factory.begin() as session: original = session.get(App, c.context.app_id) - app = App(**{a.key: getattr(original, a.key) for a in inspect(App).column_attrs if a.key != "id"}) + app = App( + **{a.key: inspect_orm(original).attrs[a.key].value for a in inspect(App).column_attrs if a.key != "id"} + ) app.id, app.maintainer = str(uuid4()), str(uuid4()) original_workflow = session.get(Workflow, c.context.workflow_id) workflow = Workflow( - **{a.key: getattr(original_workflow, a.key) for a in inspect(Workflow).column_attrs if a.key != "id"} + **{ + a.key: inspect_orm(original_workflow).attrs[a.key].value + for a in inspect(Workflow).column_attrs + if a.key != "id" + } ) workflow.id, workflow.app_id = str(uuid4()), app.id app.workflow_id = workflow.id @@ -593,7 +601,12 @@ def test_real_existing_nested_child_lookup_revalidates_parent_app_and_protected_ } ) - service = NativeChildInvocationService(c.store, resolver=SimpleNamespace(resolve_child=resolve_child)) + class ChildResolver: + def resolve_child(self, **values): + return resolve_child(**values) + + resolver = ChildResolver() + service = NativeChildInvocationService(c.store, resolver=resolver) args = { "parent_ref": parent_ref, "key": key, @@ -618,7 +631,9 @@ def test_real_existing_nested_child_lookup_revalidates_parent_app_and_protected_ output_result_ref=None, ), ) - service.resolver = SimpleNamespace(resolve_child=lambda **_kw: pytest.fail("existing C must not resolve again")) + mocker.patch.object( + resolver, "resolve_child", side_effect=lambda **_kw: pytest.fail("existing C must not resolve again") + ) before = _count(c, NativeInvocationState) if fault == "permission": monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_host_capacity.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_host_capacity.py index 93784dfc823..c3aa3c3eb22 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_host_capacity.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_host_capacity.py @@ -7,11 +7,11 @@ No local import/collection/run, provider I/O, sleep or fake checkpoint authority from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime, timedelta from threading import Barrier -from types import SimpleNamespace from uuid import uuid4 import pytest from sqlalchemy import inspect, select +from sqlalchemy import inspect as inspect_orm from core.dify_builder.models import Actor from models.account import Account, Tenant, TenantAccountJoin @@ -30,7 +30,9 @@ from services.dify_builder.native_testing.host_capacity import NativeHostCapacit from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService from services.dify_builder.native_testing.pending_actions import NativePendingActionService from services.dify_builder.native_testing.repository import OwnedNativeTestLoader +from services.dify_builder.native_testing.runtime import DifyNativeRuntimeOwner from services.dify_builder.native_testing.state_store import NativeExecutionStateStore +from tests.test_containers_integration_tests.core.workflow.builder_native_fixture_records import CapacityOwnerCase from tests.test_containers_integration_tests.core.workflow.test_builder_native_continuation import ( continuation_case as continuation_case, # noqa: PLC0414 ) @@ -80,10 +82,16 @@ def _peer(c, *, another_app=False, another_actor=False, another_tenant=False): ) actor = Actor(tenant_id=tenant.id, account_id=actor.account_id) if another_app or another_tenant: - app = App(**{a.key: getattr(app, a.key) for a in inspect(App).column_attrs if a.key != "id"}) + app = App( + **{a.key: inspect_orm(app).attrs[a.key].value for a in inspect(App).column_attrs if a.key != "id"} + ) app.id, app.tenant_id = str(uuid4()), actor.tenant_id workflow = Workflow( - **{a.key: getattr(workflow, a.key) for a in inspect(Workflow).column_attrs if a.key != "id"} + **{ + a.key: inspect_orm(workflow).attrs[a.key].value + for a in inspect(Workflow).column_attrs + if a.key != "id" + } ) workflow.id, workflow.app_id, workflow.tenant_id = str(uuid4()), app.id, actor.tenant_id app.workflow_id = workflow.id @@ -130,7 +138,7 @@ def _peer(c, *, another_app=False, another_actor=False, another_tenant=False): ) store.initialize_context(context=context) assert store.load_context(ref=ledger.ref) == context - return SimpleNamespace( + return CapacityOwnerCase( factory=factory, ledger=ledger, ref=ledger.ref, @@ -233,7 +241,9 @@ def test_real_native_capacity_is_not_global_across_apps(capacity_case): @pytest.mark.parametrize("status", ["claimed", "unknown", "completed", "cancelled"]) -def test_real_acquired_unclean_root_retains_slot_even_after_expiry_or_terminal_projection(capacity_case, status): +def test_real_acquired_unclean_root_retains_slot_even_after_expiry_or_terminal_projection( + mocker, capacity_case, status +): c = capacity_case peer = _peer(c) dispatch = c.lifecycle.enqueue_initial_dispatch(ref=c.ref) @@ -267,7 +277,9 @@ def test_real_acquired_unclean_root_retains_slot_even_after_expiry_or_terminal_p outcome=outcome, ) elif status == "cancelled": - owner.lifecycle.runtime = SimpleNamespace(cancel=lambda **_: (c.ref.invocation_id,)) + runtime = DifyNativeRuntimeOwner() + mocker.patch.object(runtime, "cancel", return_value=(c.ref.invocation_id,)) + owner.lifecycle.runtime = runtime assert ( owner.lifecycle.cancel(actor=c.ledger.actor, request_id=c.ref.request_id, idempotency_key="cancel").status == "cancelling" @@ -299,7 +311,7 @@ def test_real_exact_cleanup_releases_once_and_missing_physical_port_proof_does_n assert contender.claim_dispatch(ref=peer.ref, generation=0, dispatch_id=other.dispatch_id) -def test_real_resume_recovery_requires_cleanup_ack_and_new_exact_lifetime_for_same_sql_ids(capacity_case): +def test_real_resume_recovery_requires_cleanup_ack_and_new_exact_lifetime_for_same_sql_ids(mocker, capacity_case): c = capacity_case c.pause() c.ledger.confirm() @@ -310,7 +322,9 @@ def test_real_resume_recovery_requires_cleanup_ack_and_new_exact_lifetime_for_sa session.get(NativeDispatch, dispatch.dispatch_id).claim_expires_at = datetime.now(UTC) - timedelta(seconds=1) owner.lifecycle.reconcile() calls = [] - owner.lifecycle.runtime = SimpleNamespace(recovery_evidence=lambda **_: calls.append("proof") or "stopped") + runtime = DifyNativeRuntimeOwner() + mocker.patch.object(runtime, "recovery_evidence", side_effect=lambda **_: calls.append("proof") or "stopped") + owner.lifecycle.runtime = runtime with pytest.raises(BuilderExecutionPolicyError, match="native_cleanup_unproven"): owner.lifecycle.recover_dispatch(ref=c.ref, dispatch_id=dispatch.dispatch_id) assert calls == [] diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_http_binding.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_http_binding.py index b269b777775..a298681366d 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_http_binding.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_http_binding.py @@ -11,7 +11,6 @@ import json from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from datetime import datetime -from types import SimpleNamespace from uuid import uuid4 import pytest @@ -31,7 +30,7 @@ from runtime_contracts.builder.native.contracts import ApprovalWait, DispatchPer from runtime_contracts.builder.native.runtime_contracts import NativeCheckpointPayload, ResolvedNativeTestContext from services.dify_builder.native_testing.authorization import EffectAuthorizationService from services.dify_builder.native_testing.call_adapter import NativeCallAdapter -from services.dify_builder.native_testing.control import BoundaryOwnerState +from services.dify_builder.native_testing.control import BoundaryOwnerState, NativeControlRefused from services.dify_builder.native_testing.http_adapter import NativeHttpClient, PreparedHttpBinding from services.dify_builder.native_testing.http_binding import ( DeferredNativeHttpClient, @@ -57,6 +56,11 @@ from services.dify_builder.native_testing.public_contracts import NativeTestPubl from services.dify_builder.native_testing.repository import OwnedNativeTestLoader, native_workflow_revision from services.dify_builder.native_testing.runtime_bridge import NativeCollaborators, NativeRuntimeBridge from services.dify_builder.native_testing.state_store import NativeExecutionStateStore +from tests.test_containers_integration_tests.core.workflow.builder_native_fixture_records import ( + HttpBindingCase, + HttpConsentCase, + PreparedHttpCase, +) from tests.test_containers_integration_tests.core.workflow.test_builder_native_continuation import ( migrated_continuation_tables as migrated_continuation_tables, # noqa: PLC0414 - shared pytest fixture ) @@ -92,6 +96,14 @@ def literal_data(): } +class _RefusingConstructorControl: + """Constructor-only binding has no host capable of accepting runtime control.""" + + def record_control(self, *, record): + assert record is not None + return NativeControlRefused() + + @pytest.fixture def http_case(native_owner, no_live, migrated_continuation_tables, migrated_dispatch_claims): """Reuse shared migration/owner fixtures unchanged; initialize one real private HTTP context.""" @@ -172,7 +184,7 @@ def http_case(native_owner, no_live, migrated_continuation_tables, migrated_disp execution_mode="native", ) store.initialize_context(context=context) - return SimpleNamespace( + return HttpBindingCase( ref=ref, loader=loader, store=store, @@ -220,7 +232,7 @@ def prepare(c, execution_id=None, *, ref=None): collaborators=collaborators, node_arguments=arguments, boundary_owner=BoundaryOwnerState(ref=selected_ref), - control_sink=SimpleNamespace(record_control=lambda **_kw: None), + control_sink=_RefusingConstructorControl(), ) runtime = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) association = bridge.prepare_node( @@ -250,7 +262,7 @@ def prepare(c, execution_id=None, *, ref=None): ) request = client._prepare("POST", arguments.pop("url"), arguments) binding = producer.prepare(request=request, hop=0, previous_response=None) - return SimpleNamespace(producer=producer, binding=binding, journal=journal, association=association) + return PreparedHttpCase(producer=producer, binding=binding, journal=journal, association=association) def bindings(c): @@ -416,8 +428,11 @@ def test_real_http_input_ack_and_external_work_stay_outside_install_writer(http_ active = False monkeypatch.setattr(c.loader, "locked", tracked_lock) - for name in ("prepare_object", "load_result", "load_context"): - original = getattr(c.store, name) + for name, original in ( + ("prepare_object", c.store.prepare_object), + ("load_result", c.store.load_result), + ("load_context", c.store.load_context), + ): def checked(*args, _original=original, **kwargs): assert not active, "protected/storage work under root writer" @@ -697,7 +712,7 @@ def _confirmed_http_resume(c, prepared, lifecycle, *, prior_journals=()): assert resumed.generation == context.generation + 1 assert lifecycle.claim_dispatch(ref=c.ref, generation=resumed.generation, dispatch_id=resumed.dispatch_id) assert lifecycle.claim_dispatch(ref=c.ref, generation=resumed.generation, dispatch_id=resumed.dispatch_id) is False - return SimpleNamespace( + return HttpConsentCase( pending_id=wait.pending_operation_id, scope=disclosure.scope_digest, guard=guard, diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_observation_history.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_observation_history.py index 5d2e6883080..02c6f9d3fdc 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_observation_history.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_observation_history.py @@ -5,8 +5,6 @@ This proves content/owner boundaries, not C03 resume provenance or physical IO. import json import logging -from types import SimpleNamespace -from unittest.mock import Mock from uuid import uuid4 import pytest @@ -44,6 +42,7 @@ from services.dify_builder.native_testing.observation_repository import bind_nat from services.dify_builder.native_testing.repository import OwnedNativeTestLoader, native_workflow_revision from services.dify_builder.native_testing.state_store import NativeExecutionStateStore from services.variable_truncator import VariableTruncator +from tests.test_containers_integration_tests.core.workflow.builder_native_fixture_records import ObservationHistoryCase from tests.test_containers_integration_tests.core.workflow.test_builder_native_continuation import ( migrated_continuation_tables as migrated_continuation_tables, # noqa: PLC0414 ) @@ -65,7 +64,7 @@ CANARY = "PRIVATE_NATIVE_HISTORY_FIXTURE_4f3a" @pytest.fixture def history_case(native_owner, no_live, migrated_continuation_tables, migrated_dispatch_claims, request): - shape = getattr(request, "param", "literal_http") + shape = request.param if hasattr(request, "param") else "literal_http" # Test-owned application logging sinks use the real installed privacy filter. filtering = SensitiveHttpLogFilter() handlers = tuple(logging.getLogger().handlers) @@ -172,7 +171,7 @@ def history_case(native_owner, no_live, migrated_continuation_tables, migrated_d execution_mode="native", ) store.initialize_context(context=context) - yield SimpleNamespace(factory=ledger.factory, actor=ledger.actor, store=store, ref=ref, shape=shape) + yield ObservationHistoryCase(factory=ledger.factory, actor=ledger.actor, store=store, ref=ref, shape=shape) finally: for handler in handlers: handler.removeFilter(filtering) @@ -289,7 +288,7 @@ def test_actual_owner_or_legacy_content_refuses_before_cache(history_case, monke assert guard.failed -def test_foreign_upload_owner_refuses_before_actual_storage_read(history_case, monkeypatch): +def test_foreign_upload_owner_refuses_before_actual_storage_read(mocker, history_case, monkeypatch): from extensions.ext_storage import storage c = history_case @@ -302,9 +301,7 @@ def test_foreign_upload_owner_refuses_before_actual_storage_read(history_case, m for link in links: session.get(UploadFile, link.file_id).created_by = str(uuid4()) - forbidden = Mock(side_effect=AssertionError("foreign offload reached storage")) - - monkeypatch.setattr(storage, "load", forbidden) + forbidden = mocker.patch.object(storage, "load", side_effect=AssertionError("foreign offload reached storage")) with pytest.raises(NativeObservationError): nodes.get_db_models_by_workflow_run(context.native_run_id) forbidden.assert_not_called() diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_runtime.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_runtime.py index 607bb900c7a..95a6d91cdbe 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_runtime.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_native_runtime.py @@ -9,7 +9,6 @@ import json import logging from concurrent.futures import ThreadPoolExecutor from threading import Event -from types import SimpleNamespace import httpx import pytest @@ -31,6 +30,7 @@ from runtime_contracts.builder.native.contracts import decode_native_ref from services.dify_builder.native_testing.lifecycle import NativeDispatchRef from services.dify_builder.native_testing.observation_repository import bind_native_repository_observation from services.dify_builder.native_testing.runtime import DifyNativeRuntime, DifyNativeRuntimeOwner, build_native_api +from tests.test_containers_integration_tests.core.workflow.builder_native_fixture_records import RuntimeDeliveryCase from tests.test_containers_integration_tests.core.workflow.builder_native_runtime_diagnostics import ( native_runtime_resume_diagnostics as native_runtime_resume_diagnostics, # noqa: PLC0414 ) @@ -121,7 +121,7 @@ def runtime_case( monkeypatch, request, ): - kind = getattr(request, "param", "root") + kind = request.param if hasattr(request, "param") else "root" assert migrated_continuation_tables is migrated_dispatch_claims is no_live is None from core.helper import ssrf_proxy from extensions.ext_database import db @@ -133,7 +133,13 @@ def runtime_case( # Pytest attaches these real capture/report sinks again for the call phase, # after this setup fixture has yielded. Protect those exact handlers too; # the production readiness check must continue to reject unfiltered sinks. - capture_handlers = (getattr(logging_plugin, name, None) for name in ("caplog_handler", "report_handler")) + from _pytest.logging import LoggingPlugin + + capture_handlers = ( + (logging_plugin.caplog_handler, logging_plugin.report_handler) + if isinstance(logging_plugin, LoggingPlugin) + else () + ) handlers = tuple( dict.fromkeys((*logging.getLogger().handlers, *(h for h in capture_handlers if isinstance(h, logging.Handler)))) ) @@ -201,7 +207,7 @@ def runtime_case( idempotency_key="actual-initial", ) assert len(published) == 1 - yield SimpleNamespace( + yield RuntimeDeliveryCase( factory=factory, actor=actor, app=flask_app_with_containers, diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_builder_restricted_native.py b/api/tests/test_containers_integration_tests/core/workflow/test_builder_restricted_native.py index cb546a6de6a..bc46787f257 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_builder_restricted_native.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_builder_restricted_native.py @@ -9,7 +9,6 @@ from collections.abc import Iterator from dataclasses import replace from pathlib import Path from typing import NoReturn -from unittest.mock import Mock from uuid import uuid4 import pytest @@ -134,6 +133,9 @@ def native_owner(flask_req_ctx_with_containers: None, db_session_with_containers @pytest.fixture def no_live(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + from contextlib import ExitStack + from unittest.mock import patch + from core.helper import ssrf_proxy from core.helper.code_executor.code_executor import CodeExecutor from core.model_manager import ModelManager @@ -141,25 +143,30 @@ def no_live(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: from core.tools.tool_manager import ToolManager from factories import file_factory + # The spawned SQL-race owner enters this fixture with its own MonkeyPatch. + # Keep that explicit invocation contract while method patches own their cleanup. + assert isinstance(monkeypatch, pytest.MonkeyPatch) spies = [] - for owner, name in [ - *((ssrf_proxy, n) for n in ("get", "post", "put", "patch", "delete", "head")), - (CodeExecutor, "execute_code"), - (ModelManager, "get_model_instance"), - (OAuthHandler, "refresh_credentials"), - (ToolManager, "get_workflow_tool_runtime"), - (file_factory, "build_from_mapping"), - ]: - spy = Mock(side_effect=AssertionError(f"live capability reached: {name}")) - monkeypatch.setattr(owner, name, spy) - spies.append(spy) - yield - for spy in spies: - spy.assert_not_called() + with ExitStack() as methods: + for owner, name in [ + *((ssrf_proxy, n) for n in ("get", "post", "put", "patch", "delete", "head")), + (CodeExecutor, "execute_code"), + (ModelManager, "get_model_instance"), + (OAuthHandler, "refresh_credentials"), + (ToolManager, "get_workflow_tool_runtime"), + (file_factory, "build_from_mapping"), + ]: + spy = methods.enter_context( + patch.object(owner, name, side_effect=AssertionError(f"live capability reached: {name}")) + ) + spies.append(spy) + yield + for spy in spies: + spy.assert_not_called() @pytest.fixture -def no_jinja_compilation(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: +def no_jinja_compilation(mocker) -> Iterator[None]: """Guard restricted Template execution without replacing Jinja lex/parse.""" from jinja2 import Environment, Template, debug @@ -170,8 +177,9 @@ def no_jinja_compilation(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: (debug, ("rewrite_traceback_stack", "fake_traceback")), ): for name in names: - spy = Mock(side_effect=AssertionError(f"Jinja compilation/rendering reached: {name}")) - monkeypatch.setattr(owner, name, spy) + spy = mocker.patch.object( + owner, name, side_effect=AssertionError(f"Jinja compilation/rendering reached: {name}") + ) spies.append(spy) yield # A swallowed compiler/renderer error cannot satisfy the overflow control. @@ -505,7 +513,7 @@ def test_composed_effective_default_binding(native_owner: NativeOwner) -> None: @pytest.mark.usefixtures("no_live") -def test_async_native_repositories_without_delivered_saves_stay_unknown( +def test_async_node_saves_without_delivery_stay_unknown( native_owner: NativeOwner, monkeypatch: pytest.MonkeyPatch ) -> None: from core.repositories import celery_workflow_execution_repository as runs @@ -513,7 +521,8 @@ def test_async_native_repositories_without_delivered_saves_stay_unknown( pending_runs: list[dict[str, object]] = [] pending_nodes: list[dict[str, object]] = [] - # Exercise the actual configurable async repositories; hold only broker delivery. + # Run control records now commit synchronously even for Celery repositories. + # Node evidence still uses broker delivery; a successful Run alone is not proof. monkeypatch.setattr(runs.save_workflow_execution_task, "delay", lambda **kw: pending_runs.append(kw)) monkeypatch.setattr(nodes.save_workflow_node_execution_task, "delay", lambda **kw: pending_nodes.append(kw)) monkeypatch.setattr( @@ -527,12 +536,41 @@ def test_async_native_repositories_without_delivered_saves_stay_unknown( "core.repositories.celery_workflow_node_execution_repository.CeleryWorkflowNodeExecutionRepository", ) result = run_port(native_owner, configure(native_owner, "scalar.yml")) - assert pending_runs + assert pending_runs == [] assert pending_nodes - assert result.dify_run_id == "" + assert result.dify_run_id assert result.verification is not None assert result.verification.execution_evidence is not None assert result.verification.execution_evidence.safety_outcome == "execution_evidence_unknown" + factory, _, app, _, _, _ = native_owner + with factory() as session: + request = session.scalar( + select(DifyBuilderExecutionRequest).where(DifyBuilderExecutionRequest.app_id == app.id) + ) + assert request is not None + native = session.get(WorkflowRun, request.native_run_id) + assert native is not None + assert native.id == result.dify_run_id + assert native.status == "succeeded" + assert native.outputs_dict == {"out": "native scalar"} + assert ( + list( + session.scalars( + select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.workflow_run_id == native.id) + ) + ) + == [] + ) + assert is_execution_policy_blocker(result) + assert ( + publication_decision( + replace(result, kind="verify", immutable=True, session_id=native_owner[4]), + session_id=native_owner[4], + current_revision=result.verification.execution_revision, + current_graph_revision=result.verification.executed_graph_revision, + ).allowed + is False + ) @pytest.mark.usefixtures("no_live") @@ -1092,13 +1130,12 @@ def test_composed_template_seal_replay_rejects_changed_output( @pytest.mark.usefixtures("no_live", "no_jinja_compilation") -def test_composed_template_normal_workflow_citation_defaults_are_preserved( - native_owner: NativeOwner, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_composed_template_normal_workflow_citation_defaults_are_preserved(mocker, native_owner: NativeOwner) -> None: from core.rag.retrieval.dataset_retrieval import DatasetRetrieval - retrieval = Mock(side_effect=AssertionError("citation display metadata initiated retrieval")) - monkeypatch.setattr(DatasetRetrieval, "retrieve", retrieval) + retrieval = mocker.patch.object( + DatasetRetrieval, "retrieve", side_effect=AssertionError("citation display metadata initiated retrieval") + ) inputs = configure_template(native_owner) features: dict[str, object] = { "retriever_resource": {"enabled": True}, diff --git a/api/tests/test_containers_integration_tests/migrations/test_builder_native_migration_dialects.py b/api/tests/test_containers_integration_tests/migrations/test_builder_native_migration_dialects.py new file mode 100644 index 00000000000..0ef05849dcf --- /dev/null +++ b/api/tests/test_containers_integration_tests/migrations/test_builder_native_migration_dialects.py @@ -0,0 +1,341 @@ +"""Real database migration parity for retained native execution evidence.""" + +import importlib.util +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from io import StringIO +from pathlib import Path +from types import ModuleType +from typing import cast +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.engine import Connection, make_url +from sqlalchemy.exc import DBAPIError +from testcontainers.core.wait_strategies import ExecWaitStrategy +from testcontainers.mysql import MySqlContainer +from testcontainers.postgres import PostgresContainer + +from models.base import TypeBase +from models.types import StringUUID + +_VERSIONS = Path(__file__).parents[3] / "migrations/versions" +_REVISIONS = ("c84e6a217b90", "c67e12a904bf", "d28f0e3c912a", "f47a1c90d2e8", "e59b7d32a6c1", "7d2b9c4e61a8") + + +def _migration(revision: str) -> ModuleType: + path = next(_VERSIONS.glob(f"*-{revision}_*.py")) + spec = importlib.util.spec_from_file_location(revision, path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(params=["postgresql", "mysql"]) +def migrated_connection(request: pytest.FixtureRequest) -> Iterator[Connection]: + container = ( + PostgresContainer("postgres:14-alpine") + if request.param == "postgresql" + else MySqlContainer("mysql:8.0", dialect="pymysql", username="root", password="test") + ) + # Docker accepts host-IP tuples; testcontainers annotates only scalar ports. + # Explicit loopback publication also avoids Docker Desktop's dual-stack proxy + # closing connections to these disposable local test databases. + bind = cast(Callable[[int, tuple[str, None]], object], container.with_bind_ports) + bind(container.port, ("127.0.0.1", None)) + probe = ( + 'PGPASSWORD="$POSTGRES_PASSWORD" psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" ' + '--host 127.0.0.1 -tAc "SELECT 1"' + if request.param == "postgresql" + else 'mysql --protocol=TCP --host=127.0.0.1 --user=root --password="$MYSQL_ROOT_PASSWORD" ' + '--database="$MYSQL_DATABASE" -Nse "SELECT 1"' + ) + container.waiting_for(ExecWaitStrategy(["sh", "-c", probe]).with_startup_timeout(30)) + with container: + engine = sa.create_engine(make_url(container.get_connection_url()).set(host="127.0.0.1")) + try: + with engine.connect() as connection: + with Operations.context( + MigrationContext.configure(connection, opts={"target_metadata": TypeBase.metadata}) + ): + # This legacy table is the request migration's sole prerequisite. + sa.Table( + "dify_builder_test_inputs", sa.MetaData(), sa.Column("id", StringUUID(), primary_key=True) + ).create(connection) + for revision in _REVISIONS: + _migration(revision).upgrade() + yield connection + finally: + engine.dispose() + + +def test_native_migrations_preserve_checks_and_evidence_on_both_databases(migrated_connection: Connection) -> None: + connection = migrated_connection + # Empty-evidence downgrades and re-upgrades execute real DDL on each database. + for revision in reversed(_REVISIONS[1:]): + _migration(revision).downgrade() + for revision in _REVISIONS[1:]: + _migration(revision).upgrade() + + def seed(name: str, **overrides: object) -> str: + table = sa.Table("dify_builder_" + name, sa.MetaData(), autoload_with=connection) + values: dict[str, object] = {} + for column in table.columns: + if column.nullable or column.server_default is not None: + continue + if isinstance(column.type, sa.JSON): + value: object = {} + elif isinstance(column.type, sa.Boolean): + value = False + elif isinstance(column.type, sa.DateTime): + value = datetime.now(UTC) + elif isinstance(column.type, sa.Integer): + value = 0 + else: + value = str(uuid4()) + values[column.name] = value + values.update(overrides) + connection.execute(table.insert().values(**values)) + return str(values["id"]) + + def refused(sql: str, reason: str) -> None: + with pytest.raises(DBAPIError, match=reason), connection.begin_nested(): + connection.execute(sa.text(sql)) + + # A native-only waiting state must not become an ordinary execution escape. + with pytest.raises(DBAPIError), connection.begin_nested(): + seed("execution_requests", state="waiting", context={"policy_version": "ordinary"}, observations=[]) + request_id = seed( + "execution_requests", state="waiting", context={"policy_version": "builder-native-test-v1"}, observations=[] + ) + connection.execute(sa.text("UPDATE dify_builder_execution_requests SET state='running'")) + + tenant_id = str(uuid4()) + invocation = "NativeOccurrence" + owner = {"tenant_id": tenant_id, "request_id": request_id, "invocation_id": invocation} + operation_id = seed( + "native_operations", + **owner, + operation_digest="0" * 64, + intent_digest="0" * 64, + scope_digest="0" * 64, + state="claimed", + ) + attempt_id = seed( + "native_attempts", + **owner, + operation_id=operation_id, + dispatch_index=1, + permit_digest="0" * 64, + invocation_digest="0" * 64, + ) + seed("native_outcomes", **owner, operation_id=operation_id, attempt_id=attempt_id, outcome="unknown") + claim_id = seed("native_effect_dispatch_claims", **owner, operation_id=operation_id, attempt_id=attempt_id) + seed("native_invocations", **owner, active=True, bindings={}) + dispatch_id = seed("native_dispatches", **owner, state="queued", idempotency_digest="0" * 64) + seed( + "native_payload_objects", + **owner, + kind="context", + logical_key="context", + storage_key="context", + payload_digest="0" * 64, + value_digest="0" * 64, + ) + seed("native_continuations", **owner, checkpoint_id="checkpoint", payload_digest="0" * 64, payload_ref="payload") + for table in ("attempts", "outcomes", "effect_dispatch_claims", "payload_objects", "continuations"): + refused(f"UPDATE dify_builder_native_{table} SET invocation_id='changed'", "append-only") + refused(f"DELETE FROM dify_builder_native_{table}", "append-only") + + seed("native_invocation_states", **owner, state="running", pending_ids=[], resolution={}) + connection.execute(sa.text("UPDATE dify_builder_native_invocation_states SET generation=1")) + refused( + "UPDATE dify_builder_native_invocation_states SET invocation_id='nativeoccurrence'", "read owner is immutable" + ) + refused( + "UPDATE dify_builder_native_invocation_states SET app_id='00000000-0000-0000-0000-000000000001'", + "read owner is immutable", + ) + + physical = dict( + owner, + actor_id=str(uuid4()), + operation_id=operation_id, + attempt_id=attempt_id, + effect_claim_id=claim_id, + host_dispatch_id=dispatch_id, + scope_id="code_" + uuid4().hex, + scope_generation=1, + base_snapshot_ref="snapshot", + operation_digest="0" * 64, + intent_digest="0" * 64, + scope_spec_digest="0" * 64, + environment_digest="0" * 64, + payload_digest="0" * 64, + profile_digest="0" * 64, + base_snapshot_digest="0" * 64, + ) + with pytest.raises(DBAPIError), connection.begin_nested(): + seed("native_code_physical_operations", **{**physical, "scope_id": "code_" + "A" * 32}) + seed("native_code_physical_operations", **physical) + connection.execute(sa.text("UPDATE dify_builder_native_code_physical_operations SET observed_output_offset=2")) + refused("UPDATE dify_builder_native_code_physical_operations SET observed_output_offset=1", "cannot regress") + refused( + "UPDATE dify_builder_native_code_physical_operations SET base_snapshot_ref='Snapshot'", "identity is immutable" + ) + refused("DELETE FROM dify_builder_native_code_physical_operations", "must be retained") + connection.execute(sa.text("UPDATE dify_builder_native_code_physical_operations SET ambiguous=true")) + refused("UPDATE dify_builder_native_code_physical_operations SET scope_create_acked=true", "cannot regress") + connection.execute( + sa.text("UPDATE dify_builder_native_code_physical_operations SET destroyed_at=CURRENT_TIMESTAMP") + ) + refused("UPDATE dify_builder_native_code_physical_operations SET observed_output_offset=3", "cannot regress") + + for revision, reason in ( + ("7d2b9c4e61a8", "Code physical evidence"), + ("e59b7d32a6c1", "native dispatch claim evidence"), + ("f47a1c90d2e8", "native invocation read owner evidence"), + ("d28f0e3c912a", "native continuation recovery data"), + ): + with pytest.raises(RuntimeError, match=reason): + _migration(revision).downgrade() + assert connection.scalar(sa.text("SELECT count(*) FROM dify_builder_native_code_physical_operations")) == 1 + + if connection.dialect.name == "mysql": + # Apply the actual exported script twice: a refusal leaves the routine + # behind, and retry must still fail on evidence rather than routine DDL. + output = StringIO() + with Operations.context( + MigrationContext.configure(dialect_name="mysql", opts={"as_sql": True, "output_buffer": output}) + ): + _migration("7d2b9c4e61a8").downgrade() + for _attempt in range(2): + try: + with pytest.raises(DBAPIError, match="Code physical evidence must be retained"): + _execute_mysql_export(connection, output.getvalue()) + finally: + connection.execute(sa.text("UNLOCK TABLES")) + assert connection.scalar(sa.text("SELECT count(*) FROM dify_builder_native_code_physical_operations")) == 1 + + # The legacy sequence rollback must regenerate the same per-app order on + # both dialects without losing historical rows when upgraded again. + connection.execute( + sa.text( + "CREATE TABLE workflow_runs (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(36) NOT NULL, " + "app_id VARCHAR(36) NOT NULL, created_at TIMESTAMP NOT NULL)" + ) + ) + tenant = str(uuid4()) + first_app, second_app = str(uuid4()), str(uuid4()) + first, second, independent = sorted(str(uuid4()) for _ in range(3)) + connection.execute( + sa.text("INSERT INTO workflow_runs (id,tenant_id,app_id,created_at) VALUES (:id,:tenant,:app,:created)"), + [ + {"id": second, "tenant": tenant, "app": first_app, "created": "2026-10-01 12:00:00"}, + {"id": first, "tenant": tenant, "app": first_app, "created": "2026-10-01 12:00:00"}, + {"id": independent, "tenant": tenant, "app": second_app, "created": "2026-10-01 12:00:00"}, + ], + ) + legacy = _migration("0ab65e1cc7fa") + with Operations.context(MigrationContext.configure(connection)): + legacy.downgrade() + assert dict(connection.execute(sa.text("SELECT id,sequence_number FROM workflow_runs")).tuples().all()) == { + first: 1, + second: 2, + independent: 1, + } + with Operations.context(MigrationContext.configure(connection)): + legacy.upgrade() + assert connection.scalar(sa.text("SELECT count(*) FROM workflow_runs")) == 3 + assert "sequence_number" not in {column["name"] for column in sa.inspect(connection).get_columns("workflow_runs")} + + # Dropping the binding table must not try to remove indexes still needed + # by MySQL's foreign keys, and must preserve both parent tables' rows. + parents = sa.MetaData() + for name in ("api_tokens", "datasets"): + sa.Table(name, parents, sa.Column("id", StringUUID(), primary_key=True)).create(connection) + connection.execute(sa.text(f"INSERT INTO {name} (id) VALUES (:id)"), {"id": tenant}) + binding_migration = _migration("c3f1a9b2e6d4") + with Operations.context(MigrationContext.configure(connection, opts={"target_metadata": TypeBase.metadata})): + binding_migration.upgrade() + connection.execute( + sa.text("INSERT INTO dataset_api_token_bindings (id,api_token_id,dataset_id) VALUES (:id,:id,:id)"), + {"id": tenant}, + ) + binding_migration.downgrade() + assert "dataset_api_token_bindings" not in sa.inspect(connection).get_table_names() + for name in ("api_tokens", "datasets"): + assert connection.scalar(sa.text(f"SELECT count(*) FROM {name}")) == 1 + + debug = sa.Table( + "agent_debug_conversations", + sa.MetaData(), + sa.Column("id", StringUUID(), primary_key=True), + *(sa.Column(field, StringUUID(), nullable=False) for field in ("tenant_id", "agent_id", "account_id")), + sa.Column("draft_type", sa.String(32), nullable=False), + sa.UniqueConstraint( + "tenant_id", + "agent_id", + "account_id", + "draft_type", + name="agent_debug_conversation_agent_account_draft_type_unique", + ), + ) + debug.create(connection) + agent, account, other_account, other_tenant = (str(uuid4()) for _ in range(4)) + build_id, draft_id, draft_only_id, foreign_id = (str(uuid4()) for _ in range(4)) + connection.execute( + debug.insert(), + [ + { + "id": build_id, + "tenant_id": tenant, + "agent_id": agent, + "account_id": account, + "draft_type": "debug_build", + }, + {"id": draft_id, "tenant_id": tenant, "agent_id": agent, "account_id": account, "draft_type": "draft"}, + { + "id": draft_only_id, + "tenant_id": tenant, + "agent_id": agent, + "account_id": other_account, + "draft_type": "draft", + }, + { + "id": foreign_id, + "tenant_id": other_tenant, + "agent_id": agent, + "account_id": other_account, + "draft_type": "debug_build", + }, + ], + ) + debug_migration = _migration("d2825e7b9c10") + with Operations.context(MigrationContext.configure(connection)): + debug_migration.downgrade() + assert {str(row[0]) for row in connection.execute(sa.text("SELECT id FROM agent_debug_conversations"))} == { + build_id, + draft_only_id, + foreign_id, + } + debug_migration.upgrade() + assert connection.scalar(sa.text("SELECT count(*) FROM agent_debug_conversations")) == 3 + + +def _execute_mysql_export(connection: Connection, script: str) -> None: + delimiter = ";" + pending = "" + for line in script.splitlines(): + if line.startswith("DELIMITER "): + delimiter = line.removeprefix("DELIMITER ") + else: + pending += line + "\n" + if pending.rstrip().endswith(delimiter): + connection.execute(sa.text(pending.rstrip()[: -len(delimiter)])) + pending = "" diff --git a/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_utils.py b/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_utils.py index 35a095b0499..e2dc713b77e 100644 --- a/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_utils.py +++ b/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_utils.py @@ -14,19 +14,17 @@ if TYPE_CHECKING: def force_reload_code_executor(): - """ - Force reload the code_executor module to reinitialize code_execution_endpoint_url. + """Reload the actual transport owner after the container configuration exists. - This function should be called after setting up environment variables - to ensure the code_execution_endpoint_url is initialized with the correct value. + The HTTP endpoint moved out of the CodeExecutor facade into + clients.code_execution; reload it first so the real sandbox request uses + the container's endpoint together with its configured authentication key. """ - try: - import core.helper.code_executor.code_executor + import clients.code_execution + import core.helper.code_executor.code_executor - importlib.reload(core.helper.code_executor.code_executor) - except Exception as e: - # Log the error but don't fail the test - print(f"Warning: Failed to reload code_executor module: {e}") + importlib.reload(clients.code_execution) + importlib.reload(core.helper.code_executor.code_executor) def get_code_executor_imports(): diff --git a/api/tests/unit_tests/commands/test_check_no_new_getattr.py b/api/tests/unit_tests/commands/test_check_no_new_getattr.py index a846955a770..1196ac1aa70 100644 --- a/api/tests/unit_tests/commands/test_check_no_new_getattr.py +++ b/api/tests/unit_tests/commands/test_check_no_new_getattr.py @@ -251,9 +251,14 @@ def test_main_ci_passes_style_base_rev_input() -> None: assert style_job is not None assert "uses: ./.github/workflows/style.yml" in style_job.group("job") assert ( - "base-rev: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}" + "base-rev: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || inputs.base_sha }}" in style_job.group("job") ) + dispatch = re.search(r"(?ms)^ workflow_dispatch:\n(?P.*?)(?=^ [a-z0-9_-]+:\n)", workflow) + assert dispatch is not None + assert "base_sha:" in dispatch.group("inputs") + assert "required: true" in dispatch.group("inputs") + assert "type: string" in dispatch.group("inputs") assert "run-python-style: ${{ needs.check-changes.outputs.python-style-changed == 'true' }}" in style_job.group( "job" ) diff --git a/api/tests/unit_tests/controllers/console/app/test_generator_api.py b/api/tests/unit_tests/controllers/console/app/test_generator_api.py index 9cf7e042ea9..8ca9e7a97c2 100644 --- a/api/tests/unit_tests/controllers/console/app/test_generator_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_generator_api.py @@ -610,11 +610,12 @@ def test_generate_instruction_suggestions_no_default_model_returns_empty(monkeyp """Task 1c (ii): a missing default model degrades to an empty list, never raising.""" from core.llm_generator import llm_generator as llm_gen_module - mock_manager = MagicMock() - mock_manager.for_tenant.return_value.get_default_model_instance.side_effect = ProviderTokenNotInitError( - "no default model" - ) - monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager) + _instance, manager = _improvement_model(monkeypatch, "") + + def unavailable(**_kwargs): + raise ProviderTokenNotInitError("no default model") + + monkeypatch.setattr(manager, "get_default_model_instance", unavailable) result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(tenant_id="t1", mode="workflow") @@ -679,15 +680,28 @@ _EXPANDED_INSTRUCTION = ( ) +def _improvement_model(monkeypatch: pytest.MonkeyPatch, reply: str): + from core.llm_generator import llm_generator as llm_gen_module + from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage + from graphon.model_runtime.entities.message_entities import AssistantPromptMessage + from tests.unit_tests.core.model_fixtures import make_model_instance + + manager = llm_gen_module.ModelManager.for_tenant(tenant_id="t1") + instance = make_model_instance(provider="openai", model="gpt-4o") + response = LLMResult( + model="gpt-4o", prompt_messages=[], message=AssistantPromptMessage(content=reply), usage=LLMUsage.empty_usage() + ) + monkeypatch.setattr(instance, "invoke_llm", lambda **_kwargs: response) + monkeypatch.setattr(manager, "get_default_model_instance", lambda **_kwargs: instance) + monkeypatch.setattr(llm_gen_module.ModelManager, "for_tenant", lambda **_kwargs: manager) + return instance, manager + + def test_improve_instruction_expands_a_short_prompt(monkeypatch: pytest.MonkeyPatch) -> None: """The default model's rewrite is returned once it clears the echo/shrink checks.""" from core.llm_generator import llm_generator as llm_gen_module - instance = MagicMock() - instance.invoke_llm.return_value.message.get_text_content.return_value = _EXPANDED_INSTRUCTION - mock_manager = MagicMock() - mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance - monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager) + _improvement_model(monkeypatch, _EXPANDED_INSTRUCTION) result = llm_gen_module.LLMGenerator.improve_workflow_instruction( tenant_id="t1", instruction=_SHORT_INSTRUCTION, mode="workflow" @@ -700,11 +714,7 @@ def test_improve_instruction_strips_fences_and_quotes(monkeypatch: pytest.Monkey """A fenced or quoted rewrite is unwrapped -- the field holds plain text.""" from core.llm_generator import llm_generator as llm_gen_module - instance = MagicMock() - instance.invoke_llm.return_value.message.get_text_content.return_value = f'```text\n"{_EXPANDED_INSTRUCTION}"\n```' - mock_manager = MagicMock() - mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance - monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager) + _improvement_model(monkeypatch, f'```text\n"{_EXPANDED_INSTRUCTION}"\n```') result = llm_gen_module.LLMGenerator.improve_workflow_instruction( tenant_id="t1", instruction=_SHORT_INSTRUCTION, mode="workflow" @@ -727,11 +737,7 @@ def test_improve_instruction_reports_no_change_when_it_did_not_improve( """An echo, a shrink, or blank output all mean "no improvement" -- never a raise.""" from core.llm_generator import llm_generator as llm_gen_module - instance = MagicMock() - instance.invoke_llm.return_value.message.get_text_content.return_value = reply - mock_manager = MagicMock() - mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance - monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager) + _improvement_model(monkeypatch, reply) result = llm_gen_module.LLMGenerator.improve_workflow_instruction( tenant_id="t1", instruction=_SHORT_INSTRUCTION, mode="workflow" @@ -744,11 +750,12 @@ def test_improve_instruction_no_default_model_returns_empty(monkeypatch: pytest. """A missing default model degrades to "no improvement", never raising.""" from core.llm_generator import llm_generator as llm_gen_module - mock_manager = MagicMock() - mock_manager.for_tenant.return_value.get_default_model_instance.side_effect = ProviderTokenNotInitError( - "no default model" - ) - monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager) + _instance, manager = _improvement_model(monkeypatch, "") + + def unavailable(**_kwargs): + raise ProviderTokenNotInitError("no default model") + + monkeypatch.setattr(manager, "get_default_model_instance", unavailable) result = llm_gen_module.LLMGenerator.improve_workflow_instruction( tenant_id="t1", instruction=_SHORT_INSTRUCTION, mode="workflow" @@ -761,11 +768,12 @@ def test_improve_instruction_llm_error_returns_empty(monkeypatch: pytest.MonkeyP """An LLM failure is swallowed: the caller keeps the user's own text.""" from core.llm_generator import llm_generator as llm_gen_module - instance = MagicMock() - instance.invoke_llm.side_effect = RuntimeError("upstream exploded") - mock_manager = MagicMock() - mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance - monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager) + instance, _manager = _improvement_model(monkeypatch, "") + + def failed(**_kwargs): + raise RuntimeError("upstream exploded") + + monkeypatch.setattr(instance, "invoke_llm", failed) result = llm_gen_module.LLMGenerator.improve_workflow_instruction( tenant_id="t1", instruction=_SHORT_INSTRUCTION, mode="workflow" diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index b204607f413..5d46d0eb339 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -145,6 +145,8 @@ def test_publish_workflow_returns_success( app: Flask, monkeypatch: pytest.MonkeyPatch, advisory: str, + sqlite_engine, + mocker, ) -> None: current_user = SimpleNamespace(id="account-1") app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") @@ -179,16 +181,10 @@ def test_publish_workflow_returns_success( ) session = Mock() session.get.return_value = app_model - monkeypatch.setattr( - workflow_module, - "WorkflowService", - Mock( - return_value=SimpleNamespace( - prepare_draft_tool_publish=lambda **_kwargs: None, - publish_workflow=Mock(return_value=workflow), - ) - ), - ) + workflow_service = workflow_module.WorkflowService(sessionmaker(bind=sqlite_engine)) + mocker.patch.object(workflow_service, "prepare_draft_tool_publish", return_value=None) + mocker.patch.object(workflow_service, "publish_workflow", return_value=workflow) + monkeypatch.setattr(workflow_module, "WorkflowService", lambda: workflow_service) monkeypatch.setattr( workflow_module, "sessionmaker", diff --git a/api/tests/unit_tests/controllers/console/test_builder_native_testing_routes.py b/api/tests/unit_tests/controllers/console/test_builder_native_testing_routes.py index ee51988c560..2c143cc2045 100644 --- a/api/tests/unit_tests/controllers/console/test_builder_native_testing_routes.py +++ b/api/tests/unit_tests/controllers/console/test_builder_native_testing_routes.py @@ -126,20 +126,25 @@ def test_native_input_binding_requires_signed_login(request_sql_world): assert response.status_code == 401 -def test_native_input_binding_has_only_public_identity(request_sql_world, monkeypatch): - from types import SimpleNamespace +def test_native_input_binding_has_only_public_identity(request_sql_world, monkeypatch, mocker): + from sqlalchemy.orm import sessionmaker from controllers.console import dify_builder as controller + from services.dify_builder.native_testing.input_binding import NativeTestInputBindingService from services.dify_builder.native_testing.public_contracts import NativeTestInputBinding binding = NativeTestInputBinding( test_input_id="input-1", expected_version=3, workflow_id="workflow-1", graph_revision="a" * 64 ) - monkeypatch.setattr( - controller, - "build_native_test_input_binding_service", - lambda **_kwargs: SimpleNamespace(get=lambda **_kwargs: binding), - ) + + def binding_service(*, actor): + service = NativeTestInputBindingService( + session_factory=sessionmaker(bind=request_sql_world.registry.get_bind()), actor=actor + ) + mocker.patch.object(service, "get", return_value=binding) + return service + + monkeypatch.setattr(controller, "build_native_test_input_binding_service", binding_service) response = request_sql_world.client.get(BASE.removesuffix("/native-tests") + "/native-test-input") assert response.status_code == 200 assert response.get_json() == {"data": binding.model_dump(mode="json")} diff --git a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py index 9ccabd1291b..22358500901 100644 --- a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py +++ b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py @@ -7,12 +7,13 @@ without requiring a logged-in Flask request context. import json from collections.abc import Callable, Iterator +from dataclasses import dataclass from datetime import UTC, datetime from inspect import unwrap from pathlib import Path -from types import FunctionType, SimpleNamespace +from types import FunctionType from typing import Annotated, NamedTuple, NoReturn, TypedDict, cast, get_args, override -from unittest.mock import MagicMock, call +from unittest.mock import Mock, call import pytest from flask import Flask, Response, current_app, g, has_request_context @@ -66,12 +67,37 @@ from services.dify_builder import wiring from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError from services.dify_builder.application.ports import Repository from services.dify_builder.real_call_consent_service import BuilderRealCallConsentService +from services.dify_builder.repository import SqlDifyBuilderRepository from services.dify_builder.service import DifyBuilderService, SessionView from services.enterprise.rbac_service import RBACService +from services.entities.feature_entities import FeatureModel from tests.unit_tests.config_override import apply_config_overrides from tests.unit_tests.model_factories import make_tenant +@dataclass +class _RouteDatabase: + session: scoped_session[OrmSession] + engine: Engine + + +def _consent_calls(owner: BuilderRealCallConsentService) -> list: + """Inspect only the five explicit public boundary methods patched in these route tests.""" + boundaries = ( + (owner.prepare, call.prepare), + (owner.confirm, call.confirm), + (owner.get, call.get), + (owner.reserve_execution, call.reserve_execution), + (owner.replay_execution, call.replay_execution), + ) + return [ + recorded(*entry.args, **entry.kwargs) + for method, recorded in boundaries + if isinstance(method, Mock) + for entry in method.call_args_list + ] + + def _session_view(session_id: str = "s1", *, run_status: RunStatus = RunStatus.PROCESSING) -> SessionView: return SessionView( session_id=session_id, @@ -85,8 +111,8 @@ def _session_view(session_id: str = "s1", *, run_status: RunStatus = RunStatus.P ) -def _account(account_id: str = "acc-1") -> MagicMock: - account = MagicMock() +def _account(account_id: str = "acc-1") -> Account: + account = Account(name="Actor", email=f"{account_id}@example.test") account.id = account_id return account @@ -95,6 +121,37 @@ def _actor() -> Actor: return mod._actor(_account(), "ten-1") +def _route_service() -> DifyBuilderService: + return DifyBuilderService( + repo=SqlDifyBuilderRepository(session_factory_module.get_session_maker()), + session_lock=_HeldLease(), + enqueue_fn=lambda _session_id, _action, _actor, _token: None, + ) + + +class _ProgressSubscription: + """An idle, closeable progress subscription for reconnect transport tests.""" + + def __init__(self) -> None: + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, *_error): + self.close() + + def __iter__(self) -> Iterator[bytes]: + return iter(()) + + def receive(self, timeout: float | None = 0.1) -> bytes | None: + del timeout + return None + + def close(self) -> None: + self.closed = True + + def _frames() -> Iterator[str]: yield f"event: message\ndata: {json.dumps({'event': 'command_started', 'data': {'session_id': 's1'}})}\n\n" @@ -112,8 +169,9 @@ def test_actor_builds_from_current_user_and_tenant() -> None: assert actor == Actor(account_id="acc-1", tenant_id="ten-1") -def test_session_get_returns_json_state(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_session_get_returns_json_state(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "get_session_view") view = _session_view() view.reply_language = "ja" service.get_session_view.return_value = view @@ -141,9 +199,10 @@ def test_session_get_and_command_finished_schema_keep_reply_language() -> None: assert finished_payload["reply_language"] == "zh-Hans" -def test_session_stream_delegates_to_reconnect_stream(monkeypatch: pytest.MonkeyPatch) -> None: - stream = MagicMock(return_value=Response(_frames(), mimetype="text/event-stream", headers=mod._SSE_HEADERS)) - monkeypatch.setattr(mod, "_stream", stream) +def test_session_stream_delegates_to_reconnect_stream(mocker) -> None: + stream = mocker.patch.object( + mod, "_stream", return_value=Response(_frames(), mimetype="text/event-stream", headers=mod._SSE_HEADERS) + ) with Flask(__name__).test_request_context(): response = unwrap(mod.DifyBuilderSessionStreamApi.get)(object(), "ten-1", _account(), "s1") @@ -154,8 +213,9 @@ def test_session_stream_delegates_to_reconnect_stream(monkeypatch: pytest.Monkey ) -def test_conversation_get_validates_query_and_returns_json_page(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_conversation_get_validates_query_and_returns_json_page(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "get_conversation_page") service.get_conversation_page.return_value = ConversationPage( data=[ConversationItem(seq=2, kind="notice", payload={"text": "hi"}, at_version=2)], has_more=True, @@ -188,8 +248,9 @@ def test_conversation_get_validates_query_and_returns_json_page(monkeypatch: pyt ) -def test_conversation_get_rejects_competing_cursors(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_conversation_get_rejects_competing_cursors(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "get_conversation_page") monkeypatch.setattr(mod, "build_service", lambda: service) assert mod._conversation("s1", {"before_seq": "3", "after_seq": "1"}, _actor()) == ( @@ -256,25 +317,29 @@ def test_conversation_get_rejects_competing_cursors(monkeypatch: pytest.MonkeyPa ], ) def test_create_always_calls_streaming_service( - monkeypatch: pytest.MonkeyPatch, payload: object, method_name: str, expected_kwargs: dict[str, object] + mocker, monkeypatch: pytest.MonkeyPatch, payload: object, method_name: str, expected_kwargs: dict[str, object] ) -> None: - service = MagicMock() - getattr(service, method_name).return_value = _frames() + service = _route_service() + mocker.patch.object(service, "create_build_session") + mocker.patch.object(service, "create_edit_session") + mocker.patch.object(service, "create_fix_session") + streaming_method = mocker.patch.object(service, method_name, return_value=_frames()) monkeypatch.setattr(mod, "build_service", lambda: service) actor = _actor() response = _assert_event_stream(mod._create(payload, actor)) assert b'"event": "command_started"' in response.get_data() - called = getattr(service, method_name).call_args + called = streaming_method.call_args assert called.kwargs == {**expected_kwargs, "actor": actor} service.create_build_session.assert_not_called() service.create_edit_session.assert_not_called() service.create_fix_session.assert_not_called() -def test_create_serializes_model_config_for_service(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_create_serializes_model_config_for_service(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "create_build_session_stream") service.create_build_session_stream.return_value = _frames() monkeypatch.setattr(mod, "build_service", lambda: service) @@ -322,17 +387,32 @@ def test_create_serializes_model_config_for_service(monkeypatch: pytest.MonkeyPa ], ) def test_create_rejects_malformed_or_legacy_payload_before_service( - monkeypatch: pytest.MonkeyPatch, payload: object + mocker, monkeypatch: pytest.MonkeyPatch, payload: object ) -> None: - service = MagicMock() + service = _route_service() + mocker.patch.object(service, "create_build_session_stream") + mocker.patch.object(service, "create_edit_session_stream") + mocker.patch.object(service, "create_fix_session_stream") + mocker.patch.object(service, "submit_message_stream") + mocker.patch.object(service, "submit_action_stream") monkeypatch.setattr(mod, "build_service", lambda: service) assert mod._create(payload, _actor()) == ({"code": "bad_request"}, 400) - assert not service.mock_calls + assert not any( + method.called + for method in ( + service.create_build_session_stream, + service.create_edit_session_stream, + service.create_fix_session_stream, + service.submit_message_stream, + service.submit_action_stream, + ) + ) -def test_create_maps_pre_stream_error(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_create_maps_pre_stream_error(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "create_fix_session_stream") service.create_fix_session_stream.side_effect = ConflictError("stale") monkeypatch.setattr(mod, "build_service", lambda: service) @@ -341,8 +421,9 @@ def test_create_maps_pre_stream_error(monkeypatch: pytest.MonkeyPatch) -> None: assert result == ({"code": "conflict"}, 409) -def test_create_returns_recoverable_model_error_before_starting_stream(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_create_returns_recoverable_model_error_before_starting_stream(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "create_build_session_stream") service.create_build_session_stream.side_effect = ModelUnavailableError("provider credential detail") monkeypatch.setattr(mod, "build_service", lambda: service) @@ -354,8 +435,10 @@ def test_create_returns_recoverable_model_error_before_starting_stream(monkeypat ) -def test_action_always_returns_event_stream_and_resolves_action_id(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_action_always_returns_event_stream_and_resolves_action_id(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "submit_action") + mocker.patch.object(service, "submit_action_stream") service.submit_action_stream.return_value = _frames() monkeypatch.setattr(mod, "build_service", lambda: service) actor = _actor() @@ -381,8 +464,9 @@ def test_action_always_returns_event_stream_and_resolves_action_id(monkeypatch: service.submit_action.assert_not_called() -def test_action_keeps_receipt_snapshot_separate_from_raw_submission(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_action_keeps_receipt_snapshot_separate_from_raw_submission(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "submit_action_stream") service.submit_action_stream.return_value = _frames() monkeypatch.setattr(mod, "build_service", lambda: service) raw = {"goal": "Goal {{name}} ", "enabled": True} @@ -441,17 +525,32 @@ def test_action_keeps_receipt_snapshot_separate_from_raw_submission(monkeypatch: ], ) def test_action_rejects_malformed_or_legacy_payload_before_service( - monkeypatch: pytest.MonkeyPatch, payload: object + mocker, monkeypatch: pytest.MonkeyPatch, payload: object ) -> None: - service = MagicMock() + service = _route_service() + mocker.patch.object(service, "create_build_session_stream") + mocker.patch.object(service, "create_edit_session_stream") + mocker.patch.object(service, "create_fix_session_stream") + mocker.patch.object(service, "submit_message_stream") + mocker.patch.object(service, "submit_action_stream") monkeypatch.setattr(mod, "build_service", lambda: service) assert mod._action("s1", payload, _actor()) == ({"code": "bad_request"}, 400) - assert not service.mock_calls + assert not any( + method.called + for method in ( + service.create_build_session_stream, + service.create_edit_session_stream, + service.create_fix_session_stream, + service.submit_message_stream, + service.submit_action_stream, + ) + ) -def test_action_maps_pre_stream_conflict(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_action_maps_pre_stream_conflict(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "submit_action_stream") service.submit_action_stream.side_effect = ConflictError("stale") monkeypatch.setattr(mod, "build_service", lambda: service) @@ -469,8 +568,10 @@ def test_action_maps_pre_stream_conflict(monkeypatch: pytest.MonkeyPatch) -> Non assert result == ({"code": "conflict"}, 409) -def test_message_always_returns_event_stream(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_message_always_returns_event_stream(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "submit_message") + mocker.patch.object(service, "submit_message_stream") service.submit_message_stream.return_value = _frames() monkeypatch.setattr(mod, "build_service", lambda: service) actor = _actor() @@ -503,19 +604,36 @@ def test_message_always_returns_event_stream(monkeypatch: pytest.MonkeyPatch) -> ], ) def test_message_rejects_malformed_or_legacy_payload_before_service( - monkeypatch: pytest.MonkeyPatch, payload: object + mocker, monkeypatch: pytest.MonkeyPatch, payload: object ) -> None: - service = MagicMock() + service = _route_service() + mocker.patch.object(service, "create_build_session_stream") + mocker.patch.object(service, "create_edit_session_stream") + mocker.patch.object(service, "create_fix_session_stream") + mocker.patch.object(service, "submit_message_stream") + mocker.patch.object(service, "submit_action_stream") monkeypatch.setattr(mod, "build_service", lambda: service) assert mod._message("s1", payload, _actor()) == ({"code": "bad_request"}, 400) - assert not service.mock_calls + assert not any( + method.called + for method in ( + service.create_build_session_stream, + service.create_edit_session_stream, + service.create_fix_session_stream, + service.submit_message_stream, + service.submit_action_stream, + ) + ) -def test_stream_authorizes_then_subscribes_before_reading_state(monkeypatch: pytest.MonkeyPatch) -> None: +def test_stream_authorizes_then_subscribes_before_reading_state(mocker, monkeypatch: pytest.MonkeyPatch) -> None: calls: list[str] = [] - subscription = MagicMock() - service = MagicMock() + subscription = _ProgressSubscription() + mocker.spy(subscription, "close") + service = _route_service() + mocker.patch.object(service, "authorize_session") + mocker.patch.object(service, "get_session_view") service.authorize_session.side_effect = lambda *_args: calls.append("authorize") service.get_session_view.side_effect = lambda *_args: (calls.append("view"), _session_view())[1] @@ -524,15 +642,14 @@ def test_stream_authorizes_then_subscribes_before_reading_state(monkeypatch: pyt assert (tenant_id, session_id) == ("ten-1", "s1") calls.append("snapshot") - def subscribe(_session_id: str) -> MagicMock: + def subscribe(_session_id: str) -> _ProgressSubscription: calls.append("subscribe") return subscription - stream = MagicMock(return_value=iter(["data: {}\n\n"])) + stream = mocker.patch.object(mod, "stream_advance_frames", return_value=iter(["data: {}\n\n"])) monkeypatch.setattr(mod, "build_service", lambda: service) monkeypatch.setattr(mod.progress_bus, "subscribe", subscribe) monkeypatch.setattr(mod.progress_snapshot, "read", read_snapshot) - monkeypatch.setattr(mod, "stream_advance_frames", stream) with Flask(__name__).app_context(): response = mod._stream("s1", _actor()) @@ -545,14 +662,16 @@ def test_stream_authorizes_then_subscribes_before_reading_state(monkeypatch: pyt assert stream.call_args.kwargs["command_id"] == "" -def test_stream_settled_state_does_not_watch_for_progress(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_stream_settled_state_does_not_watch_for_progress(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "authorize_session", return_value=None) + mocker.patch.object(service, "get_session_view") service.get_session_view.return_value = _session_view(run_status=RunStatus.COMPLETE) - subscription = MagicMock() - stream = MagicMock(return_value=iter(["data: {}\n\n"])) + subscription = _ProgressSubscription() + mocker.spy(subscription, "close") + stream = mocker.patch.object(mod, "stream_advance_frames", return_value=iter(["data: {}\n\n"])) monkeypatch.setattr(mod, "build_service", lambda: service) monkeypatch.setattr(mod.progress_bus, "subscribe", lambda _session_id: subscription) - monkeypatch.setattr(mod, "stream_advance_frames", stream) with Flask(__name__).app_context(): response = mod._stream("s1", _actor()) @@ -563,14 +682,18 @@ def test_stream_settled_state_does_not_watch_for_progress(monkeypatch: pytest.Mo assert stream.call_args.kwargs["emit_command_finished_when_settled"] is True -def test_reconnect_heartbeat_recovers_without_retaining_the_request_context(monkeypatch: pytest.MonkeyPatch) -> None: +def test_reconnect_heartbeat_recovers_without_retaining_the_request_context( + mocker, monkeypatch: pytest.MonkeyPatch +) -> None: app = Flask(__name__) processing = _session_view() settled = _session_view(run_status=RunStatus.WAITING_INPUT) settled.last_command_id = "command-1" - service = MagicMock() - subscription = MagicMock() - subscription.receive.return_value = None + service = _route_service() + mocker.patch.object(service, "authorize_session", return_value=None) + mocker.patch.object(service, "get_session_view") + subscription = _ProgressSubscription() + mocker.spy(subscription, "close") def read_view(_session_id: str, actor: Actor) -> SessionView: assert current_app == app @@ -597,9 +720,12 @@ def test_reconnect_heartbeat_recovers_without_retaining_the_request_context(monk subscription.close.assert_called_once_with() -def test_stream_closes_subscription_when_state_is_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - subscription = MagicMock() - service = MagicMock() +def test_stream_closes_subscription_when_state_is_unavailable(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + subscription = _ProgressSubscription() + mocker.spy(subscription, "close") + service = _route_service() + mocker.patch.object(service, "authorize_session", return_value=None) + mocker.patch.object(service, "get_session_view") service.get_session_view.side_effect = NotFoundError("missing") monkeypatch.setattr(mod.progress_bus, "subscribe", lambda _session_id: subscription) monkeypatch.setattr(mod, "build_service", lambda: service) @@ -610,11 +736,11 @@ def test_stream_closes_subscription_when_state_is_unavailable(monkeypatch: pytes subscription.close.assert_called_once_with() -def test_stream_authorizes_before_opening_subscription(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_stream_authorizes_before_opening_subscription(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "authorize_session") service.authorize_session.side_effect = NotFoundError("missing") - subscribe = MagicMock() - monkeypatch.setattr(mod.progress_bus, "subscribe", subscribe) + subscribe = mocker.patch.object(mod.progress_bus, "subscribe") monkeypatch.setattr(mod, "build_service", lambda: service) assert mod._stream("s1", _actor()) == ({"code": "not_found"}, 404) @@ -643,7 +769,7 @@ def test_stream_response_reraises_unmapped_exception() -> None: def test_dify_builder_required_blocks_when_feature_disabled(monkeypatch: pytest.MonkeyPatch) -> None: wrapped = mod.dify_builder_required(lambda *_args, **_kwargs: ({"ok": True}, 200)) - features = MagicMock(dify_builder_enabled=False) + features = FeatureModel(dify_builder_enabled=False) monkeypatch.setattr(mod.FeatureService, "get_features", lambda _tenant_id: features) assert wrapped(object(), "t") == ({"code": "feature_unavailable"}, 403) @@ -653,7 +779,7 @@ def test_dify_builder_required_passes_through_when_enabled(monkeypatch: pytest.M def view(_self: object, current_tenant_id: str, *_args: object, **_kwargs: object) -> tuple[dict[str, object], int]: return {"current_tenant_id": current_tenant_id}, 200 - features = MagicMock(dify_builder_enabled=True) + features = FeatureModel(dify_builder_enabled=True) monkeypatch.setattr(mod.FeatureService, "get_features", lambda _tenant_id: features) assert mod.dify_builder_required(view)(object(), "t") == ({"current_tenant_id": "t"}, 200) @@ -665,7 +791,7 @@ def test_decorator_stack_injects_positionally_in_method_order(monkeypatch: pytes monkeypatch.setattr( mod.FeatureService, "get_features", - lambda _tenant_id: MagicMock(dify_builder_enabled=True), + lambda _tenant_id: FeatureModel(dify_builder_enabled=True), ) captured = {} @@ -687,7 +813,7 @@ def test_decorator_stack_gate_blocks_when_feature_off(monkeypatch: pytest.Monkey monkeypatch.setattr( mod.FeatureService, "get_features", - lambda _tenant_id: MagicMock(dify_builder_enabled=False), + lambda _tenant_id: FeatureModel(dify_builder_enabled=False), ) def view(*_args: object, **_kwargs: object) -> NoReturn: @@ -717,8 +843,9 @@ def test_session_routes_require_legacy_edit_permission(method: Callable[..., obj assert "edit_permission_required" in gate.__code__.co_qualname -def test_create_build_defaults_to_not_renaming_the_app(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_create_build_defaults_to_not_renaming_the_app(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "create_build_session_stream") service.create_build_session_stream.return_value = _frames() monkeypatch.setattr(mod, "build_service", lambda: service) @@ -727,8 +854,9 @@ def test_create_build_defaults_to_not_renaming_the_app(monkeypatch: pytest.Monke assert service.create_build_session_stream.call_args.kwargs["derive_app_name"] is False -def test_create_build_forwards_the_rename_opt_in(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_create_build_forwards_the_rename_opt_in(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "create_build_session_stream") service.create_build_session_stream.return_value = _frames() monkeypatch.setattr(mod, "build_service", lambda: service) @@ -740,8 +868,9 @@ def test_create_build_forwards_the_rename_opt_in(monkeypatch: pytest.MonkeyPatch assert service.create_build_session_stream.call_args.kwargs["derive_app_name"] is True -def test_action_resolves_confirm_through_the_option_the_client_chose(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_action_resolves_confirm_through_the_option_the_client_chose(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "submit_action_stream") service.submit_action_stream.return_value = _frames() monkeypatch.setattr(mod, "build_service", lambda: service) @@ -764,8 +893,9 @@ def test_action_resolves_confirm_through_the_option_the_client_chose(monkeypatch assert action.payload == {"option_id": "run_validation", "free_text": ""} -def test_action_still_accepts_a_client_posting_the_bare_action_id(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_action_still_accepts_a_client_posting_the_bare_action_id(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "submit_action_stream") service.submit_action_stream.return_value = _frames() monkeypatch.setattr(mod, "build_service", lambda: service) @@ -782,9 +912,10 @@ def test_action_still_accepts_a_client_posting_the_bare_action_id(monkeypatch: p "evidence", [None, {}, {"null": None, "false": False, "zero": 0, "empty": "", "list": [], "map": {}}] ) def test_conversation_result_preserves_terminal_evidence_through_derived_json( - monkeypatch: pytest.MonkeyPatch, evidence: dict[str, object] | None + mocker, monkeypatch: pytest.MonkeyPatch, evidence: dict[str, object] | None ) -> None: - service = MagicMock() + service = _route_service() + mocker.patch.object(service, "get_conversation_page") service.get_conversation_page.return_value = ConversationPage( data=[ ConversationItem( @@ -815,8 +946,11 @@ def test_conversation_result_preserves_terminal_evidence_through_derived_json( assert payload["review_note"] == "Review outputs" -def test_conversation_historical_result_keeps_missing_evidence_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock() +def test_conversation_historical_result_keeps_missing_evidence_unavailable( + mocker, monkeypatch: pytest.MonkeyPatch +) -> None: + service = _route_service() + mocker.patch.object(service, "get_conversation_page") service.get_conversation_page.return_value = ConversationPage( data=[ConversationItem(seq=0, kind="test_result", at_version=1, payload={"status": "succeeded"})], has_more=False, @@ -975,8 +1109,10 @@ def _no_consent_factory() -> BuilderRealCallConsentService: raise AssertionError("execute must go through the DifyBuilderService confirmed coordinator") -def _consent_service(monkeypatch: pytest.MonkeyPatch) -> MagicMock: - consent = MagicMock(spec=BuilderRealCallConsentService) +def _consent_service(monkeypatch: pytest.MonkeyPatch, mocker) -> BuilderRealCallConsentService: + consent = BuilderRealCallConsentService(session_factory_module.get_session_maker()) + for method in ("prepare", "confirm", "get"): + mocker.patch.object(consent, method) monkeypatch.setattr(mod, "build_real_call_consent_service", lambda: consent) monkeypatch.setattr(mod, "build_service", _no_dispatch) return consent @@ -1105,8 +1241,10 @@ def test_confirm_payload_is_strict(injected: dict[str, object]) -> None: DifyBuilderConfirmRealCallPayload.model_validate(_CONFIRM_BODY | injected) -def test_prepare_route_passes_trusted_actor_and_returns_created_receipt(monkeypatch: pytest.MonkeyPatch) -> None: - consent = _consent_service(monkeypatch) +def test_prepare_route_passes_trusted_actor_and_returns_created_receipt( + mocker, monkeypatch: pytest.MonkeyPatch +) -> None: + consent = _consent_service(monkeypatch, mocker) consent.prepare.return_value = _receipt() body, status = mod._prepare_real_call(_SESSION_ID, dict(_PREPARE_BODY), _ACTOR) @@ -1118,7 +1256,7 @@ def test_prepare_route_passes_trusted_actor_and_returns_created_receipt(monkeypa assert body["disclosure"]["identity_basis"] == "workspace_credential" assert body["disclosure"]["remote_account_status"] == "unknown" # Prepare only freezes metadata: the single consent call, nothing runtime/dispatch. - assert consent.mock_calls == [ + assert _consent_calls(consent) == [ call.prepare( actor=_ACTOR, session_id=_SESSION_ID, @@ -1142,12 +1280,12 @@ def test_prepare_route_passes_trusted_actor_and_returns_created_receipt(monkeypa ) @pytest.mark.parametrize("body", [{}, [], "text", {"at_version": True}]) def test_consent_routes_reject_invalid_bodies_before_any_service( - monkeypatch: pytest.MonkeyPatch, route: Callable[[object], object], body: object + mocker, monkeypatch: pytest.MonkeyPatch, route: Callable[[object], object], body: object ) -> None: - consent = _consent_service(monkeypatch) + consent = _consent_service(monkeypatch, mocker) assert route(body) == ({"code": "bad_request"}, 400) - assert not consent.mock_calls + assert not _consent_calls(consent) @pytest.mark.parametrize( @@ -1212,9 +1350,9 @@ def test_consent_routes_reject_invalid_bodies_before_any_service( ], ) def test_prepare_maps_domain_refusals_to_fixed_public_codes( - monkeypatch: pytest.MonkeyPatch, reason: str, status: int, expected: dict[str, str] + mocker, monkeypatch: pytest.MonkeyPatch, reason: str, status: int, expected: dict[str, str] ) -> None: - consent = _consent_service(monkeypatch) + consent = _consent_service(monkeypatch, mocker) consent.prepare.side_effect = BuilderExecutionPolicyError(reason) result = mod._prepare_real_call(_SESSION_ID, dict(_PREPARE_BODY), _ACTOR) @@ -1227,8 +1365,8 @@ def test_prepare_maps_domain_refusals_to_fixed_public_codes( assert DifyBuilderRealCallRefusalResponse.model_validate(expected).reason_code == expected["reason_code"] -def test_refusal_never_echoes_exception_text(monkeypatch: pytest.MonkeyPatch) -> None: - consent = _consent_service(monkeypatch) +def test_refusal_never_echoes_exception_text(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + consent = _consent_service(monkeypatch, mocker) leaked = "provider said token=ya29.secret for alice@example.com" consent.prepare.side_effect = BuilderExecutionPolicyError(leaked) @@ -1242,8 +1380,8 @@ def test_refusal_never_echoes_exception_text(monkeypatch: pytest.MonkeyPatch) -> ) -def test_unexpected_consent_failure_is_not_serialized(monkeypatch: pytest.MonkeyPatch) -> None: - consent = _consent_service(monkeypatch) +def test_unexpected_consent_failure_is_not_serialized(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + consent = _consent_service(monkeypatch, mocker) consent.get.side_effect = RuntimeError("ciphertext=abc") with pytest.raises(RuntimeError): @@ -1260,8 +1398,10 @@ def test_every_mapped_refusal_reason_is_a_declared_public_reason() -> None: @pytest.mark.parametrize("decision", ["confirm", "decline"]) -def test_confirm_route_records_decision_without_dispatch(monkeypatch: pytest.MonkeyPatch, decision: str) -> None: - consent = _consent_service(monkeypatch) +def test_confirm_route_records_decision_without_dispatch( + mocker, monkeypatch: pytest.MonkeyPatch, decision: str +) -> None: + consent = _consent_service(monkeypatch, mocker) state = "confirmed" if decision == "confirm" else "declined" consent.confirm.return_value = _receipt(state=state, can_retry=decision == "decline") @@ -1270,7 +1410,7 @@ def test_confirm_route_records_decision_without_dispatch(monkeypatch: pytest.Mon assert status == 200 assert body["state"] == state # A decision (including decline) never reaches the dispatch owner or enqueues. - assert consent.mock_calls == [ + assert _consent_calls(consent) == [ call.confirm( actor=_ACTOR, session_id=_SESSION_ID, @@ -1283,8 +1423,8 @@ def test_confirm_route_records_decision_without_dispatch(monkeypatch: pytest.Mon ] -def test_confirm_replay_returns_the_saved_decision_receipt(monkeypatch: pytest.MonkeyPatch) -> None: - consent = _consent_service(monkeypatch) +def test_confirm_replay_returns_the_saved_decision_receipt(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + consent = _consent_service(monkeypatch, mocker) saved = _receipt(state="confirmed") # The owner answers an identical replay from the saved decision even after later advancement. consent.confirm.side_effect = [saved, saved] @@ -1309,8 +1449,8 @@ def test_confirm_replay_returns_the_saved_decision_receipt(monkeypatch: pytest.M "consent_expired", ], ) -def test_confirm_scope_version_and_body_conflicts_are_409(monkeypatch: pytest.MonkeyPatch, reason: str) -> None: - consent = _consent_service(monkeypatch) +def test_confirm_scope_version_and_body_conflicts_are_409(mocker, monkeypatch: pytest.MonkeyPatch, reason: str) -> None: + consent = _consent_service(monkeypatch, mocker) consent.confirm.side_effect = BuilderExecutionPolicyError(reason) assert mod._confirm_real_call(_SESSION_ID, _CONSENT_ID, dict(_CONFIRM_BODY), _ACTOR) == ( @@ -1319,8 +1459,8 @@ def test_confirm_scope_version_and_body_conflicts_are_409(monkeypatch: pytest.Mo ) -def test_cross_owner_consent_is_indistinguishable_from_missing(monkeypatch: pytest.MonkeyPatch) -> None: - consent = _consent_service(monkeypatch) +def test_cross_owner_consent_is_indistinguishable_from_missing(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + consent = _consent_service(monkeypatch, mocker) consent.confirm.side_effect = BuilderExecutionPolicyError("consent_not_found") consent.get.side_effect = BuilderExecutionPolicyError("consent_not_found") @@ -1331,8 +1471,8 @@ def test_cross_owner_consent_is_indistinguishable_from_missing(monkeypatch: pyte assert mod._get_real_call(_SESSION_ID, _CONSENT_ID, _ACTOR) == ({"code": "not_found"}, 404) -def test_status_route_returns_current_receipt_without_execution(monkeypatch: pytest.MonkeyPatch) -> None: - consent = _consent_service(monkeypatch) +def test_status_route_returns_current_receipt_without_execution(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + consent = _consent_service(monkeypatch, mocker) consent.get.return_value = _claimed_receipt() body, status = mod._get_real_call(_SESSION_ID, _CONSENT_ID, _ACTOR) @@ -1340,11 +1480,12 @@ def test_status_route_returns_current_receipt_without_execution(monkeypatch: pyt assert status == 200 assert body["state"] == "claimed" assert body["can_retry"] is False - assert consent.mock_calls == [call.get(actor=_ACTOR, session_id=_SESSION_ID, consent_id=_CONSENT_ID)] + assert _consent_calls(consent) == [call.get(actor=_ACTOR, session_id=_SESSION_ID, consent_id=_CONSENT_ID)] -def test_execute_route_uses_only_the_confirmed_dispatch_coordinator(monkeypatch: pytest.MonkeyPatch) -> None: - service = MagicMock(spec=DifyBuilderService) +def test_execute_route_uses_only_the_confirmed_dispatch_coordinator(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + service = _route_service() + mocker.patch.object(service, "execute_confirmed_tool") service.execute_confirmed_tool.return_value = _reserved_receipt() monkeypatch.setattr(mod, "build_service", lambda: service) monkeypatch.setattr(mod, "build_real_call_consent_service", _no_consent_factory) @@ -1354,8 +1495,8 @@ def test_execute_route_uses_only_the_confirmed_dispatch_coordinator(monkeypatch: assert status == 202 assert body["command_id"] == _COMMAND_ID assert body["can_retry"] is False - assert service.mock_calls == [ - call.execute_confirmed_tool( + assert service.execute_confirmed_tool.call_args_list == [ + call( _SESSION_ID, _ACTOR, consent_id=_CONSENT_ID, @@ -1386,9 +1527,10 @@ def test_execute_route_uses_only_the_confirmed_dispatch_coordinator(monkeypatch: ], ) def test_execute_route_maps_coordinator_failures( - monkeypatch: pytest.MonkeyPatch, error: Exception, expected: tuple[dict[str, str], int] + mocker, monkeypatch: pytest.MonkeyPatch, error: Exception, expected: tuple[dict[str, str], int] ) -> None: - service = MagicMock(spec=DifyBuilderService) + service = _route_service() + mocker.patch.object(service, "execute_confirmed_tool") service.execute_confirmed_tool.side_effect = error monkeypatch.setattr(mod, "build_service", lambda: service) @@ -1420,23 +1562,25 @@ class _HeldLease: def _coordinator( - monkeypatch: pytest.MonkeyPatch, *, owner_account_id: str = "acc-1" -) -> tuple[MagicMock, _HeldLease, list[tuple[str, Action, Actor, str]], list[str]]: + monkeypatch: pytest.MonkeyPatch, mocker, *, owner_account_id: str = "acc-1" +) -> tuple[BuilderRealCallConsentService, _HeldLease, list[tuple[str, Action, Actor, str]], list[str]]: """Wire the real DifyBuilderService confirmed coordinator behind the execute route.""" - repo = MagicMock(spec=Repository) - repo.get_session.return_value = ( - DomainSession( - id=_SESSION_ID, - app_id="app-1", - tenant_id="ten-1", - owner_account_id=owner_account_id, - entry_mode=EntryMode.BUILD, - current_state=PcState.BUILD_REVIEW, - version=3, - ), - DifyBuilderContext(), + repo = SqlDifyBuilderRepository(session_factory_module.get_session_maker()) + stored_session = DomainSession( + id=_SESSION_ID, + app_id="app-1", + tenant_id="ten-1", + owner_account_id=owner_account_id, + entry_mode=EntryMode.BUILD, + current_state=PcState.BUILD_REVIEW, + version=3, ) - consent = MagicMock(spec=BuilderRealCallConsentService) + repo.create_session(stored_session, DifyBuilderContext(), []) + for version in (1, 2): + repo.compare_and_advance(stored_session.id, version, PcState.BUILD_REVIEW, DifyBuilderContext(), []) + consent = BuilderRealCallConsentService(session_factory_module.get_session_maker()) + for method in ("reserve_execution", "replay_execution"): + mocker.patch.object(consent, method) lease = _HeldLease() enqueued: list[tuple[str, Action, Actor, str]] = [] authorized: list[str] = [] @@ -1460,9 +1604,10 @@ def _coordinator( def test_matching_execute_replay_returns_current_receipt_without_extra_enqueue( + mocker, monkeypatch: pytest.MonkeyPatch, ) -> None: - consent, lease, enqueued, authorized = _coordinator(monkeypatch) + consent, lease, enqueued, authorized = _coordinator(monkeypatch, mocker) consent.reserve_execution.return_value = (_reserved_receipt(), True) consent.replay_execution.return_value = _claimed_receipt() @@ -1497,8 +1642,8 @@ def test_matching_execute_replay_returns_current_receipt_without_extra_enqueue( assert authorized == ["acc-1:app-1:test_and_run", "acc-1:app-1:test_and_run"] -def test_held_lease_without_saved_execute_is_busy(monkeypatch: pytest.MonkeyPatch) -> None: - consent, lease, enqueued, _authorized = _coordinator(monkeypatch) +def test_held_lease_without_saved_execute_is_busy(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + consent, lease, enqueued, _authorized = _coordinator(monkeypatch, mocker) lease.tokens[_SESSION_ID] = "other-command" consent.replay_execution.return_value = None @@ -1510,8 +1655,10 @@ def test_held_lease_without_saved_execute_is_busy(monkeypatch: pytest.MonkeyPatc assert enqueued == [] -def test_execute_refusal_releases_only_its_own_lease_and_never_enqueues(monkeypatch: pytest.MonkeyPatch) -> None: - consent, lease, enqueued, _authorized = _coordinator(monkeypatch) +def test_execute_refusal_releases_only_its_own_lease_and_never_enqueues( + mocker, monkeypatch: pytest.MonkeyPatch +) -> None: + consent, lease, enqueued, _authorized = _coordinator(monkeypatch, mocker) consent.reserve_execution.side_effect = BuilderExecutionPolicyError("consent_scope_mismatch") assert mod._execute_real_call(_SESSION_ID, _CONSENT_ID, dict(_EXECUTE_BODY), _ACTOR) == ( @@ -1522,14 +1669,14 @@ def test_execute_refusal_releases_only_its_own_lease_and_never_enqueues(monkeypa assert enqueued == [] -def test_execute_on_another_owners_session_is_not_found(monkeypatch: pytest.MonkeyPatch) -> None: - consent, lease, enqueued, authorized = _coordinator(monkeypatch, owner_account_id="someone-else") +def test_execute_on_another_owners_session_is_not_found(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + consent, lease, enqueued, authorized = _coordinator(monkeypatch, mocker, owner_account_id="someone-else") assert mod._execute_real_call(_SESSION_ID, _CONSENT_ID, dict(_EXECUTE_BODY), _ACTOR) == ( {"code": "not_found"}, 404, ) - assert not consent.mock_calls + assert not _consent_calls(consent) assert (lease.acquired, enqueued, authorized) == (0, [], []) @@ -1670,6 +1817,7 @@ def test_consent_route_docs_declare_bodies_only_for_posts() -> None: ], ) def test_consent_routes_propagate_current_tenant_and_user_through_decorators( + mocker, monkeypatch: pytest.MonkeyPatch, method: Callable[..., object], path_kwargs: dict[str, str], @@ -1679,45 +1827,48 @@ def test_consent_routes_propagate_current_tenant_and_user_through_decorators( ) -> None: account = _account("acc-9") monkeypatch.setattr(wraps_mod, "current_account_with_tenant", lambda: (account, "ten-9")) - monkeypatch.setattr(mod.FeatureService, "get_features", lambda _tenant_id: MagicMock(dify_builder_enabled=True)) - consent = _consent_service(monkeypatch) - getattr(consent, service_method).return_value = _receipt() + monkeypatch.setattr(mod.FeatureService, "get_features", lambda _tenant_id: FeatureModel(dify_builder_enabled=True)) + consent = _consent_service(monkeypatch, mocker) + consent_method = {"prepare": consent.prepare, "confirm": consent.confirm, "get": consent.get}[service_method] + consent_method.return_value = _receipt() view = unwrap(method, stop=lambda wrapper: "with_current_user" in wrapper.__code__.co_qualname) with Flask(__name__).test_request_context(method="GET" if body is None else "POST", json=body): result = view(object(), session_id=_SESSION_ID, **path_kwargs) assert cast(tuple[dict[str, object], int], result)[1] == status - called = getattr(consent, service_method).call_args + called = consent_method.call_args assert called.kwargs["actor"] == Actor(account_id="acc-9", tenant_id="ten-9") assert called.kwargs["session_id"] == _SESSION_ID -def test_build_service_wires_the_confirmed_consent_owner(monkeypatch: pytest.MonkeyPatch) -> None: - consent = MagicMock(spec=BuilderRealCallConsentService) +def test_build_service_wires_the_confirmed_consent_owner(monkeypatch, sqlite_session_factory, sqlite_engine) -> None: + consent = BuilderRealCallConsentService(sqlite_session_factory) + database = _RouteDatabase(scoped_session(sqlite_session_factory), sqlite_engine) monkeypatch.setattr(wiring, "build_real_call_consent_service", lambda: consent) - monkeypatch.setattr(wiring, "sessionmaker", MagicMock()) - monkeypatch.setattr(wiring, "SqlDifyBuilderRepository", MagicMock()) - monkeypatch.setattr(wiring, "WorkflowServiceDifyPort", MagicMock()) - monkeypatch.setattr(wiring, "db", MagicMock()) + monkeypatch.setattr(wiring, "db", database) service = wiring.build_service() assert service._real_call_consent is consent + assert isinstance(service._repo, SqlDifyBuilderRepository) -def test_build_real_call_consent_service_uses_the_shared_engine(monkeypatch: pytest.MonkeyPatch) -> None: - factory = MagicMock() - sessionmaker = MagicMock(return_value=factory) - database = MagicMock() - monkeypatch.setattr(wiring, "sessionmaker", sessionmaker) +def test_build_real_call_consent_service_uses_the_shared_engine( + monkeypatch, mocker, sqlite_session_factory, sqlite_engine +) -> None: + database = _RouteDatabase(scoped_session(sqlite_session_factory), sqlite_engine) monkeypatch.setattr(wiring, "db", database) + maker = mocker.spy(wiring, "sessionmaker") consent = wiring.build_real_call_consent_service() assert isinstance(consent, BuilderRealCallConsentService) - assert consent._session_factory is factory - sessionmaker.assert_called_once_with(bind=database.engine, expire_on_commit=False) + assert consent._session_factory is maker.spy_return + maker.assert_called_once_with(bind=database.engine, expire_on_commit=False) + with consent._session_factory() as session: + assert session.get_bind() is sqlite_engine + assert session.scalar(select(1)) == 1 # -- real Flask/RESTX requests through the console blueprint and its error handlers -- @@ -1733,20 +1884,25 @@ _CONSENT_POSTS: list[tuple[str, dict[str, object]]] = [ class _ConsentHttp(NamedTuple): client: FlaskClient - consent: MagicMock - dispatch: MagicMock + consent: BuilderRealCallConsentService + dispatch: DifyBuilderService def _consent_http( - monkeypatch: pytest.MonkeyPatch, *, role: TenantAccountRole = TenantAccountRole.EDITOR, enabled: bool = True + monkeypatch: pytest.MonkeyPatch, mocker, *, role: TenantAccountRole = TenantAccountRole.EDITOR, enabled: bool = True ) -> _ConsentHttp: """Serve the real console blueprint with the real decorator stack; only identity and owners are faked.""" apply_config_overrides(monkeypatch, RBAC_ENABLED=False, LOGIN_DISABLED=False) monkeypatch.setattr(wraps_mod, "_is_setup_completed", lambda: True) monkeypatch.setattr("libs.login.check_csrf_token", lambda _request, _user_id: None) - monkeypatch.setattr(mod.FeatureService, "get_features", lambda _tenant_id: MagicMock(dify_builder_enabled=enabled)) - consent = MagicMock(spec=BuilderRealCallConsentService) - dispatch = MagicMock(spec=DifyBuilderService) + monkeypatch.setattr( + mod.FeatureService, "get_features", lambda _tenant_id: FeatureModel(dify_builder_enabled=enabled) + ) + consent = BuilderRealCallConsentService(session_factory_module.get_session_maker()) + for method in ("prepare", "confirm", "get"): + mocker.patch.object(consent, method) + dispatch = _route_service() + mocker.patch.object(dispatch, "execute_confirmed_tool") monkeypatch.setattr(mod, "build_real_call_consent_service", lambda: consent) monkeypatch.setattr(mod, "build_service", lambda: dispatch) account = Account(name="tester", email="tester@example.com") @@ -1776,38 +1932,39 @@ def _consent_http( ], ) def test_consent_posts_reject_non_json_and_malformed_bodies_with_fixed_400( + mocker, monkeypatch: pytest.MonkeyPatch, url: str, data: str, content_type: str | None, ) -> None: - http = _consent_http(monkeypatch) + http = _consent_http(monkeypatch, mocker) response = http.client.post(url, data=data, content_type=content_type) assert response.status_code == 400 assert response.get_json() == {"code": "bad_request"} - assert not http.consent.mock_calls - assert not http.dispatch.mock_calls + assert not _consent_calls(http.consent) + assert not http.dispatch.execute_confirmed_tool.call_args_list @pytest.mark.parametrize(("url", "valid_body"), _CONSENT_POSTS) def test_consent_posts_reject_json_that_is_not_the_declared_object( - monkeypatch: pytest.MonkeyPatch, url: str, valid_body: dict[str, object] + mocker, monkeypatch: pytest.MonkeyPatch, url: str, valid_body: dict[str, object] ) -> None: - http = _consent_http(monkeypatch) + http = _consent_http(monkeypatch, mocker) bodies: list[object] = [[], [valid_body], "text", 7, valid_body | {"guard": {}}] for body in bodies: response = http.client.post(url, json=body) assert response.status_code == 400 assert response.get_json() == {"code": "bad_request"} - assert not http.consent.mock_calls - assert not http.dispatch.mock_calls + assert not _consent_calls(http.consent) + assert not http.dispatch.execute_confirmed_tool.call_args_list -def test_consent_prepare_json_request_reaches_only_the_consent_owner(monkeypatch: pytest.MonkeyPatch) -> None: - http = _consent_http(monkeypatch) +def test_consent_prepare_json_request_reaches_only_the_consent_owner(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + http = _consent_http(monkeypatch, mocker) http.consent.prepare.return_value = _receipt() response = http.client.post(_CONSENTS_URL, json=_PREPARE_BODY) @@ -1815,7 +1972,7 @@ def test_consent_prepare_json_request_reaches_only_the_consent_owner(monkeypatch assert response.status_code == 201 assert response.get_json()["consent_id"] == _CONSENT_ID assert http.consent.prepare.call_args.kwargs["actor"].tenant_id == "ten-1" - assert not http.dispatch.mock_calls + assert not http.dispatch.execute_confirmed_tool.call_args_list _ALL_CONSENT_REQUESTS: list[tuple[str, str, dict[str, object] | None]] = [ @@ -1828,36 +1985,36 @@ _ALL_CONSENT_REQUESTS: list[tuple[str, str, dict[str, object] | None]] = [ @pytest.mark.parametrize(("method", "url", "body"), _ALL_CONSENT_REQUESTS) def test_inherited_edit_permission_guard_403_shape( - monkeypatch: pytest.MonkeyPatch, method: str, url: str, body: dict[str, object] | None + mocker, monkeypatch: pytest.MonkeyPatch, method: str, url: str, body: dict[str, object] | None ) -> None: - http = _consent_http(monkeypatch, role=TenantAccountRole.NORMAL) + http = _consent_http(monkeypatch, mocker, role=TenantAccountRole.NORMAL) response = http.client.open(url, method=method, json=body) assert response.status_code == 403 assert response.get_json() == {"code": "forbidden", "message": Forbidden.description, "status": 403} DifyBuilderRealCallForbiddenResponse.model_validate(response.get_json()) - assert not http.consent.mock_calls - assert not http.dispatch.mock_calls + assert not _consent_calls(http.consent) + assert not http.dispatch.execute_confirmed_tool.call_args_list @pytest.mark.parametrize(("method", "url", "body"), _ALL_CONSENT_REQUESTS) def test_inherited_feature_gate_403_shape( - monkeypatch: pytest.MonkeyPatch, method: str, url: str, body: dict[str, object] | None + mocker, monkeypatch: pytest.MonkeyPatch, method: str, url: str, body: dict[str, object] | None ) -> None: - http = _consent_http(monkeypatch, enabled=False) + http = _consent_http(monkeypatch, mocker, enabled=False) response = http.client.open(url, method=method, json=body) assert response.status_code == 403 assert response.get_json() == {"code": "feature_unavailable"} DifyBuilderRealCallForbiddenResponse.model_validate(response.get_json()) - assert not http.consent.mock_calls - assert not http.dispatch.mock_calls + assert not _consent_calls(http.consent) + assert not http.dispatch.execute_confirmed_tool.call_args_list -def test_domain_permission_refusal_403_shape(monkeypatch: pytest.MonkeyPatch) -> None: - http = _consent_http(monkeypatch) +def test_domain_permission_refusal_403_shape(mocker, monkeypatch: pytest.MonkeyPatch) -> None: + http = _consent_http(monkeypatch, mocker) http.consent.get.side_effect = BuilderExecutionPolicyError("execution_permission_denied") http.dispatch.execute_confirmed_tool.side_effect = Forbidden() @@ -1920,7 +2077,7 @@ class _RequestSqlWorld(NamedTuple): client: FlaskClient registry: scoped_session[OrmSession] witness: _OpenTransactions - consent: MagicMock + consent: BuilderRealCallConsentService boundaries: list[tuple[str, bool, int]] enqueued: list[tuple[str, Action, Actor, str]] # Per request, just before teardown removes the session: (scoped transaction open, @@ -1932,7 +2089,7 @@ _STAGED = select(DifySetup.version).where(DifySetup.version == "staged-by-anothe @pytest.fixture -def request_sql_world(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[_RequestSqlWorld]: +def request_sql_world(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, mocker) -> Iterator[_RequestSqlWorld]: """The real consent routes, decorator stack, uncached setup read and production authorizer on real SQL. ``db`` of the console wraps, the controller and the wiring is one real ``scoped_session`` over @@ -1947,7 +2104,7 @@ def request_sql_world(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterat TypeBase.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) registry = scoped_session(factory) - database = SimpleNamespace(session=registry, engine=engine) + database = _RouteDatabase(session=registry, engine=engine) monkeypatch.setattr(session_factory_module, "_session_maker", factory) monkeypatch.setattr(wraps_mod, "db", database) monkeypatch.setattr(wiring, "db", database) @@ -1987,10 +2144,10 @@ def request_sql_world(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterat def observe(name: str) -> None: boundaries.append((name, registry().in_transaction(), witness.count)) - def features(_tenant_id: str) -> MagicMock: + def features(_tenant_id: str) -> FeatureModel: # The feature gate's billing/enterprise read: the first external boundary after the auth wrappers. observe("feature") - return MagicMock(dify_builder_enabled=True) + return FeatureModel(dify_builder_enabled=True) monkeypatch.setattr(mod.FeatureService, "get_features", features) @@ -2005,20 +2162,22 @@ def request_sql_world(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterat observe("lease_release") super().release(session_id, token) - consent = MagicMock(spec=BuilderRealCallConsentService) - repo = MagicMock(spec=Repository) - repo.get_session.return_value = ( - DomainSession( - id=_SESSION_ID, - app_id="app-1", - tenant_id="ten-1", - owner_account_id=account.id, - entry_mode=EntryMode.BUILD, - current_state=PcState.BUILD_REVIEW, - version=3, - ), - DifyBuilderContext(), + consent = BuilderRealCallConsentService(session_factory_module.get_session_maker()) + for method in ("prepare", "confirm", "get", "reserve_execution", "replay_execution"): + mocker.patch.object(consent, method) + repo = SqlDifyBuilderRepository(session_factory_module.get_session_maker()) + stored_session = DomainSession( + id=_SESSION_ID, + app_id="app-1", + tenant_id="ten-1", + owner_account_id=account.id, + entry_mode=EntryMode.BUILD, + current_state=PcState.BUILD_REVIEW, + version=3, ) + repo.create_session(stored_session, DifyBuilderContext(), []) + for version in (1, 2): + repo.compare_and_advance(stored_session.id, version, PcState.BUILD_REVIEW, DifyBuilderContext(), []) enqueued: list[tuple[str, Action, Actor, str]] = [] def enqueue(session_id: str, action: Action, actor: Actor, token: str) -> None: @@ -2089,7 +2248,7 @@ def enabled_rbac( ) -> dict[RBACPermission, bool]: """Keep the real locator/evaluator; replace only the external access RPC with a witness.""" world = request_sql_world - enabled = getattr(request, "param", True) + enabled = request.param if hasattr(request, "param") else True apply_config_overrides(monkeypatch, RBAC_ENABLED=enabled) permissions = {RBACPermission.APP_EDIT: True, RBACPermission.APP_TEST_AND_RUN: True} if enabled else {} @@ -2208,7 +2367,10 @@ def test_consent_owner_calls_start_without_request_sql( ) -> None: """The consent owner reads external metadata first; the wrappers' request reads must be over.""" world = request_sql_world - getattr(world.consent, owner_call).side_effect = _observed_consent(world, owner_call, _receipt()) + consent_method = {"prepare": world.consent.prepare, "confirm": world.consent.confirm, "get": world.consent.get}[ + owner_call + ] + consent_method.side_effect = _observed_consent(world, owner_call, _receipt()) response = world.client.open(url, method=method, json=body) diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 8bee3b90f6d..b87ca61000d 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -51,6 +51,7 @@ from models.account import AccountStatus, TenantAccountRole from models.dataset import RateLimitLog from services.entities.feature_entities import LicenseStatus from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.database_fixtures import BoundDatabase @pytest.fixture(autouse=True) @@ -1214,7 +1215,7 @@ class TestSystemSetup: ) -> scoped_session[Session]: """The request-scoped ``db.session`` the wrapped view and later wrappers share.""" registry = scoped_session(sqlite_session_factory) - monkeypatch.setattr(wraps_module, "db", SimpleNamespace(session=registry)) + monkeypatch.setattr(wraps_module, "db", BoundDatabase(registry)) return registry @pytest.mark.parametrize("complete", [True, False], ids=["complete", "missing"]) diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_config_runtime_access.py b/api/tests/unit_tests/controllers/inner_api/test_agent_config_runtime_access.py index 6c47e6e4c4f..0ee88dacc82 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_config_runtime_access.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_config_runtime_access.py @@ -1,6 +1,6 @@ """Actual registered config routes preserve strict versus legacy admission.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from flask import Flask @@ -83,7 +83,7 @@ def test_config_mutation_stays_closed_for_confirmed_runtime(app): ), patch( "controllers.inner_api.plugin.agent_config.AgentConfigService", - new=Mock(side_effect=AssertionError("write must not start")), + side_effect=AssertionError("write must not start"), ), ): response = app.test_client().post("/inner/api/agent/runtime/config/a/push", headers=headers, json={}) diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py b/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py index 712e561e94c..e43819d2a01 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py @@ -3,7 +3,7 @@ import json from collections.abc import Generator from contextlib import contextmanager -from unittest.mock import MagicMock, patch +from unittest.mock import patch from uuid import uuid4 import pytest @@ -18,6 +18,7 @@ from graphon.model_runtime.entities.message_entities import AssistantPromptMessa from services.agent_llm_inner_service import AgentLLMInnerServiceError, PreparedAgentLLMInvocation from services.entities.agent_llm_inner import AgentLLMInvokeRequest from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.core.model_fixtures import make_model_instance def _payload() -> dict[str, object]: @@ -62,7 +63,9 @@ def _flask_app() -> Flask: def test_post_streams_plugin_compatible_envelope() -> None: payload = _payload() request = AgentLLMInvokeRequest.model_validate(payload) - prepared = PreparedAgentLLMInvocation(request=request, model_instance=MagicMock()) + prepared = PreparedAgentLLMInvocation( + request=request, model_instance=make_model_instance(provider="openai", model="gpt-test") + ) chunk = LLMResultChunk( model="gpt-test", delta=LLMResultChunkDelta( @@ -128,7 +131,9 @@ def test_post_preserves_prompt_messages_without_transport_validation() -> None: def prepare(request: AgentLLMInvokeRequest) -> PreparedAgentLLMInvocation: captured["request"] = request - return PreparedAgentLLMInvocation(request=request, model_instance=MagicMock()) + return PreparedAgentLLMInvocation( + request=request, model_instance=make_model_instance(provider="openai", model="gpt-test") + ) with ( _agent_inner_auth(), @@ -199,7 +204,7 @@ def test_post_preserves_stream_quota_failure() -> None: payload = _payload() prepared = PreparedAgentLLMInvocation( request=AgentLLMInvokeRequest.model_validate(payload), - model_instance=MagicMock(), + model_instance=make_model_instance(provider="openai", model="gpt-test"), ) with ( _agent_inner_auth(), @@ -253,7 +258,8 @@ def test_native_stream_error_is_bounded_even_if_provider_raises_private_text( payload = {**_payload(), "native_dispatch": envelope().model_dump(mode="json")} prepared = PreparedAgentLLMInvocation( - request=AgentLLMInvokeRequest.model_validate(payload), model_instance=MagicMock() + request=AgentLLMInvokeRequest.model_validate(payload), + model_instance=make_model_instance(provider="openai", model="gpt-test"), ) app = _flask_app() diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_runtime_lineage.py b/api/tests/unit_tests/controllers/inner_api/test_agent_runtime_lineage.py index df8ffea9cde..ec26a776cc5 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_runtime_lineage.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_runtime_lineage.py @@ -2,7 +2,6 @@ import base64 import json -from types import SimpleNamespace from unittest.mock import patch import pytest @@ -10,6 +9,10 @@ from flask import Flask from controllers.inner_api import bp from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.services.dify_builder import native_owner_fixtures as operation_fixtures +from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import resolved_context + +operations_service = operation_fixtures.operations_service ROUTES = [ ("POST", "llm/invoke"), @@ -205,9 +208,8 @@ def test_private_read_failure_never_reaches_model_service(app, native_markers): @pytest.mark.parametrize("legacy", [False, True]) @pytest.mark.parametrize(("owned_tenant", "status"), [("tenant-1", 200), ("foreign", 403)]) def test_genuine_plugin_and_verified_ordinary_retain_real_knowledge_resource_authorization( - app, legacy, owned_tenant, status, nullable_envelope + app, legacy, owned_tenant, status, nullable_envelope, sqlite_session, monkeypatch ): - from contextlib import contextmanager from tests.unit_tests.controllers.inner_api.test_knowledge_retrieval import _payload from tests.unit_tests.services.dify_builder.test_agent_endpoint_lineage import owner @@ -216,39 +218,34 @@ def test_genuine_plugin_and_verified_ordinary_retain_real_knowledge_resource_aut app.extensions["native_agent_endpoint_lineage"] = lineage accesses = [] - class SQL: - def scalar(self, _query): - accesses.append("app") - return SimpleNamespace(id="app-1", tenant_id=owned_tenant) + from core.rag.retrieval.dataset_retrieval import DatasetRetrieval + from models.dataset import Dataset + from models.model import App - def scalars(self, _query): - accesses.append("datasets") - return SimpleNamespace(all=lambda: [SimpleNamespace(id="dataset-1", tenant_id=owned_tenant)]) + sqlite_session.add_all( + [ + App( + id="app-1", tenant_id=owned_tenant, name="Caller", mode="workflow", enable_site=False, enable_api=False + ), + Dataset(id="dataset-1", tenant_id=owned_tenant, name="Knowledge", created_by="actor"), + ] + ) + sqlite_session.commit() + scalar = sqlite_session.scalar + scalars = sqlite_session.scalars - def commit(self): - pass + def read_app(query): + accesses.append("app") + return scalar(query) - def rollback(self): - pass + def read_datasets(query): + accesses.append("datasets") + return scalars(query) - @contextmanager - def sql(): - yield SQL() + monkeypatch.setattr(sqlite_session, "scalar", read_app) + monkeypatch.setattr(sqlite_session, "scalars", read_datasets) + monkeypatch.setattr(DatasetRetrieval, "knowledge_retrieval", lambda _self, **_kwargs: []) - usage = { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "prompt_unit_price": "0", - "completion_unit_price": "0", - "prompt_price_unit": "0", - "completion_price_unit": "0", - "prompt_price": "0", - "completion_price": "0", - "total_price": "0", - "latency": 0, - } - rag = SimpleNamespace(knowledge_retrieval=lambda **_kwargs: [], llm_usage=usage) route = "/inner/api/knowledge/retrieve" if legacy else "/inner/api/agent/runtime/knowledge/retrieve" headers = ( {"X-Inner-Api-Key": "plugin-key"} @@ -258,13 +255,7 @@ def test_genuine_plugin_and_verified_ordinary_retain_real_knowledge_resource_aut "X-Dify-Agent-Run": encoded(transport), } ) - with ( - patch("core.db.session_factory.session_factory.create_session", sql), - patch( - "services.knowledge_retrieval_inner_service.DatasetRetrieval", - return_value=rag, - ), - ): + with patch("core.db.session_factory.session_factory.create_session", lambda: sqlite_session): payload = _payload() if nullable_envelope: payload["native_dispatch"] = None @@ -295,7 +286,9 @@ def test_ordinary_private_identity_rejects_native_envelope_before_service(app, p @pytest.mark.parametrize("mode", ["prepare-run", "prepared-config"]) -def test_strict_initial_control_reaches_exact_s1_owner_without_effect_envelope(app, mode): +def test_strict_initial_control_reaches_exact_s1_owner_without_effect_envelope( + app, mode, operations_service, monkeypatch +): from services.dify_builder.native_testing.agent_preparation import ( AgentPreparationService, ) @@ -308,21 +301,17 @@ def test_strict_initial_control_reaches_exact_s1_owner_without_effect_envelope(a app.extensions["native_agent_endpoint_lineage"] = lineage calls = [] - class Store: - def load_prepared_config(self, **_kwargs): - calls.append("read-config") - return lineage.resolve( - principal=principal, - selection=transport, - boundary="prepared-config", - caller={}, - ).config + def load_prepared_config(**_kwargs): + calls.append("read-config") + return lineage.resolve(principal=principal, selection=transport, boundary="prepared-config", caller={}).config + store = operations_service.state_store + monkeypatch.setattr(store, "load_prepared_config", load_prepared_config) # Prepared-config uses the actual S1 read service. Bind uses the covered S1 # service separately below, rather than manufacturing another create owner. if mode == "prepared-config": service = AgentPreparationService( - state_store=Store(), + state_store=store, backend=lineage.backend, workspace=None, config_builder=None, @@ -331,25 +320,16 @@ def test_strict_initial_control_reaches_exact_s1_owner_without_effect_envelope(a else: config = lineage.resolve(principal=principal, selection=transport, boundary="prepare-run", caller={}).config - class BindStore: - def load_context(self, **_kwargs): - return SimpleNamespace(generation=4) + def bind_agent_run(**kwargs): + calls.append(("bind", kwargs["association"].agent_run_id, kwargs["expected_invocation_generation"])) + return "association" - def load_prepared_config(self, **_kwargs): - return config - - def bind_agent_run(self, **kwargs): - calls.append( - ( - "bind", - kwargs["association"].agent_run_id, - kwargs["expected_invocation_generation"], - ) - ) - return "association" + monkeypatch.setattr(store, "load_context", lambda **_kwargs: resolved_context()) + monkeypatch.setattr(store, "load_prepared_config", lambda **_kwargs: config) + monkeypatch.setattr(store, "bind_agent_run", bind_agent_run) service = AgentPreparationService( - state_store=BindStore(), + state_store=store, backend=lineage.backend, workspace=None, config_builder=None, @@ -518,7 +498,7 @@ def test_control_handler_itself_cannot_consume_a_physical_permit(app): bridge.assert_not_called() -def test_run_bound_claim_route_reaches_shell_owner_only_after_exact_lineage(app): +def test_run_bound_claim_route_reaches_shell_owner_only_after_exact_lineage(app, operations_service, monkeypatch): from runtime_contracts.builder.execution import BuilderExecutionPolicyError from tests.unit_tests.services.dify_builder.test_agent_endpoint_lineage import owner from tests.unit_tests.services.dify_builder.test_agent_operation_owner import envelope @@ -531,9 +511,10 @@ def test_run_bound_claim_route_reaches_shell_owner_only_after_exact_lineage(app) if envelope.intent.kind != "shell": raise BuilderExecutionPolicyError("native_agent_boundary_unavailable") + monkeypatch.setattr(operations_service, "claim_shell_dispatch", claim) with patch( "controllers.inner_api.agent.native_testing.agent_operations_for_ref", - return_value=SimpleNamespace(claim_shell_dispatch=claim), + return_value=operations_service, ): headers = {"Authorization": "Bearer agent-token", "X-Dify-Agent-Run": encoded(transport)} payload = wire.model_dump(mode="json") @@ -617,7 +598,7 @@ def test_registered_resumed_config_read_requires_current_bound_exact_owner(app, @pytest.mark.parametrize("reason", ["native_agent_risk_assessment_unavailable", "private-token-and-call-arguments"]) -def test_prepare_intent_refusal_logs_only_bounded_policy_category(app, caplog, reason): +def test_prepare_intent_refusal_logs_only_bounded_policy_category(app, caplog, reason, operations_service, monkeypatch): from runtime_contracts.builder.execution import BuilderExecutionPolicyError from tests.unit_tests.services.dify_builder.test_agent_endpoint_lineage import owner from tests.unit_tests.services.dify_builder.test_agent_operation_owner import envelope @@ -636,10 +617,11 @@ def test_prepare_intent_refusal_logs_only_bounded_policy_category(app, caplog, r def refused(**_kwargs): raise BuilderExecutionPolicyError(reason) + monkeypatch.setattr(operations_service, "prepare_intent", refused) with ( patch( "controllers.inner_api.agent.native_testing.agent_operations_for_ref", - return_value=SimpleNamespace(prepare_intent=refused), + return_value=operations_service, ), caplog.at_level("WARNING"), ): 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 470d2505109..1d6d3275719 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 @@ -8,21 +8,25 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from flask import Flask from pydantic import BaseModel, ValidationError from sqlalchemy import Engine, event -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from constants import UUID_NIL from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig from core.app.apps.exc import GenerateTaskStoppedError +from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from core.ops.ops_trace_manager import TraceQueueManager +from graphon.variable_loader import DUMMY_VARIABLE_LOADER from libs.datetime_utils import naive_utc_now from models.account import Account from models.enums import ConversationFromSource, EndUserType, MessageStatus from models.model import App, AppMode, Conversation, EndUser, Message from models.workflow import Workflow, WorkflowType from services.workflow_run_agg import WorkflowRunAgg +from services.workflow_runtime import chatflow_generator as chatflow_generator_module from services.workflow_runtime.chatflow_generator import AdvancedChatAppGenerator from services.workflow_runtime.chatflow_pipeline import ( ConversationSnapshot, @@ -30,6 +34,7 @@ from services.workflow_runtime.chatflow_pipeline import ( WorkflowSnapshot, ) from tests.unit_tests.config_override import apply_config_overrides +from tests.unit_tests.core.app.apps.test_workflow_app_generator import _repositories def _make_app(*, app_id: str = "app", tenant_id: str = "tenant") -> App: @@ -240,10 +245,8 @@ class TestAdvancedChatAppGeneratorInternals: "services.workflow_runtime.chatflow_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", lambda **kwargs: SimpleNamespace(), ) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", unbound_session) monkeypatch.setattr(generator, "_prepare_user_inputs", lambda **kwargs: kwargs["user_inputs"]) dummy_trace_queue_manager = type( @@ -363,10 +366,8 @@ class TestAdvancedChatAppGeneratorInternals: lambda **kwargs: SimpleNamespace(repo="node"), ) monkeypatch.setattr("services.workflow_runtime.chatflow_generator.DraftVarLoader", lambda **kwargs: var_loader) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", unbound_session) class _DraftVarService: def __init__(self, session): @@ -428,10 +429,8 @@ class TestAdvancedChatAppGeneratorInternals: lambda **kwargs: SimpleNamespace(repo="node"), ) monkeypatch.setattr("services.workflow_runtime.chatflow_generator.DraftVarLoader", lambda **kwargs: var_loader) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", unbound_session) class _DraftVarService: def __init__(self, session): @@ -537,10 +536,8 @@ class TestAdvancedChatAppGeneratorInternals: return False monkeypatch.setattr("services.workflow_runtime.chatflow_generator.threading.Thread", _Thread) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", sqlite_session) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") monkeypatch.setattr( generator, @@ -651,10 +648,8 @@ class TestAdvancedChatAppGeneratorInternals: return False monkeypatch.setattr("services.workflow_runtime.chatflow_generator.threading.Thread", _Thread) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", sqlite_session) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") monkeypatch.setattr( generator, @@ -708,8 +703,10 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + monkeypatch.setattr(generator, "_get_conversation", lambda _: _make_conversation(conversation_id="conv")) + monkeypatch.setattr( + generator, "_get_message", lambda _: _make_message(message_id="msg", conversation_id="conv") + ) @contextmanager def _fake_context[**P](*args: P.args, **kwargs: P.kwargs): @@ -717,10 +714,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("services.workflow_runtime.chatflow_generator.preserve_flask_contexts", _fake_context) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", sqlite_session) with pytest.raises(ValueError, match="Workflow not found"): generator._generate_worker( @@ -757,8 +752,10 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + monkeypatch.setattr(generator, "_get_conversation", lambda _: _make_conversation(conversation_id="conv")) + monkeypatch.setattr( + generator, "_get_message", lambda _: _make_message(message_id="msg", conversation_id="conv") + ) sqlite_session.add(_make_workflow()) sqlite_session.commit() @@ -768,10 +765,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("services.workflow_runtime.chatflow_generator.preserve_flask_contexts", _fake_context) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", sqlite_session) with pytest.raises(ValueError, match="App not found"): generator._generate_worker( @@ -809,8 +804,10 @@ class TestAdvancedChatAppGeneratorInternals: ) queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + monkeypatch.setattr(generator, "_get_conversation", lambda _: _make_conversation(conversation_id="conv")) + monkeypatch.setattr( + generator, "_get_message", lambda _: _make_message(message_id="msg", conversation_id="conv") + ) @contextmanager def _fake_context[**P](*args: P.args, **kwargs: P.kwargs): @@ -831,10 +828,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("services.workflow_runtime.chatflow_generator.AdvancedChatAppRunner", _Runner) restore_workflow_run_graph = MagicMock() monkeypatch.setattr(generator, "_restore_workflow_run_graph", restore_workflow_run_graph) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", sqlite_session) generator._generate_worker( flask_app=SimpleNamespace(), @@ -885,8 +880,10 @@ class TestAdvancedChatAppGeneratorInternals: raise AssertionError("validation error should be created") queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + monkeypatch.setattr(generator, "_get_conversation", lambda _: _make_conversation(conversation_id="conv")) + monkeypatch.setattr( + generator, "_get_message", lambda _: _make_message(message_id="msg", conversation_id="conv") + ) sqlite_session.add_all([_make_app(), _make_workflow()]) sqlite_session.commit() @@ -904,10 +901,8 @@ class TestAdvancedChatAppGeneratorInternals: raise validation_error monkeypatch.setattr("services.workflow_runtime.chatflow_generator.AdvancedChatAppRunner", _Runner) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", sqlite_session) generator._generate_worker( flask_app=SimpleNamespace(), @@ -963,8 +958,10 @@ class TestAdvancedChatAppGeneratorInternals: ) queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + monkeypatch.setattr(generator, "_get_conversation", lambda _: _make_conversation(conversation_id="conv")) + monkeypatch.setattr( + generator, "_get_message", lambda _: _make_message(message_id="msg", conversation_id="conv") + ) monkeypatch.setattr("services.workflow_runtime.chatflow_generator.preserve_flask_contexts", _fake_context) monkeypatch.setattr( @@ -972,10 +969,8 @@ class TestAdvancedChatAppGeneratorInternals: _make_runner(raised_error), ) apply_config_overrides(monkeypatch, DEBUG=True) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", sqlite_session) generator._generate_worker( flask_app=SimpleNamespace(), @@ -1130,8 +1125,10 @@ class TestAdvancedChatAppGeneratorInternals: queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + monkeypatch.setattr(generator, "_get_conversation", lambda _: _make_conversation(conversation_id="conv")) + monkeypatch.setattr( + generator, "_get_message", lambda _: _make_message(message_id="msg", conversation_id="conv") + ) sqlite_session.add_all([_make_app(), _make_workflow(), _make_end_user()]) sqlite_session.commit() @@ -1152,10 +1149,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("services.workflow_runtime.chatflow_generator.preserve_flask_contexts", _fake_context) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", sqlite_session) generator._generate_worker( flask_app=SimpleNamespace(), @@ -1215,10 +1210,8 @@ class TestAdvancedChatAppGeneratorInternals: "services.workflow_runtime.chatflow_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", lambda **kwargs: SimpleNamespace(), ) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", unbound_session) captured = {} @@ -1290,10 +1283,8 @@ class TestAdvancedChatAppGeneratorInternals: "services.workflow_runtime.chatflow_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", lambda **kwargs: SimpleNamespace(), ) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", unbound_session) captured = {} @@ -1447,7 +1438,7 @@ def test_builder_worker_refuses_changed_chatflow_before_runner(monkeypatch, sqli from services.dify_builder.revision import execution_revision - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(execution_driver=WorkflowRunAgg.run) generator._dialogue_count = 1 app = _make_app() workflow = _make_workflow() @@ -1470,27 +1461,36 @@ def test_builder_worker_refuses_changed_chatflow_before_runner(monkeypatch, sqli monkeypatch.setattr( "services.workflow_runtime.chatflow_generator.preserve_flask_contexts", lambda *a, **k: nullcontext() ) - monkeypatch.setattr( - "services.workflow_runtime.chatflow_generator.db", SimpleNamespace(engine=sqlite_engine, session=sqlite_session) - ) + monkeypatch.setattr(type(chatflow_generator_module.db), "engine", property(lambda _db: sqlite_engine)) + monkeypatch.setattr(chatflow_generator_module.db, "session", sqlite_session) reached = [] - def first_node(**kwargs): - reached.append(kwargs) - return SimpleNamespace(run=lambda: None) + def prepare(runner): + reached.append(runner) - monkeypatch.setattr("services.workflow_runtime.chatflow_generator.AdvancedChatAppRunner", first_node) - queue = MagicMock() + monkeypatch.setattr(chatflow_generator_module.AdvancedChatAppRunner, "prepare", prepare) + queue = MessageBasedAppQueueManager( + task_id="task", + user_id=entity.user_id, + invoke_from=entity.invoke_from, + conversation_id="conversation", + app_mode=app.mode, + message_id="message", + ) + errors = [] + monkeypatch.setattr(queue, "publish_error", lambda error, *_args: errors.append(error)) + repositories = _repositories(sessionmaker(sqlite_engine), app, _make_end_user()) generator._generate_worker( - flask_app=SimpleNamespace(), + flask_app=Flask(__name__), application_generate_entity=entity, queue_manager=queue, conversation_id="conversation", message_id="message", context=contextvars.copy_context(), - variable_loader=SimpleNamespace(), - workflow_execution_repository=SimpleNamespace(), - workflow_node_execution_repository=SimpleNamespace(), + variable_loader=DUMMY_VARIABLE_LOADER, + workflow_execution_repository=repositories[0], + workflow_node_execution_repository=repositories[1], ) assert reached == [] - assert queue.publish_error.call_args.args[0].reason_code == "stale_execution_revision" + assert len(errors) == 1 + assert errors[0].reason_code == "stale_execution_revision" 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 d040dea9881..2bf912ccb7c 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 @@ -169,18 +169,7 @@ def test_handle_workflow_paused_event_does_not_overwrite_a_concurrent_stop( def test_resume_appends_chunks_to_paused_answer(unbound_session: Session) -> None: - app_config = SimpleNamespace(app_id="app-1", tenant_id="tenant-1", sensitive_word_avoidance=None) - application_generate_entity = SimpleNamespace( - app_config=app_config, - files=[], - workflow_run_id="run-1", - query="hello", - invoke_from=InvokeFrom.WEB_APP, - inputs={}, - task_id="task-1", - trace_manager=None, - extras={}, - ) + application_generate_entity = _build_generate_entity().model_copy(update={"trace_manager": None, "extras": {}}) queue_manager = SimpleNamespace(graph_runtime_state=None) conversation = pipeline_module.ConversationSnapshot(id="conversation-1", mode=AppMode.ADVANCED_CHAT) message = pipeline_module.MessageSnapshot( @@ -223,17 +212,8 @@ def test_emit_message_trace_enqueues_message_trace() -> None: from core.ops.entities.trace_entity import TraceTaskName mock_trace_manager = mock.MagicMock() - app_config = SimpleNamespace(app_id="app-1", tenant_id="tenant-1", sensitive_word_avoidance=None) - application_generate_entity = SimpleNamespace( - app_config=app_config, - files=[], - workflow_run_id="run-1", - query="hello", - invoke_from=InvokeFrom.WEB_APP, - inputs={}, - task_id="task-1", - trace_manager=mock_trace_manager, - extras={"trace_session_id": "session-trace-1"}, + application_generate_entity = _build_generate_entity().model_copy( + update={"trace_manager": mock_trace_manager, "extras": {"trace_session_id": "session-trace-1"}} ) queue_manager = SimpleNamespace(graph_runtime_state=None) conversation = pipeline_module.ConversationSnapshot(id="conv-1", mode=AppMode.ADVANCED_CHAT) @@ -300,17 +280,8 @@ def test_save_message_does_not_emit_trace() -> None: when the message is later resumed. """ mock_trace_manager = mock.MagicMock() - app_config = SimpleNamespace(app_id="app-1", tenant_id="tenant-1", sensitive_word_avoidance=None) - application_generate_entity = SimpleNamespace( - app_config=app_config, - files=[], - workflow_run_id="run-1", - query="hello", - invoke_from=InvokeFrom.WEB_APP, - inputs={}, - task_id="task-1", - trace_manager=mock_trace_manager, - extras={"trace_session_id": "session-trace-1"}, + application_generate_entity = _build_generate_entity().model_copy( + update={"trace_manager": mock_trace_manager, "extras": {"trace_session_id": "session-trace-1"}} ) queue_manager = SimpleNamespace(graph_runtime_state=None) conversation = pipeline_module.ConversationSnapshot(id="conv-1", mode=AppMode.ADVANCED_CHAT) @@ -367,18 +338,7 @@ def test_save_message_does_not_emit_trace() -> None: def test_emit_message_trace_skips_when_no_trace_manager() -> None: """When trace_manager is None, _emit_message_trace and _save_message must not fail.""" - app_config = SimpleNamespace(app_id="app-1", tenant_id="tenant-1", sensitive_word_avoidance=None) - application_generate_entity = SimpleNamespace( - app_config=app_config, - files=[], - workflow_run_id="run-1", - query="hello", - invoke_from=InvokeFrom.WEB_APP, - inputs={}, - task_id="task-1", - trace_manager=None, - extras={}, - ) + application_generate_entity = _build_generate_entity().model_copy(update={"trace_manager": None, "extras": {}}) queue_manager = SimpleNamespace(graph_runtime_state=None) conversation = pipeline_module.ConversationSnapshot(id="conv-1", mode=AppMode.ADVANCED_CHAT) message = pipeline_module.MessageSnapshot( 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 26e103a5fd9..94a750a16dd 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 @@ -3,12 +3,12 @@ from __future__ import annotations from collections.abc import Generator +from dataclasses import dataclass from typing import cast -from unittest.mock import MagicMock import pytest from flask import Flask -from sqlalchemy import Select +from sqlalchemy import Engine, Select from sqlalchemy.orm import Session from werkzeug.local import LocalProxy @@ -20,6 +20,8 @@ from core.app.entities.app_invoke_entities import ( WorkflowAppGenerateEntity, ) from graphon.graph.graph import Graph +from graphon.runtime import RuntimeState, VariablePool +from graphon.variable_loader import DUMMY_VARIABLE_LOADER from models.model import Account, App from models.workflow import Workflow from runtime_contracts.builder.execution import JsonToolExecutionContext @@ -30,6 +32,98 @@ from tests.unit_tests.services.dify_builder.test_execution_policy_service import from tests.unit_tests.services.dify_builder.test_execution_policy_service import owned as owned # noqa: PLC0414 +@dataclass(frozen=True) +class _AdmissionDatabase: + engine: Engine + + +def _forbidden_driver(*_args, **_kwargs): + raise AssertionError("execution driver must not run before admission") + + +@pytest.fixture +def admission(owned, mocker): + from services.dify_builder.dify_port import _BuilderExecutionLaunch + from services.dify_builder.execution_policy_service import BuilderExecutionPolicyService + + launch = _BuilderExecutionLaunch(BuilderExecutionPolicyService(owned[0]), context()) + mocker.spy(launch, "on_refusal") + mocker.spy(launch, "admit_raw") + return launch + + +@pytest.fixture +def recorder(owned): + from services.dify_builder.execution_policy_service import BuilderExecutionPolicyService, ServiceExecutionRecorder + + return ServiceExecutionRecorder(BuilderExecutionPolicyService(owned[0]), context(), "run", "task") + + +@pytest.fixture +def workflow_repositories(owned): + from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository + from core.repositories.sqlalchemy_workflow_node_execution_repository import ( + SQLAlchemyWorkflowNodeExecutionRepository, + ) + + factory, actor, app, _workflow, *_ = owned + with factory() as session: + user = session.get(Account, actor.account_id) + assert user is not None + args = { + "session_factory": factory, + "tenant_id": actor.tenant_id, + "user": user, + "app_id": app.id, + "triggered_from": None, + } + return SQLAlchemyWorkflowExecutionRepository(**args), SQLAlchemyWorkflowNodeExecutionRepository(**args) + + +@pytest.fixture +def workflow_queue(mocker): + import core.app.apps.base_app_queue_manager as queue_module + from core.app.apps.execution_coordinator import AppExecutionCoordinator + from services.workflow_runtime.workflow_queue import WorkflowAppQueueManager + + mocker.patch.object(queue_module.redis_client, "setex", return_value=True) + mocker.patch.object(queue_module.redis_client, "delete", return_value=1) + mocker.patch.object(queue_module.redis_client, "get", return_value=None) + mocker.patch.object(AppExecutionCoordinator, "start_watchdog", return_value=None) + queue = WorkflowAppQueueManager("task", "account", InvokeFrom.DEBUGGER, "workflow") + mocker.spy(queue, "publish_error") + return queue + + +def _install_paused_marker(module, owned, entity, mocker, monkeypatch): + from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper + from models.workflow import WorkflowPause + from repositories.sqlalchemy_api_workflow_run_repository import ( + DifyAPISQLAlchemyWorkflowRunRepository, + _PrivateWorkflowPauseEntity, + ) + + factory = owned[0] + with factory() as db_session: + engine = db_session.get_bind() + monkeypatch.setattr(module, "db", _AdmissionDatabase(engine)) + repository = DifyAPISQLAlchemyWorkflowRunRepository(factory) + pause = _PrivateWorkflowPauseEntity( + pause_model=WorkflowPause(workflow_id="workflow", workflow_run_id="run", state_object_key="state"), + reason_models=[], + ) + mocker.patch.object(pause, "get_state", return_value=b"state") + mocker.patch.object(repository, "get_workflow_pause", return_value=pause) + mocker.spy(repository, "resume_workflow_pause") + mocker.patch.object(module.DifyAPIRepositoryFactory, "create_api_workflow_run_repository", return_value=repository) + state = WorkflowResumptionContext.model_construct( + generate_entity=_WorkflowGenerateEntityWrapper.model_construct(entity=entity), + serialized_graph_runtime_state="{}", + ) + mocker.patch.object(module.WorkflowResumptionContext, "loads", return_value=state) + return repository + + def context() -> BuilderExecutionContext: return BuilderExecutionContext( request_id="request", @@ -127,7 +221,9 @@ def test_json_marker_round_trips_existing_native_carriers(carrier: str) -> None: assert restored.builder_execution.qualified_tool == marker.qualified_tool -def test_json_celery_refuses_before_logging_or_runner(monkeypatch: pytest.MonkeyPatch) -> None: +def test_json_celery_refuses_before_logging_or_runner( + mocker, +) -> None: import tasks.app_generate.workflow_execute_task as module from tests.unit_tests.services.dify_builder.test_execution_policy_service import _json_context @@ -141,10 +237,8 @@ def test_json_celery_refuses_before_logging_or_runner(monkeypatch: pytest.Monkey invoke_from=InvokeFrom.DEBUGGER, builder_execution=marker, ) - log = MagicMock() - runner = MagicMock() - monkeypatch.setattr(module.logger, "info", log) - monkeypatch.setattr(module, "_AppRunner", runner) + log = mocker.patch.object(module.logger, "info") + runner = mocker.patch.object(module._AppRunner, "__init__", side_effect=AssertionError("runner forbidden")) with pytest.raises(BuilderExecutionPolicyError, match="unsupported_execution_transport"): module.workflow_based_app_execution_task.run(params.model_dump_json()) log.assert_not_called() @@ -152,25 +246,24 @@ def test_json_celery_refuses_before_logging_or_runner(monkeypatch: pytest.Monkey @pytest.mark.parametrize("transport", ["celery", "files"]) -def test_json_generate_refuses_unsupported_transport_before_workflow_read( - monkeypatch: pytest.MonkeyPatch, transport: str -) -> None: +def test_json_generate_refuses_unsupported_transport_before_workflow_read(admission, mocker, transport: str) -> None: from services.app_generate_service import AppGenerateService from tests.unit_tests.services.dify_builder.test_execution_policy_service import _json_context marker = _json_context() account = Account(name="Owner", email="owner@example.invalid") account.id = marker.actor_id - read = MagicMock(side_effect=AssertionError("workflow read forbidden")) - admit = MagicMock() - monkeypatch.setattr(AppGenerateService, "_get_workflow", read) + read = mocker.patch.object( + AppGenerateService, "_get_workflow", side_effect=AssertionError("workflow read forbidden") + ) + admit = admission with pytest.raises(BuilderExecutionPolicyError, match="unsupported_execution_transport"): AppGenerateService.generate( app_model=App(id=marker.app_id, tenant_id=marker.tenant_id, mode="workflow"), user=account, args={"inputs": {}, "files": [{"id": "file"}]} if transport == "files" else {"inputs": {}}, invoke_from=InvokeFrom.DEBUGGER, - session=MagicMock(), + session=Session(), workflow_execution_mode="celery" if transport == "celery" else "in_process", builder_execution=marker, builder_execution_admit=admit, @@ -180,20 +273,19 @@ def test_json_generate_refuses_unsupported_transport_before_workflow_read( @pytest.mark.parametrize("transport", ["missing_issuer", "files", "depth"]) -def test_json_workflow_generator_refuses_before_file_or_graph_effect( - monkeypatch: pytest.MonkeyPatch, transport: str -) -> None: +def test_json_workflow_generator_refuses_before_file_or_graph_effect(admission, mocker, transport: str) -> None: from services.workflow_runtime.workflow_generator import WorkflowAppGenerator from tests.unit_tests.services.dify_builder.test_execution_policy_service import _json_context marker = _json_context() account = Account(name="Owner", email="owner@example.invalid") account.id = marker.actor_id - admit = MagicMock() - file_scope = MagicMock(side_effect=AssertionError("file scope forbidden")) - monkeypatch.setattr(WorkflowAppGenerator, "_bind_file_access_scope", file_scope) + admit = admission + file_scope = mocker.patch.object( + WorkflowAppGenerator, "_bind_file_access_scope", side_effect=AssertionError("file scope forbidden") + ) with pytest.raises(BuilderExecutionPolicyError, match="unsupported_execution_transport"): - WorkflowAppGenerator(execution_driver=MagicMock()).generate( + WorkflowAppGenerator(execution_driver=_forbidden_driver).generate( app_model=App(id=marker.app_id, tenant_id=marker.tenant_id, mode="workflow"), workflow=Workflow(id=marker.workflow_id), user=account, @@ -210,24 +302,29 @@ def test_json_workflow_generator_refuses_before_file_or_graph_effect( admit.on_refusal.assert_called_once() -def test_json_resume_refuses_before_trace_or_graph_restore() -> None: +def test_json_resume_refuses_before_trace_or_graph_restore( + workflow_repositories, +) -> None: from services.workflow_runtime.workflow_generator import WorkflowAppGenerator from tests.unit_tests.services.dify_builder.test_execution_policy_service import _json_context marker = _json_context() with pytest.raises(BuilderExecutionPolicyError, match="unsupported_execution_transport"): - WorkflowAppGenerator(execution_driver=MagicMock()).resume( + WorkflowAppGenerator(execution_driver=_forbidden_driver).resume( app_model=App(id=marker.app_id, tenant_id=marker.tenant_id, mode="workflow"), workflow=Workflow(id=marker.workflow_id), - user=MagicMock(), + user=Account(name="Owner", email="owner@example.invalid"), application_generate_entity=WorkflowAppGenerateEntity.model_construct(builder_execution=marker), - graph_runtime_state=MagicMock(), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), + graph_runtime_state=RuntimeState(workflow_id=marker.workflow_id, variable_pool=VariablePool(), start_at=0), + workflow_execution_repository=workflow_repositories[0], + workflow_node_execution_repository=workflow_repositories[1], ) -def test_json_factory_refuses_nonissued_recorder_before_ordinary_setup(monkeypatch: pytest.MonkeyPatch) -> None: +def test_json_factory_refuses_nonissued_recorder_before_ordinary_setup( + mocker, + recorder, +) -> None: import time from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY @@ -236,8 +333,9 @@ def test_json_factory_refuses_nonissued_recorder_before_ordinary_setup(monkeypat 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) + ordinary = mocker.patch.object( + node_factory, "build_dify_model_access", side_effect=AssertionError("ordinary setup forbidden") + ) params = InitParams( workflow_id=marker.workflow_id, graph_config={"nodes": [], "edges": []}, @@ -255,11 +353,11 @@ def test_json_factory_refuses_nonissued_recorder_before_ordinary_setup(monkeypat ) state = RuntimeState(workflow_id=params.workflow_id, 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)) + node_factory.DifyNodeFactory(params, state, execution_recorder=recorder) ordinary.assert_not_called() -def test_json_task_resume_refuses_before_snapshot_restore(monkeypatch: pytest.MonkeyPatch) -> None: +def test_json_task_resume_refuses_before_snapshot_restore(mocker, owned, monkeypatch: pytest.MonkeyPatch) -> None: import tasks.app_generate.workflow_execute_task as module from tests.unit_tests.services.dify_builder.test_execution_policy_service import _json_context @@ -273,23 +371,17 @@ def test_json_task_resume_refuses_before_snapshot_restore(monkeypatch: pytest.Mo invoke_from=InvokeFrom.DEBUGGER, builder_execution=marker, ) - monkeypatch.setattr(module, "db", MagicMock()) - repository = MagicMock() - monkeypatch.setattr( - module.DifyAPIRepositoryFactory, "create_api_workflow_run_repository", MagicMock(return_value=repository) - ) - state = MagicMock() - state.get_generate_entity.return_value = entity - monkeypatch.setattr(module.WorkflowResumptionContext, "loads", lambda _: state) - restore = MagicMock(side_effect=AssertionError("restore forbidden")) - monkeypatch.setattr(module.RuntimeState, "from_snapshot", restore) + repository = _install_paused_marker(module, owned, entity, mocker, monkeypatch) + restore = mocker.patch.object(module.RuntimeState, "from_snapshot", side_effect=AssertionError("restore forbidden")) with pytest.raises(BuilderExecutionPolicyError, match="unsupported_execution_transport"): module._resume_app_execution({"workflow_run_id": "run"}) restore.assert_not_called() repository.resume_workflow_pause.assert_not_called() -def test_celery_refuses_marker_before_logging_or_initialization(monkeypatch: pytest.MonkeyPatch) -> None: +def test_celery_refuses_marker_before_logging_or_initialization( + mocker, +) -> None: import tasks.app_generate.workflow_execute_task as module params = AppExecutionParams( @@ -301,10 +393,8 @@ def test_celery_refuses_marker_before_logging_or_initialization(monkeypatch: pyt invoke_from=InvokeFrom.DEBUGGER, builder_execution=context(), ) - log = MagicMock() - runner = MagicMock() - monkeypatch.setattr(module.logger, "info", log) - monkeypatch.setattr(module, "_AppRunner", runner) + log = mocker.patch.object(module.logger, "info") + runner = mocker.patch.object(module._AppRunner, "__init__", side_effect=AssertionError("runner forbidden")) with pytest.raises(BuilderExecutionPolicyError, match="unsupported_execution_transport"): module.workflow_based_app_execution_task.run(params.model_dump_json()) log.assert_not_called() @@ -314,7 +404,7 @@ def test_celery_refuses_marker_before_logging_or_initialization(monkeypatch: pyt @pytest.mark.parametrize("entry", ["constructor", "classmethod", "runner"]) @pytest.mark.parametrize("has_recorder", [True, False]) def test_factory_composes_restricted_without_live_adapters( - monkeypatch: pytest.MonkeyPatch, entry: str, has_recorder: bool + mocker, recorder, workflow_queue, entry: str, has_recorder: bool ) -> None: import time @@ -332,8 +422,9 @@ def test_factory_composes_restricted_without_live_adapters( update={"admitted_nodes": admitted_node_bindings(snapshot()), "fixture_digest": fixture_digest("a" * 64, ())} ) 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) + live = mocker.patch.object( + module, "build_dify_model_access", side_effect=AssertionError("live construction forbidden") + ) params = InitParams( workflow_id="workflow", graph_config=graph(), @@ -352,21 +443,21 @@ def test_factory_composes_restricted_without_live_adapters( state = RuntimeState(workflow_id=params.workflow_id, variable_pool=VariablePool(), start_at=time.perf_counter()) def construct() -> DifyNodeFactory | Graph: - recorder = MagicMock(healthy=True) if has_recorder else None + selected_recorder = recorder if has_recorder else None if entry == "constructor": - return module.DifyNodeFactory(params, state, execution_recorder=recorder) + return module.DifyNodeFactory(params, state, execution_recorder=selected_recorder) elif entry == "classmethod": return module.DifyNodeFactory.from_graph_init_context( graph_init_context=module.DifyGraphInitContext( workflow_id="workflow", graph_config=graph(), run_context=params.run_context, call_depth=0 ), runtime_state=state, - execution_recorder=recorder, + execution_recorder=selected_recorder, ) else: from services.workflow_runtime.base_runner import WorkflowBasedAppRunner - runner = WorkflowBasedAppRunner(queue_manager=MagicMock(), app_id="app") + runner = WorkflowBasedAppRunner(queue_manager=workflow_queue, app_id="app") return runner._init_graph( graph_config=graph(), graph_runtime_state=state, @@ -376,7 +467,7 @@ def test_factory_composes_restricted_without_live_adapters( user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, builder_execution=marker, - execution_recorder=recorder, + execution_recorder=selected_recorder, call_depth=0, ) @@ -393,7 +484,7 @@ def test_factory_composes_restricted_without_live_adapters( "change", ["code", "tool", "environment", "features", "tenant", "inputs", "fixture", "membership", "tracing"] ) def test_worker_readmission_rejects_reload_races_inside_queue_lifecycle( - owned: Owned, monkeypatch: pytest.MonkeyPatch, change: str + mocker, workflow_repositories, owned: Owned, monkeypatch: pytest.MonkeyPatch, change: str ) -> None: import contextvars import json @@ -445,37 +536,43 @@ def test_worker_readmission_rejects_reload_races_inside_queue_lifecycle( yield session monkeypatch.setattr(module.session_factory, "create_session", worker_session) - runner = MagicMock(side_effect=AssertionError("runner must not construct")) - snippet = MagicMock(side_effect=AssertionError("snippet must not mutate")) - trace = MagicMock(side_effect=AssertionError("trace must not construct")) - monkeypatch.setattr(module, "WorkflowAppRunner", runner) - monkeypatch.setattr(module.WorkflowAppGenerator, "_ensure_snippet_start_node_in_worker", snippet) - monkeypatch.setattr(module, "TraceQueueManager", trace) + runner = mocker.patch.object( + module.WorkflowAppRunner, "__init__", side_effect=AssertionError("runner must not construct") + ) + snippet = mocker.patch.object( + module.WorkflowAppGenerator, + "_ensure_snippet_start_node_in_worker", + side_effect=AssertionError("snippet must not mutate"), + ) + trace = mocker.patch.object( + module.TraceQueueManager, "__init__", side_effect=AssertionError("trace must not construct") + ) from core.helper import encrypter - secret = MagicMock(side_effect=AssertionError("secret resolution forbidden")) - monkeypatch.setattr(encrypter, "decrypt_token", secret) + secret = mocker.patch.object(encrypter, "decrypt_token", side_effect=AssertionError("secret resolution forbidden")) import core.app.apps.base_app_queue_manager as queue_module from core.app.apps.execution_coordinator import AppExecutionCoordinator, AppExecutionState from core.app.entities.queue_entities import QueueErrorEvent from services.workflow_runtime.workflow_queue import WorkflowAppQueueManager - monkeypatch.setattr(queue_module, "redis_client", MagicMock()) + mocker.patch.object(queue_module.redis_client, "setex", return_value=True) + mocker.patch.object(queue_module.redis_client, "get", return_value=None) + mocker.patch.object(queue_module.redis_client, "delete", return_value=1) monkeypatch.setattr(AppExecutionCoordinator, "start_watchdog", lambda _: None) queue = WorkflowAppQueueManager("task", actor.account_id, InvokeFrom.DEBUGGER, "workflow") monkeypatch.setattr(queue, "_is_stopped", lambda: False) - queue.publish_error = MagicMock(wraps=queue.publish_error) + mocker.spy(queue, "publish_error") launch = _BuilderExecutionLaunch(BuilderExecutionPolicyService(factory), marker) worker = threading.Thread( - target=module.WorkflowAppGenerator(execution_driver=MagicMock())._generate_worker, + target=module.WorkflowAppGenerator(execution_driver=_forbidden_driver)._generate_worker, kwargs={ "flask_app": cast(LocalProxy[Flask], current_app)._get_current_object(), "application_generate_entity": entity, "queue_manager": queue, "context": contextvars.copy_context(), - "variable_loader": MagicMock(), - "workflow_execution_repository": MagicMock(), - "workflow_node_execution_repository": MagicMock(), + "variable_loader": DUMMY_VARIABLE_LOADER, + "workflow_execution_repository": workflow_repositories[0], + "workflow_node_execution_repository": workflow_repositories[1], "builder_execution_admit": launch, }, ) @@ -531,7 +628,7 @@ def test_worker_readmission_rejects_reload_races_inside_queue_lifecycle( def test_worker_claims_once_and_shares_recorder_with_runner_and_completion( - owned: Owned, monkeypatch: pytest.MonkeyPatch + mocker, workflow_queue, workflow_repositories, owned: Owned, monkeypatch: pytest.MonkeyPatch ) -> None: import contextvars from contextlib import nullcontext @@ -563,32 +660,35 @@ def test_worker_claims_once_and_shares_recorder_with_runner_and_completion( monkeypatch.setattr(module, "active_workflow_task", lambda _: nullcontext()) service = BuilderExecutionPolicyService(factory) launch = _BuilderExecutionLaunch(service, marker) - runner = MagicMock() - queue = MagicMock() - monkeypatch.setattr(module, "WorkflowAppRunner", runner) - module.WorkflowAppGenerator(execution_driver=MagicMock())._generate_worker( + runners = [] + + def record_driver(runner, *_args): + runners.append(runner) + + queue = workflow_queue + module.WorkflowAppGenerator(execution_driver=record_driver)._generate_worker( flask_app=cast(LocalProxy[Flask], current_app)._get_current_object(), application_generate_entity=entity, queue_manager=queue, context=contextvars.copy_context(), - variable_loader=MagicMock(), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), + variable_loader=DUMMY_VARIABLE_LOADER, + workflow_execution_repository=workflow_repositories[0], + workflow_node_execution_repository=workflow_repositories[1], builder_execution_admit=launch, ) queue.publish_error.assert_not_called() - assert runner.call_args.kwargs["execution_recorder"] is launch.recorder + assert runners[0]._execution_recorder is launch.recorder with factory() as db: row = db.get(DifyBuilderExecutionRequest, marker.request_id) assert row is not None assert (row.state, row.native_run_id, row.task_id) == ("running", "allocated-run", "task") with pytest.raises(BuilderExecutionPolicyError, match="execution_already_claimed"): launch(workflow, app, entity, "start", False) - assert runner.call_args.kwargs["execution_recorder"] is launch.recorder + assert runners[0]._execution_recorder is launch.recorder # An append failure latches the very recorder that the port completion receives. from services.dify_builder.application.execution_policy import BuilderExecutionObservation - monkeypatch.setattr(service, "record", MagicMock(side_effect=OSError("append failed"))) + mocker.patch.object(service, "record", side_effect=OSError("append failed")) observation = BuilderExecutionObservation( observation_id="observation", invocation_id="invocation", @@ -608,7 +708,9 @@ def test_worker_claims_once_and_shares_recorder_with_runner_and_completion( assert result.execution_refusal.reason_code == "execution_recorder_unhealthy" -def test_resume_marker_refused_before_restore_trace_or_cancellation_reset(monkeypatch: pytest.MonkeyPatch) -> None: +def test_resume_marker_refused_before_restore_trace_or_cancellation_reset( + mocker, owned, monkeypatch: pytest.MonkeyPatch +) -> None: import tasks.app_generate.workflow_execute_task as module entity = AppGenerateEntity( @@ -620,18 +722,11 @@ def test_resume_marker_refused_before_restore_trace_or_cancellation_reset(monkey invoke_from=InvokeFrom.DEBUGGER, builder_execution=context(), ) - monkeypatch.setattr(module, "db", MagicMock()) - repo = MagicMock() - monkeypatch.setattr( - module.DifyAPIRepositoryFactory, "create_api_workflow_run_repository", MagicMock(return_value=repo) + repo = _install_paused_marker(module, owned, entity, mocker, monkeypatch) + restore = mocker.patch.object(module.RuntimeState, "from_snapshot", side_effect=AssertionError("restore forbidden")) + reset = mocker.patch.object( + module, "clear_app_task_cancellation_signals", side_effect=AssertionError("reset forbidden") ) - state = MagicMock() - state.get_generate_entity.return_value = entity - 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.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"}) restore.assert_not_called() @@ -640,7 +735,7 @@ def test_resume_marker_refused_before_restore_trace_or_cancellation_reset(monkey def test_native_worker_outer_finally_acknowledges_context_entry_failure( - owned: Owned, monkeypatch: pytest.MonkeyPatch + workflow_queue, workflow_repositories, owned: Owned, monkeypatch: pytest.MonkeyPatch ) -> None: import contextvars import threading @@ -672,14 +767,14 @@ def test_native_worker_outer_finally_acknowledges_context_entry_failure( def target() -> None: try: - module.WorkflowAppGenerator(execution_driver=MagicMock())._generate_worker( + module.WorkflowAppGenerator(execution_driver=_forbidden_driver)._generate_worker( flask_app=app, application_generate_entity=entity, - queue_manager=MagicMock(), + queue_manager=workflow_queue, context=contextvars.copy_context(), - variable_loader=MagicMock(), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), + variable_loader=DUMMY_VARIABLE_LOADER, + workflow_execution_repository=workflow_repositories[0], + workflow_node_execution_repository=workflow_repositories[1], builder_execution_admit=launch, ) except RuntimeError as error: 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 8e92b116d50..2d9de086ec8 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 @@ -3,7 +3,6 @@ import contextlib import contextvars import json -from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -286,9 +285,7 @@ def test_generate_includes_parent_trace_context_in_extras( "services.workflow_runtime.workflow_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", create_workflow_node_execution_repository, ) - monkeypatch.setattr( - "services.workflow_runtime.workflow_generator.db", SimpleNamespace(engine=sqlite_session.get_bind()) - ) + monkeypatch.setattr(type(app_generator_module.db), "engine", property(lambda _db: sqlite_session.get_bind())) monkeypatch.setattr(generator, "_prepare_user_inputs", lambda *, user_inputs, **kwargs: user_inputs) captured = {} @@ -341,7 +338,7 @@ def test_confirmed_agent_generator_keeps_preallocated_identity(monkeypatch, sqli generator = WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run) app, workflow, _ = _persist_generator_rows(sqlite_session) - account = MagicMock(spec=Account) + account = Account(name="Actor", email="actor@example.invalid") account.id = "actor" context = marker() captured = {} @@ -350,7 +347,7 @@ def test_confirmed_agent_generator_keeps_preallocated_identity(monkeypatch, sqli app_generator_module.WorkflowAppConfigManager, "get_app_config", lambda **_: _app_config(app, workflow) ) monkeypatch.setattr(app_generator_module.file_factory, "build_from_mappings", lambda **_: []) - monkeypatch.setattr(app_generator_module, "db", SimpleNamespace(engine=sqlite_session.get_bind())) + monkeypatch.setattr(type(app_generator_module.db), "engine", property(lambda _db: sqlite_session.get_bind())) monkeypatch.setattr(generator, "_prepare_user_inputs", lambda *, user_inputs, **_: user_inputs) monkeypatch.setattr(generator, "_generate", lambda **kw: captured.update(kw)) kwargs = { @@ -388,7 +385,7 @@ def test_confirmed_agent_resume_keeps_original_context_and_run(monkeypatch, sqli ) # Exercise the persisted carrier, including reconstruction of transient tracing. entity = WorkflowAppGenerateEntity.model_validate_json(entity.model_dump_json()) - user = MagicMock(spec=Account) + user = Account(name="Actor", email="actor@example.invalid") user.id = entity.user_id if not valid: entity.invoke_from = InvokeFrom.SERVICE_API @@ -401,8 +398,8 @@ def test_confirmed_agent_resume_keeps_original_context_and_run(monkeypatch, sqli "user": user, "application_generate_entity": entity, "graph_runtime_state": _runtime_state(), - "workflow_execution_repository": MagicMock(), - "workflow_node_execution_repository": MagicMock(), + "workflow_execution_repository": _repositories(sessionmaker(sqlite_session.get_bind()), app, end_user)[0], + "workflow_node_execution_repository": _repositories(sessionmaker(sqlite_session.get_bind()), app, end_user)[1], } if not valid: with pytest.raises(BuilderExecutionPolicyError): @@ -675,28 +672,32 @@ def test_builder_worker_refuses_draft_changed_after_confirmation_before_runner( monkeypatch.setattr( app_generator_module, "preserve_flask_contexts", lambda *args, **kwargs: contextlib.nullcontext() ) - monkeypatch.setattr(app_generator_module, "session_factory", SimpleNamespace(create_session=sqlite_session_factory)) + monkeypatch.setattr(app_generator_module.session_factory, "create_session", sqlite_session_factory) reached = [] - def first_node(**kwargs): - reached.append(kwargs) - return SimpleNamespace(run=lambda: None) + def prepare(runner): + reached.append(runner) - monkeypatch.setattr(app_generator_module, "WorkflowAppRunner", first_node) - queue = MagicMock() + monkeypatch.setattr(app_generator_module.WorkflowAppRunner, "prepare", prepare) + queue = WorkflowAppQueueManager( + task_id="task", user_id=entity.user_id, invoke_from=entity.invoke_from, app_mode=app.mode + ) + errors = [] + monkeypatch.setattr(queue, "publish_error", lambda error, *_args: errors.append(error)) repositories = _repositories(sqlite_session_factory, app, end_user) WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run)._generate_worker( flask_app=Flask(__name__), application_generate_entity=entity, queue_manager=queue, - context=SimpleNamespace(), - variable_loader=SimpleNamespace(), + context=contextvars.copy_context(), + variable_loader=DUMMY_VARIABLE_LOADER, workflow_execution_repository=repositories[0], workflow_node_execution_repository=repositories[1], ) assert reached == [] - assert queue.publish_error.call_args.args[0].reason_code == "stale_execution_revision" + assert len(errors) == 1 + assert errors[0].reason_code == "stale_execution_revision" def test_actual_generation_marks_configuration_failure_before_worker_dispatch( @@ -706,10 +707,14 @@ def test_actual_generation_marks_configuration_failure_before_worker_dispatch( from services.dify_builder.native_launch_observation import NativeLaunchObservation app, workflow, user = _persist_generator_rows(sqlite_session) + + def unavailable(): + raise ConnectionRefusedError("private plugin schema service") + monkeypatch.setattr( app_generator_module.WorkflowAppConfigManager, "get_app_config", - MagicMock(side_effect=ConnectionRefusedError("private plugin schema service")), + lambda **_kwargs: unavailable(), ) observation = NativeLaunchObservation() with observation.track(), pytest.raises(ConnectionRefusedError): 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 603e7221d5a..608b5d81e05 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 @@ -5,16 +5,15 @@ from copy import deepcopy from datetime import UTC, datetime from types import SimpleNamespace from typing import override -from unittest.mock import Mock import pytest from pydantic import ValidationError -from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.execution_coordinator import AppExecutionState from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom from core.app.entities.queue_entities import ( QueueAgentLogEvent, + QueueHumanInputFormFilledEvent, QueueIterationCompletedEvent, QueueIterationStartEvent, QueueLoopCompletedEvent, @@ -42,6 +41,7 @@ from graphon.engine_events import ( GraphRunSucceededEvent, NodeRunExceptionEvent, NodeRunFailedEvent, + NodeRunHumanInputFormFilledEvent, NodeRunIterationStartedEvent, NodeRunIterationSucceededEvent, NodeRunLoopFailedEvent, @@ -59,6 +59,7 @@ from graphon.enums import BuiltinNodeTypes, ErrorStrategy, WorkflowNodeExecution from graphon.graph import Graph from graphon.node_events import NodeRunResult from graphon.runtime import RuntimeState, VariablePool +from graphon.variables.segments import StringSegment from graphon.variables.variables import StringVariable from models.workflow import Workflow from services.workflow_run_index import WorkflowRunIndex @@ -77,6 +78,21 @@ def _workflow(graph: dict[str, object] | None = None) -> Workflow: ) +def _workflow_graph(runner: WorkflowBasedAppRunner, state: RuntimeState | None = None) -> Graph: + state = state or RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0) + graph = runner._init_graph( + graph_config={ + "nodes": [{"id": "start", "data": {"type": "start", "title": "Start", "variables": []}}], + "edges": [], + }, + graph_runtime_state=state, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + call_depth=0, + ) + return graph + + def _workflow_entry(runner: WorkflowBasedAppRunner, state: RuntimeState | None = None) -> WorkflowEntry: state = state or RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0) graph = runner._init_graph( @@ -346,7 +362,12 @@ class TestWorkflowBasedAppRunner: assert graph_config == original_config def test_ordinary_init_graph_keeps_python_mode_typed_serialization(self, monkeypatch: pytest.MonkeyPatch): - runner = WorkflowBasedAppRunner(queue_manager=Mock(spec=AppQueueManager), app_id="app") + runner = WorkflowBasedAppRunner( + queue_manager=WorkflowAppQueueManager( + task_id="serialization", user_id="account", invoke_from=InvokeFrom.DEBUGGER, app_mode="workflow" + ), + app_id="app", + ) runtime_state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0) raw_graph = { "nodes": [ @@ -365,14 +386,19 @@ class TestWorkflowBasedAppRunner: original = deepcopy(raw_graph) captured = {} + from services.workflow_runtime.base_runner import DifyNodeFactory + + original_factory = DifyNodeFactory.from_graph_init_context + prepared_graph = _workflow_graph(runner) + def capture_context(**kwargs): captured["graph"] = kwargs["graph_init_context"].graph_config - return Mock() + return original_factory(**kwargs) monkeypatch.setattr( "services.workflow_runtime.base_runner.DifyNodeFactory.from_graph_init_context", capture_context ) - monkeypatch.setattr("services.workflow_runtime.base_runner.Graph.init", lambda **_kwargs: Mock()) + monkeypatch.setattr("services.workflow_runtime.base_runner.Graph.init", lambda **_kwargs: prepared_graph) runner._init_graph( graph_config=raw_graph, graph_runtime_state=runtime_state, @@ -451,14 +477,17 @@ class TestWorkflowBasedAppRunner: ], } expected_graph_config = deepcopy(graph_config) - workflow = SimpleNamespace(tenant_id="tenant", id="workflow", graph_dict=graph_config) + workflow = _workflow(graph_config) + workflow.id = "workflow" user_inputs = {"question": "hello"} expected_user_inputs = dict(user_inputs) captured_graph_config = {} + prepared_graph = _workflow_graph(runner) + def fake_graph_init(**kwargs): captured_graph_config["value"] = kwargs["graph_config"] - return SimpleNamespace() + return prepared_graph class _NodeCls: @staticmethod @@ -600,10 +629,12 @@ class TestWorkflowBasedAppRunner: def extract_variable_selector_to_variable_mapping(graph_config, config): return {} + prepared_graph = _workflow_graph(runner) + def _graph_init(**kwargs): variable_pool = graph_runtime_state.variable_pool assert variable_pool.get(["sys", "conversation_id"]) is not None - return SimpleNamespace() + return prepared_graph monkeypatch.setattr( "services.workflow_runtime.base_runner.Graph.init", @@ -683,6 +714,36 @@ class TestWorkflowBasedAppRunner: assert paused_event.paused_nodes == ["node-1"] assert emails + def test_handle_human_input_form_filled_event_preserves_submitted_data(self): + published: list[object] = [] + + class RecordingQueue: + def publish(self, event, publish_from): + del publish_from + published.append(event) + + runner = WorkflowBasedAppRunner(queue_manager=RecordingQueue(), app_id="app") + submitted = {"decision": StringSegment(value="approve")} + runner.handle_event( + _workflow_entry(runner), + NodeRunHumanInputFormFilledEvent( + id="exec", + node_id="node", + node_type=BuiltinNodeTypes.HUMAN_INPUT, + node_title="Human Input", + rendered_content="content", + action_id="approve", + action_text="Approve", + submitted_data=submitted, + ), + ) + event = published[-1] + assert isinstance(event, QueueHumanInputFormFilledEvent) + assert event.form_id == "exec" + assert event.node_id == "node" + assert event.action_id == "approve" + assert event.submitted_data == submitted + def test_handle_graph_aborted_publishes_stopped_terminal(self): published: list[object] = [] 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 e5bf731e04b..c9ece1c0693 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 @@ -1,10 +1,10 @@ from collections.abc import Callable -from types import SimpleNamespace import pytest from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context from graphon.enums import BuiltinNodeTypes +from graphon.runtime import InitParams, RuntimeState, VariablePool from services.workflow_runtime.node_factory import DifyNodeFactory @@ -73,8 +73,8 @@ class TestDifyNodeFactory: ) return DifyNodeFactory( - init_params=SimpleNamespace(run_context=run_context, workflow_id="workflow"), - runtime_state=SimpleNamespace(), + init_params=InitParams(run_context=run_context, workflow_id="workflow", graph_config={}, call_depth=0), + runtime_state=RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0), ) def test_create_node_unknown_type(self): diff --git a/api/tests/unit_tests/core/dify_builder/fakes.py b/api/tests/unit_tests/core/dify_builder/fakes.py index 91c1a50e02f..fb3be7be76b 100644 --- a/api/tests/unit_tests/core/dify_builder/fakes.py +++ b/api/tests/unit_tests/core/dify_builder/fakes.py @@ -493,6 +493,7 @@ class StubAgent(DifyBuilderAgent): def __init__(self, risk_level: str = "low") -> None: self.risk_level = risk_level + self.learn_calls = 0 self.edit_intent_calls: list[tuple[list[str], str | None]] = [] @override @@ -558,7 +559,7 @@ class StubAgent(DifyBuilderAgent): def learn_from_build( self, goal_text: str, requirements: dict[str, object], plan_items: list[str], built_node_ids: list[str] ) -> str: - self.learn_calls = getattr(self, "learn_calls", 0) + 1 + self.learn_calls += 1 return "stub-skill" def analyze_impact(self, _goal_text, _graph, *, reply_language=None): # noqa: ARG002 @@ -576,7 +577,6 @@ class StubAgent(DifyBuilderAgent): last_edit_rejection=None, approved_plan=None, # noqa: ARG002 ): - self.edit_intent_calls = getattr(self, "edit_intent_calls", []) self.edit_intent_calls.append((list(edit_target_node_ids), last_edit_rejection)) return [] diff --git a/api/tests/unit_tests/core/dify_builder/test_effect_confirmation_gate.py b/api/tests/unit_tests/core/dify_builder/test_effect_confirmation_gate.py index 25cf7c88de4..04c8ff9bf94 100644 --- a/api/tests/unit_tests/core/dify_builder/test_effect_confirmation_gate.py +++ b/api/tests/unit_tests/core/dify_builder/test_effect_confirmation_gate.py @@ -2,9 +2,9 @@ from copy import deepcopy from datetime import datetime -from unittest.mock import Mock import pytest +from pytest_mock import MockerFixture from core.dify_builder.models import ( Action, @@ -57,7 +57,7 @@ FLOWS = [ EntryMode.BUILD, PcState.BUILD_EXECUTION, "run_test", - "handle_execution", + handlers_build.handle_execution, PcState.BUILD_AWAIT_TESTDATA, ), ( @@ -65,7 +65,7 @@ FLOWS = [ EntryMode.EDIT, PcState.EDIT_APPLY_CHANGES, "run_affected_tests", - "handle_apply_changes", + handlers_edit.handle_apply_changes, PcState.EDIT_AWAIT_TESTDATA, ), ( @@ -73,14 +73,14 @@ FLOWS = [ EntryMode.FIX, PcState.FIX_AWAIT_VERIFY, "run_verify", - "handle_await_verify", + handlers_fix.handle_await_verify, PcState.FIX_AWAIT_TESTDATA, ), ] RUN_HANDLERS = { - handlers_build: "handle_test_and_repair", - handlers_edit: "handle_test_affected_paths", - handlers_fix: "handle_verify", + handlers_build: handlers_build.handle_test_and_repair, + handlers_edit: handlers_edit.handle_test_affected_paths, + handlers_fix: handlers_fix.handle_verify, } @@ -105,7 +105,7 @@ def test_run_test_shows_the_form_again_while_a_side_effect_is_unconfirmed(flow): module, _dify, repo, env, session, turn = _fixture(flow) fc = saved_test_context(repo, session.id) - result = getattr(module, flow[4])(env, turn, session, fc) + result = flow[4](env, turn, session, fc) assert result.next == flow[5] form = next(item.payload for item in result.items if item.kind == "form") @@ -126,7 +126,7 @@ def test_explicit_run_requires_a_fresh_confirmation_even_after_a_previous_approv module, _dify, repo, env, session, turn = _fixture(flow) fc = saved_test_context(repo, session.id, effect_confirmation={"slack": _signature("slack")}) - result = getattr(module, flow[4])(env, turn, session, fc) + result = flow[4](env, turn, session, fc) assert result.next == flow[5] form = next(item.payload for item in result.items if item.kind == "form") @@ -137,13 +137,13 @@ def test_explicit_run_requires_a_fresh_confirmation_even_after_a_previous_approv def test_nothing_to_disclose_keeps_the_existing_gate(flow): module, _dify, repo, env, session, turn = _fixture(flow, disclosed=False) - result = getattr(module, flow[4])(env, turn, session, saved_test_context(repo, session.id)) + result = flow[4](env, turn, session, saved_test_context(repo, session.id)) assert result.next != flow[5] @pytest.mark.parametrize("flow", FLOWS) -def test_ticked_box_is_recorded_and_kept_out_of_the_start_inputs(flow): +def test_ticked_box_is_recorded_and_kept_out_of_the_start_inputs(mocker: MockerFixture, flow): module, dify, repo, env, session, _turn = _fixture(flow) turn = Turn( actor=Actor(account_id="a", tenant_id="t"), @@ -153,15 +153,15 @@ def test_ticked_box_is_recorded_and_kept_out_of_the_start_inputs(flow): ), ) fc = DifyBuilderContext() - getattr(module, flow[4])(env, _turn, session, fc) + flow[4](env, _turn, session, fc) result = module.handle_await_testdata(env, turn, session, fc) assert result.context.effect_confirmation == {"slack": _signature("slack")} assert repo.get_test_input(result.context.test_input_ref).inputs == {} - dify.run_draft = Mock(return_value=Run(status="succeeded")) - getattr(module, RUN_HANDLERS[module])(env, turn, session, result.context) + mocker.patch.object(dify, "run_draft", return_value=Run(status="succeeded")) + RUN_HANDLERS[module](env, turn, session, result.context) assert dify.run_draft.call_args.kwargs["effect_confirmation"] == {"slack": _signature("slack")} @@ -178,7 +178,7 @@ def test_old_checkbox_redisplays_changed_targets_instead_of_confirming_them(flow ) dify.effect_disclosure = disclose(graph, {"slack": "Ops bot"}, account_bindings={"slack": "credential-a"}) fc = DifyBuilderContext() - getattr(module, flow[4])(env, turn, session, fc) + flow[4](env, turn, session, fc) fc = context_from_dict(context_to_dict(fc)) if change == "endpoint": graph["nodes"][-1]["data"]["url"] = "https://x.io/b" @@ -236,7 +236,7 @@ def test_user_entered_start_target_is_bound_without_rejecting_an_intentional_inp monkeypatch.setattr(dify, "disclose_effects", disclosure) fc = DifyBuilderContext() - getattr(module, flow[4])(env, turn, session, fc) + flow[4](env, turn, session, fc) turn.action = Action( kind="provide_testdata", payload={"inputs": {"host": "a.example.com", confirm_field_key("hook"): True}} ) diff --git a/api/tests/unit_tests/core/dify_builder/test_execution_evidence.py b/api/tests/unit_tests/core/dify_builder/test_execution_evidence.py index 6ecda54d5b0..f9e57e8dc67 100644 --- a/api/tests/unit_tests/core/dify_builder/test_execution_evidence.py +++ b/api/tests/unit_tests/core/dify_builder/test_execution_evidence.py @@ -5,7 +5,7 @@ from typing import Literal import pytest from core.dify_builder.models import Run, RunVerification -from services.dify_builder.application import verification +from services.dify_builder.application import handlers_build, handlers_edit, handlers_fix, verification from services.dify_builder.application.execution_policy import ExecutionEvidenceSummary @@ -102,6 +102,7 @@ def test_denial_ids_block_even_when_native_failed_or_proof_incomplete( "outcome", ["execution_blocked", "native_failed", "execution_evidence_unknown", "simulation_completed"] ) def test_handlers_keep_denials_out_of_review_and_repair( + mocker, flow: str, outcome: Literal[ "restricted_execution_completed", @@ -113,14 +114,12 @@ def test_handlers_keep_denials_out_of_review_and_repair( | None, ) -> None: import importlib - from unittest.mock import Mock from core.dify_builder.models import Turn from core.dify_builder.state import PcState from tests.unit_tests.core.dify_builder.fakes import saved_test_context helpers = importlib.import_module(f"tests.unit_tests.core.dify_builder.test_handlers_{flow}") - handlers = importlib.import_module(f"services.dify_builder.application.handlers_{flow}") env, repo = helpers._new_env() state = { "build": PcState.BUILD_TEST_AND_REPAIR, @@ -133,12 +132,13 @@ def test_handlers_keep_denials_out_of_review_and_repair( if outcome == "native_failed": raw.status = "failed" env.dify.run_draft = lambda *_a, **_kw: raw - env.agent.diagnose = Mock(side_effect=AssertionError("policy is not a business bug")) - env.agent.propose_repair = Mock(side_effect=AssertionError("policy is not a business bug")) - handler = getattr( - handlers, - {"build": "handle_test_and_repair", "edit": "handle_test_affected_paths", "fix": "handle_verify"}[flow], - ) + mocker.patch.object(env.agent, "diagnose", side_effect=AssertionError("policy is not a business bug")) + mocker.patch.object(env.agent, "propose_repair", side_effect=AssertionError("policy is not a business bug")) + handler = { + "build": handlers_build.handle_test_and_repair, + "edit": handlers_edit.handle_test_affected_paths, + "fix": handlers_fix.handle_verify, + }[flow] result = handler(env, Turn(actor=helpers._actor()), session, context) assert result.next not in {PcState.BUILD_REVIEW, PcState.EDIT_REVIEW} assert not result.context.staged_repair @@ -151,8 +151,9 @@ def test_handlers_keep_denials_out_of_review_and_repair( env.agent.propose_repair.assert_not_called() -def test_fix_diagnose_does_not_diagnose_persisted_policy_denial() -> None: - from unittest.mock import Mock +def test_fix_diagnose_does_not_diagnose_persisted_policy_denial( + mocker, +) -> None: from core.dify_builder.models import DifyBuilderContext, Turn from core.dify_builder.state import PcState @@ -165,23 +166,21 @@ def test_fix_diagnose_does_not_diagnose_persisted_policy_denial() -> None: run.id = "denied" run.status = "failed" repo.save_run(session.id, run) - env.agent.diagnose = Mock(side_effect=AssertionError("must not diagnose policy")) + mocker.patch.object(env.agent, "diagnose", side_effect=AssertionError("must not diagnose policy")) result = handle_diagnose(env, Turn(actor=_actor()), session, DifyBuilderContext(failed_run_id="denied")) assert result.next == PcState.FIX_AWAIT_DECISION env.agent.diagnose.assert_not_called() @pytest.mark.parametrize("flow", ["build", "edit", "fix"]) -def test_generic_verification_exception_never_enters_diagnosis(flow: str) -> None: +def test_generic_verification_exception_never_enters_diagnosis(mocker, flow: str) -> None: import importlib - from unittest.mock import Mock from core.dify_builder.models import Turn from core.dify_builder.state import PcState from tests.unit_tests.core.dify_builder.fakes import saved_test_context helpers = importlib.import_module(f"tests.unit_tests.core.dify_builder.test_handlers_{flow}") - handlers = importlib.import_module(f"services.dify_builder.application.handlers_{flow}") env, repo = helpers._new_env() session = helpers._session( current_state={ @@ -190,13 +189,14 @@ def test_generic_verification_exception_never_enters_diagnosis(flow: str) -> Non "fix": PcState.FIX_VERIFY, }[flow] ) - env.dify.run_draft = Mock(side_effect=RuntimeError("setup failed")) - env.agent.diagnose = Mock(side_effect=AssertionError("missing proof")) - env.agent.propose_repair = Mock(side_effect=AssertionError("missing proof")) - handler = getattr( - handlers, - {"build": "handle_test_and_repair", "edit": "handle_test_affected_paths", "fix": "handle_verify"}[flow], - ) + mocker.patch.object(env.dify, "run_draft", side_effect=RuntimeError("setup failed")) + mocker.patch.object(env.agent, "diagnose", side_effect=AssertionError("missing proof")) + mocker.patch.object(env.agent, "propose_repair", side_effect=AssertionError("missing proof")) + handler = { + "build": handlers_build.handle_test_and_repair, + "edit": handlers_edit.handle_test_affected_paths, + "fix": handlers_fix.handle_verify, + }[flow] result = handler(env, Turn(actor=helpers._actor()), session, saved_test_context(repo, session.id)) assert verification.is_execution_policy_blocker(result.run) env.agent.diagnose.assert_not_called() diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py index cab3bf22e30..3af8d9fc6dc 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import replace from datetime import datetime from typing import NoReturn, Unpack, override -from unittest.mock import Mock import pytest @@ -130,7 +129,7 @@ def test_capability_check_renders_agent_fields() -> None: @pytest.mark.parametrize("reply_language", ["en", "es-ES", ""]) -def test_capability_check_binds_session_language_in_requirement_prompt(reply_language: str) -> None: +def test_capability_check_binds_session_language_in_requirement_prompt(mocker, reply_language: str) -> None: from services.dify_builder.agent.llm_agent import LlmBuilderAgent from services.dify_builder.application.handlers_build import handle_capability_check @@ -169,7 +168,7 @@ def test_capability_check_binds_session_language_in_requirement_prompt(reply_lan model = _Model() agent = LlmBuilderAgent("tenant-1") - agent.model_or_none = Mock(return_value=model) + mocker.patch.object(agent, "model_or_none", return_value=model) env, _ = _new_env(agent=agent) fc = DifyBuilderContext( goal_text="Create a sales report in USD with a monthly reporting period", @@ -947,7 +946,9 @@ def test_test_and_repair_model_config_failure_surfaces_without_repair() -> None: assert "model" in english_text(assistant.payload["reply_text"]).lower() -def test_missing_proof_running_result_stops_at_review() -> None: +def test_missing_proof_running_result_stops_at_review( + mocker, +) -> None: """A truncated verify result requires review and never enters diagnosis.""" from core.dify_builder.models import Run from services.dify_builder.application.handlers_build import handle_test_and_repair @@ -955,7 +956,7 @@ def test_missing_proof_running_result_stops_at_review() -> None: diagnose_calls: list[object] = [] agent = StubAgent() - agent.diagnose = Mock(side_effect=lambda *a, **kw: diagnose_calls.append((a, kw))) + mocker.patch.object(agent, "diagnose", side_effect=lambda *a, **kw: diagnose_calls.append((a, kw))) env, _ = _new_env(agent=agent) env.fake_dify = FakeBuildDifyPort() env.fake_dify.run_draft = lambda *_a, **_k: Run( diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py b/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py index 564483ddc66..8f9095d9868 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py @@ -4,7 +4,6 @@ from collections.abc import Callable from dataclasses import replace from datetime import datetime from typing import NoReturn, Unpack, override -from unittest.mock import Mock import pytest @@ -1786,7 +1785,9 @@ _WOULD_RUN_EMPTY = ( ) -def test_a_proposal_the_agent_judged_wrong_ends_at_the_gate_and_writes_nothing() -> None: +def test_a_proposal_the_agent_judged_wrong_ends_at_the_gate_and_writes_nothing( + mocker, +) -> None: """The last hole the two semantic guards had. Their defects are ones the ENGINE ACCEPTS, so ``apply_repair`` has nothing to veto: if the agent handed the condemned batch over anyway it would simply be written. The @@ -1795,7 +1796,7 @@ def test_a_proposal_the_agent_judged_wrong_ends_at_the_gate_and_writes_nothing() agent = _JudgesItsOwnProposalWrongAgent(_WOULD_RUN_EMPTY) dify = FakeEditDifyPort() written: list[object] = [] - dify.apply_repair = Mock(side_effect=lambda *a, **k: written.append((a, k))) + mocker.patch.object(dify, "apply_repair", side_effect=lambda *a, **k: written.append((a, k))) env, repo = _new_env(dify=dify, agent=agent) s = _seed_edit_session( repo, PcState.EDIT_PLAN_APPROVAL, edit_rules={"risk_threshold": "high"}, edit_target_node_ids=["node2"] diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_fix.py b/api/tests/unit_tests/core/dify_builder/test_handlers_fix.py index fb4b7cfc739..0cda891249f 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_fix.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_fix.py @@ -8,7 +8,6 @@ cases (``## ---- checklist-fix flow ----``). from dataclasses import replace from datetime import datetime from typing import NoReturn, Unpack -from unittest.mock import Mock from core.dify_builder.contract import PreflightContextCard, PreflightIssue from core.dify_builder.models import ( @@ -1517,7 +1516,9 @@ _UNFED_AGGREGATOR_REASON = ( ) -def test_a_repair_the_guards_condemned_reaches_the_gate_with_its_reason() -> None: +def test_a_repair_the_guards_condemned_reaches_the_gate_with_its_reason( + mocker, +) -> None: """Parity with Edit's refusal. ``propose_repair`` already refused to stage a batch the semantic guards condemned -- but it surfaced BLIND: the reason lived on ``fc.risk`` and was shown to nobody, so the human at the gate saw @@ -1528,7 +1529,7 @@ def test_a_repair_the_guards_condemned_reaches_the_gate_with_its_reason() -> Non s = _session() fc = DifyBuilderContext() written: list[object] = [] - env.fake_dify.apply_repair = Mock(side_effect=lambda *a, **k: written.append((a, k))) + mocker.patch.object(env.fake_dify, "apply_repair", side_effect=lambda *a, **k: written.append((a, k))) def propose_repair(diagnosis: Diagnosis, graph: Graph) -> tuple[list[MutationIntent], Risk]: _ = diagnosis, graph @@ -1572,7 +1573,9 @@ def test_a_staged_repair_still_carries_no_error_card() -> None: assert turns[-1].payload["cards"] == [] -def test_missing_test_input_ref_requests_test_data_without_launch() -> None: +def test_missing_test_input_ref_requests_test_data_without_launch( + mocker, +) -> None: from services.dify_builder.application.handlers_fix import handle_verify env, repo = _new_env() @@ -1584,7 +1587,7 @@ def test_missing_test_input_ref_requests_test_data_without_launch() -> None: current_state=PcState.FIX_VERIFY, ) calls = [] - env.fake_dify.run_draft = Mock(side_effect=lambda *args, **_kwargs: calls.append(args)) + mocker.patch.object(env.fake_dify, "run_draft", side_effect=lambda *args, **_kwargs: calls.append(args)) result = handle_verify(env, Turn(actor=_actor()), s, DifyBuilderContext()) assert result.next == PcState.FIX_AWAIT_TESTDATA assert not calls diff --git a/api/tests/unit_tests/core/dify_builder/test_node_defaults.py b/api/tests/unit_tests/core/dify_builder/test_node_defaults.py index a96978fd57f..f486891ff2c 100644 --- a/api/tests/unit_tests/core/dify_builder/test_node_defaults.py +++ b/api/tests/unit_tests/core/dify_builder/test_node_defaults.py @@ -7,12 +7,21 @@ through the engine node data class that consumes it, pinned in BOTH directions: no structural field is missing, and no PURPOSE field is fabricated. """ -import importlib - import pytest from pydantic import ValidationError from core.dify_builder.node_defaults import default_config, default_config_or_empty +from core.workflow.nodes.knowledge_retrieval.entities import KnowledgeRetrievalNodeData +from graphon.nodes.answer.entities import AnswerNodeData +from graphon.nodes.code.entities import CodeNodeData +from graphon.nodes.end.entities import EndNodeData +from graphon.nodes.http_request.entities import HttpRequestNodeData +from graphon.nodes.llm.entities import LLMNodeData +from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData +from graphon.nodes.start.entities import StartNodeData +from graphon.nodes.template_transform.entities import TemplateTransformNodeData +from graphon.nodes.tool.entities import ToolNodeData +from graphon.nodes.variable_aggregator.entities import VariableAggregatorNodeData def test_default_config_start_matches_fe_default() -> None: @@ -248,7 +257,19 @@ _ENGINE_EXPECTATIONS: list[tuple[str, str, str, set[str]]] = [ def test_the_engine_reports_exactly_the_purpose_fields_as_missing( node_type: str, module_name: str, class_name: str, expected_missing: set[str] ) -> None: - data_class = getattr(importlib.import_module(module_name), class_name) + data_class = { + ("graphon.nodes.start.entities", "StartNodeData"): StartNodeData, + ("graphon.nodes.llm.entities", "LLMNodeData"): LLMNodeData, + ("graphon.nodes.code.entities", "CodeNodeData"): CodeNodeData, + ("graphon.nodes.http_request.entities", "HttpRequestNodeData"): HttpRequestNodeData, + ("graphon.nodes.end.entities", "EndNodeData"): EndNodeData, + ("graphon.nodes.answer.entities", "AnswerNodeData"): AnswerNodeData, + ("core.workflow.nodes.knowledge_retrieval.entities", "KnowledgeRetrievalNodeData"): KnowledgeRetrievalNodeData, + ("graphon.nodes.template_transform.entities", "TemplateTransformNodeData"): TemplateTransformNodeData, + ("graphon.nodes.variable_aggregator.entities", "VariableAggregatorNodeData"): VariableAggregatorNodeData, + ("graphon.nodes.question_classifier.entities", "QuestionClassifierNodeData"): QuestionClassifierNodeData, + ("graphon.nodes.tool.entities", "ToolNodeData"): ToolNodeData, + }[(module_name, class_name)] payload = {"type": node_type, "title": "T", **default_config(node_type)} if not expected_missing: diff --git a/api/tests/unit_tests/core/dify_builder/test_planning_source.py b/api/tests/unit_tests/core/dify_builder/test_planning_source.py index 58ff69feaf6..c6bd246f90e 100644 --- a/api/tests/unit_tests/core/dify_builder/test_planning_source.py +++ b/api/tests/unit_tests/core/dify_builder/test_planning_source.py @@ -1,6 +1,7 @@ """Source fidelity and refusal through real Build handlers and Runner persistence.""" import copy +from dataclasses import asdict from datetime import datetime from typing import override @@ -148,6 +149,7 @@ def test_empty_plan_preserves_context_and_stays_at_its_waiting_gate(state: PcSta # emit success cards, or begin discovery/canvas/checkpoint work. out, stored, before, agent, repo, canvas, progress = _advance(state, []) assert out.current_state == state + stored_fields, before_fields = asdict(stored), asdict(before) for name in ( "plan_items", "plan_title", @@ -164,7 +166,7 @@ def test_empty_plan_preserves_context_and_stays_at_its_waiting_gate(state: PcSta "unknown_outcome_count", "staged_repair", ): - assert getattr(stored, name) == getattr(before, name), name + assert stored_fields[name] == before_fields[name], name confirmed = {**REQUIREMENTS, "currency": "EUR" if state == PcState.BUILD_GOAL_ANALYSIS else "USD"} assert stored.requirements == confirmed assert agent.calls == [("plan", GOAL, confirmed)] diff --git a/api/tests/unit_tests/core/dify_builder/test_publication_gate.py b/api/tests/unit_tests/core/dify_builder/test_publication_gate.py index 90c5546ba16..836ba936cec 100644 --- a/api/tests/unit_tests/core/dify_builder/test_publication_gate.py +++ b/api/tests/unit_tests/core/dify_builder/test_publication_gate.py @@ -1,8 +1,8 @@ from datetime import datetime from types import ModuleType -from unittest.mock import Mock import pytest +from pytest_mock import MockerFixture from core.dify_builder.models import Action, Actor, DifyBuilderContext, EntryMode, Run, RunVerification, Session, Turn from core.dify_builder.state import PcState @@ -24,6 +24,22 @@ FLOWS = [ (handlers_fix, EntryMode.FIX, PcState.FIX_AWAIT_DECISION, "publish", "run_verify"), ] +REVIEW_HANDLERS = { + handlers_build: handlers_build.handle_review, + handlers_edit: handlers_edit.handle_review, + handlers_fix: handlers_fix.handle_await_decision, +} +TEST_HANDLERS = { + handlers_build: handlers_build.handle_test_and_repair, + handlers_edit: handlers_edit.handle_test_affected_paths, + handlers_fix: handlers_fix.handle_verify, +} +ENTRY_HANDLERS = { + handlers_build: handlers_build.handle_execution, + handlers_edit: handlers_edit.handle_apply_changes, + handlers_fix: handlers_fix.handle_await_verify, +} + def fixture( flow: tuple[ModuleType, EntryMode, PcState, str, str], @@ -61,7 +77,7 @@ def test_publish_verification_direct_boundaries( if status != "missing": repo.save_run(run.session_id, run) fc = DifyBuilderContext(verify_run_id=run.id) - decision = getattr(module, "handle_review", None) or module.handle_await_decision + decision = REVIEW_HANDLERS[module] assert decision(env, turn, s, fc).next == s.current_state assert module.handle_publish(env, turn, s, fc).next == s.current_state assert not dify.published @@ -73,7 +89,7 @@ def test_verification_retest_route_clears_previous(flow: tuple[ModuleType, Entry assert turn.action is not None turn.action.kind = flow[4] fc = DifyBuilderContext(verify_run_id="old") - decision = getattr(module, "handle_review", None) or module.handle_await_decision + decision = REVIEW_HANDLERS[module] result = decision(env, turn, s, fc) assert result.context.verify_run_id == "" assert result.next in {PcState.BUILD_AWAIT_TESTDATA, PcState.EDIT_AWAIT_TESTDATA, PcState.FIX_AWAIT_TESTDATA} @@ -81,6 +97,7 @@ def test_verification_retest_route_clears_previous(flow: tuple[ModuleType, Entry @pytest.mark.parametrize("flow", FLOWS) def test_verification_latest_attempt_and_truthful_outputs( + mocker: MockerFixture, flow: tuple[ModuleType, EntryMode, PcState, str, str], ) -> None: module, dify, repo, env, s, turn = fixture(flow) @@ -93,12 +110,8 @@ def test_verification_latest_attempt_and_truthful_outputs( executed_node_ids=["end"], no_output_dead_branch=False, ) - dify.run_draft = Mock(return_value=Run(status="succeeded", verification=evidence)) - handler = ( - getattr(module, "handle_test_and_repair", None) - or getattr(module, "handle_test_affected_paths", None) - or module.handle_verify - ) + mocker.patch.object(dify, "run_draft", return_value=Run(status="succeeded", verification=evidence)) + handler = TEST_HANDLERS[module] fc = saved_test_context(env.repo, s.id) first = handler(env, turn, s, fc) assert first.context.verify_run_id == first.run.id @@ -113,7 +126,9 @@ def test_verification_latest_attempt_and_truthful_outputs( @pytest.mark.parametrize("flow", FLOWS) -def test_publish_verification_positive_and_final_race(flow: tuple[ModuleType, EntryMode, PcState, str, str]) -> None: +def test_publish_verification_positive_and_final_race( + mocker: MockerFixture, flow: tuple[ModuleType, EntryMode, PcState, str, str] +) -> None: from core.dify_builder.errors import HashMismatchError from tests.unit_tests.core.dify_builder.fakes import successful_verification @@ -130,9 +145,9 @@ def test_publish_verification_positive_and_final_race(flow: tuple[ModuleType, En ) repo.save_run(s.id, run) fc = DifyBuilderContext(verify_run_id=run.id) - decision = getattr(module, "handle_review", None) or module.handle_await_decision + decision = REVIEW_HANDLERS[module] assert decision(env, turn, s, fc).next in {PcState.BUILD_PUBLISH, PcState.EDIT_PUBLISH, PcState.FIX_PUBLISH} - dify.publish = Mock(side_effect=HashMismatchError("changed")) + mocker.patch.object(dify, "publish", side_effect=HashMismatchError("changed")) assert module.handle_publish(env, turn, s, fc).next == s.current_state dify.publish.assert_called_once_with( s.app_id, @@ -144,6 +159,7 @@ def test_publish_verification_positive_and_final_race(flow: tuple[ModuleType, En @pytest.mark.parametrize("flow", FLOWS) def test_verification_unresolved_output_never_invents_repair( + mocker: MockerFixture, flow: tuple[ModuleType, EntryMode, PcState, str, str], ) -> None: from core.dify_builder.models import OutputFinding @@ -166,12 +182,8 @@ def test_verification_unresolved_output_never_invents_repair( executed_node_ids=["tool", "end"], no_output_dead_branch=False, ) - dify.run_draft = Mock(return_value=Run(status="succeeded", verification=evidence)) - handler = ( - getattr(module, "handle_test_and_repair", None) - or getattr(module, "handle_test_affected_paths", None) - or module.handle_verify - ) + mocker.patch.object(dify, "run_draft", return_value=Run(status="succeeded", verification=evidence)) + handler = TEST_HANDLERS[module] fc = saved_test_context(env.repo, s.id) out = handler(env, turn, s, fc) assert out.run.status == "succeeded" @@ -187,11 +199,7 @@ def test_verification_failed_card_preserves_execution_outcome( ) -> None: module, dify, repo, env, s, turn = fixture(flow) dify.verify_pass = False - handler = ( - getattr(module, "handle_test_and_repair", None) - or getattr(module, "handle_test_affected_paths", None) - or module.handle_verify - ) + handler = TEST_HANDLERS[module] result = handler(env, turn, s, saved_test_context(env.repo, s.id)) card = next(i.payload for i in result.items if i.kind == "test_result") assert card["outcome"] == "execution_failed" @@ -204,26 +212,19 @@ def test_verification_new_attempt_entry_clears_old_evidence( module, _dify, _repo, env, session, turn = fixture(flow) assert turn.action is not None turn.action.kind = flow[4] - handler = ( - getattr(module, "handle_execution", None) - or getattr(module, "handle_apply_changes", None) - or module.handle_await_verify - ) + handler = ENTRY_HANDLERS[module] result = handler(env, turn, session, DifyBuilderContext(verify_run_id="old")) assert result.context.verify_run_id == "" @pytest.mark.parametrize("flow", FLOWS) def test_verification_unbound_success_reply_requires_retest( + mocker: MockerFixture, flow: tuple[ModuleType, EntryMode, PcState, str, str], ) -> None: module, dify, _repo, env, session, turn = fixture(flow) - dify.run_draft = Mock(return_value=Run(status="succeeded")) - handler = ( - getattr(module, "handle_test_and_repair", None) - or getattr(module, "handle_test_affected_paths", None) - or module.handle_verify - ) + mocker.patch.object(dify, "run_draft", return_value=Run(status="succeeded")) + handler = TEST_HANDLERS[module] result = handler(env, turn, session, saved_test_context(env.repo, session.id)) reply = next(english_text(item.payload["reply_text"]) for item in result.items if item.kind == "assistant_turn") assert "Execution safety requires review" in reply @@ -233,7 +234,7 @@ def test_verification_unbound_success_reply_requires_retest( @pytest.mark.parametrize("flow", FLOWS) @pytest.mark.parametrize("failure_mode", ["native", "launch", "invalid_prepared_input"]) def test_verification_input_failure_retains_latest_persisted_attempt( - flow: tuple[ModuleType, EntryMode, PcState, str, str], failure_mode: str + mocker: MockerFixture, flow: tuple[ModuleType, EntryMode, PcState, str, str], failure_mode: str ) -> None: from core.dify_builder.models import TestInput from services.dify_builder.application.runner import Runner @@ -263,18 +264,16 @@ def test_verification_input_failure_retains_latest_persisted_attempt( repo.save_run(session.id, prior) repo.create_session(session, DifyBuilderContext(verify_run_id=prior.id, test_input_ref=test_input.id), []) native_error = "File variable not found for selector: ['start', 'document']" - dify.run_draft = Mock( + mocker.patch.object( + dify, + "run_draft", return_value=Run( status="failed", dify_run_id="dify-run-1", error=native_error, verification=failed_verification(dify) - ) + ), ) if failure_mode == "launch": dify.run_draft.side_effect = ValueError("query is required in input form") - handler = ( - getattr(module, "handle_test_and_repair", None) - or getattr(module, "handle_test_affected_paths", None) - or module.handle_verify - ) + handler = TEST_HANDLERS[module] runner = Runner(env, {working_state: handler, waiting_state: module.handle_await_testdata}) runner.advance(session.id, Turn(actor=turn.actor)) @@ -339,11 +338,12 @@ def test_verification_input_failure_retains_latest_persisted_attempt( @pytest.mark.parametrize("flow", FLOWS) def test_verification_prelaunch_input_gate_still_clears_prior_evidence( + mocker: MockerFixture, flow: tuple[ModuleType, EntryMode, PcState, str, str], ) -> None: module, dify, _repo, env, session, turn = fixture(flow) dify.app_mode = "advanced-chat" - dify.run_draft = Mock() + mocker.patch.object(dify, "run_draft") turn.action = Action(kind="provide_testdata", payload={"mode": "provide", "inputs": {"sys.query": ""}}) result = module.handle_await_testdata( env, turn, session, DifyBuilderContext(verify_run_id="older-run", test_input_ref="older-input") diff --git a/api/tests/unit_tests/core/dify_builder/test_testdata.py b/api/tests/unit_tests/core/dify_builder/test_testdata.py index 489246b0674..afb811ac2d5 100644 --- a/api/tests/unit_tests/core/dify_builder/test_testdata.py +++ b/api/tests/unit_tests/core/dify_builder/test_testdata.py @@ -1,7 +1,6 @@ """The shared handler seam preserves absence, immutable fixtures and lineage.""" import importlib -from unittest.mock import MagicMock import pytest @@ -12,6 +11,7 @@ from services.dify_builder.application.execution_policy import ( HttpResponseFixtureV1, ) from services.dify_builder.application.ports import DifyPort +from services.dify_builder.dify_port import WorkflowServiceDifyPort def stamp(dify: DifyPort, turn: Turn) -> HttpFixtureSetV1 | None: @@ -25,15 +25,17 @@ def sample(**overrides: object) -> dict[str, object]: @pytest.mark.parametrize("action", [None, Action(kind="provide_testdata", payload={"inputs": {}})]) -def test_absent_http_fixtures_do_not_stamp(action: Action | None) -> None: - dify = MagicMock(spec=DifyPort) +def test_absent_http_fixtures_do_not_stamp(mocker, action: Action | None) -> None: + dify = WorkflowServiceDifyPort() + mocker.patch.object(dify, "stamp_http_fixtures") assert stamp(dify, Turn(actor=Actor(account_id="account", tenant_id="tenant"), action=action)) is None dify.stamp_http_fixtures.assert_not_called() @pytest.mark.parametrize("raw", [None, {}, [sample(source="generated_sample")], [sample(status_code=True)]]) -def test_invalid_public_http_fixtures_refuse_before_stamp(raw: object) -> None: - dify = MagicMock(spec=DifyPort) +def test_invalid_public_http_fixtures_refuse_before_stamp(mocker, raw: object) -> None: + dify = WorkflowServiceDifyPort() + mocker.patch.object(dify, "stamp_http_fixtures") turn = Turn( actor=Actor(account_id="account", tenant_id="tenant"), action=Action(kind="provide_testdata", payload={"http_fixtures": raw}, base_app_revision="a" * 64), @@ -44,8 +46,11 @@ def test_invalid_public_http_fixtures_refuse_before_stamp(raw: object) -> None: dify.stamp_http_fixtures.assert_not_called() -def test_shared_stamp_passes_actor_exact_base_revision_and_immutable_public_fixtures() -> None: - dify = MagicMock(spec=DifyPort) +def test_shared_stamp_passes_actor_exact_base_revision_and_immutable_public_fixtures( + mocker, +) -> None: + dify = WorkflowServiceDifyPort() + mocker.patch.object(dify, "stamp_http_fixtures") actor = Actor(account_id="account", tenant_id="tenant") revision = "a" * 64 raw = [sample(node_id="second"), sample(node_id="first")] @@ -60,8 +65,11 @@ def test_shared_stamp_passes_actor_exact_base_revision_and_immutable_public_fixt assert "source" not in raw[0] -def test_shared_stamp_never_replaces_a_stale_submitted_base_revision() -> None: - dify = MagicMock(spec=DifyPort) +def test_shared_stamp_never_replaces_a_stale_submitted_base_revision( + mocker, +) -> None: + dify = WorkflowServiceDifyPort() + mocker.patch.object(dify, "stamp_http_fixtures") dify.stamp_http_fixtures.side_effect = BuilderExecutionPolicyError("stale_http_fixtures") actor = Actor(account_id="account", tenant_id="tenant") turn = Turn( diff --git a/api/tests/unit_tests/core/dify_builder/test_workflow_events.py b/api/tests/unit_tests/core/dify_builder/test_workflow_events.py index 803bb90ea0d..98d3decd0c1 100644 --- a/api/tests/unit_tests/core/dify_builder/test_workflow_events.py +++ b/api/tests/unit_tests/core/dify_builder/test_workflow_events.py @@ -2,7 +2,6 @@ from collections.abc import Callable from datetime import datetime -from unittest.mock import MagicMock import pytest @@ -39,7 +38,7 @@ from tests.unit_tests.core.dify_builder.fakes import ( (handle_verify, PcState.FIX_VERIFY, EntryMode.FIX), ], ) -def test_all_test_flows_forward_native_events(handler: Handler, state: PcState, mode: EntryMode) -> None: +def test_all_test_flows_forward_native_events(mocker, handler: Handler, state: PcState, mode: EntryMode) -> None: port = FakeDifyPort() port.workflow_events = [ {"event": "workflow_started", "workflow_run_id": "run-1", "data": {"inputs": {"query": "hello"}}}, @@ -56,7 +55,7 @@ def test_all_test_flows_forward_native_events(handler: Handler, state: PcState, ) session = Session(id="real-session", app_id="app", tenant_id="tenant", entry_mode=mode, current_state=state) context = saved_test_context(env.repo, session.id) - port.run_draft = MagicMock(wraps=port.run_draft) + mocker.spy(port, "run_draft") handler( env, @@ -94,6 +93,7 @@ def test_all_test_flows_forward_native_events(handler: Handler, state: PcState, ], ) def test_structural_repair_is_committed_before_a_separate_retest( + mocker, registry: Callable[[], dict[PcState, Handler]], mode: EntryMode, repair_state: PcState, @@ -102,7 +102,7 @@ def test_structural_repair_is_committed_before_a_separate_retest( review_state: PcState, ) -> None: port = FakeEditDifyPort() - port.run_draft = MagicMock(wraps=port.run_draft) + mocker.spy(port, "run_draft") repo = InMemoryRepository() env = Env(dify=port, agent=StubAgent(), repo=repo, now=lambda: datetime.min) session = Session(app_id="app", tenant_id="tenant", entry_mode=mode, current_state=repair_state) @@ -136,9 +136,11 @@ def test_structural_repair_is_committed_before_a_separate_retest( port.run_draft.assert_called_once() -def test_edit_unknown_result_does_not_stage_a_failure_or_repair() -> None: +def test_edit_unknown_result_does_not_stage_a_failure_or_repair( + mocker, +) -> None: port = FakeEditDifyPort() - port.run_draft = MagicMock(return_value=Run(status="running", dify_run_id="run-1")) + mocker.patch.object(port, "run_draft", return_value=Run(status="running", dify_run_id="run-1")) env = Env(dify=port, agent=StubAgent(), repo=InMemoryRepository(), now=lambda: datetime.min) session = Session( app_id="app", tenant_id="tenant", entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS diff --git a/api/tests/unit_tests/core/helper/test_encrypter.py b/api/tests/unit_tests/core/helper/test_encrypter.py index b99d599d108..3e29ba42d35 100644 --- a/api/tests/unit_tests/core/helper/test_encrypter.py +++ b/api/tests/unit_tests/core/helper/test_encrypter.py @@ -7,7 +7,7 @@ from Crypto.PublicKey import RSA from pytest_mock import MockerFixture from sqlalchemy.orm import Session -import models.engine as model_engine +import extensions.ext_key_provider as key_provider_module from core.helper.encrypter import ( batch_decrypt_token, decrypt_token, @@ -18,6 +18,7 @@ from core.helper.encrypter import ( from libs import gmpy2_pkcs10aep_cipher, rsa from libs.rsa import PrivkeyNotFoundError from models.account import Tenant +from tests.unit_tests.database_fixtures import BoundDatabase pytestmark = [ pytest.mark.usefixtures("sqlite_session"), @@ -27,7 +28,10 @@ pytestmark = [ @pytest.fixture(autouse=True) def bind_sqlite_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(model_engine.db, "session", sqlite_session) + # The key repository opens a short-lived read when the caller has no active + # transaction; bind both paths to the same real SQLite database. + monkeypatch.setattr(key_provider_module, "db", BoundDatabase(sqlite_session)) + monkeypatch.setattr(key_provider_module.key_provider_manager, "_provider", None) @pytest.fixture(scope="module") diff --git a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py index 15bedc1d432..e47109511cb 100644 --- a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py +++ b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py @@ -801,11 +801,10 @@ def test_trace_queue_enqueue_error_propagates(monkeypatch: pytest.MonkeyPatch, t manager.enqueue_persisted_trace({"file_id": "workflow-final-run-1", "app_id": "app-id"}) -def test_restricted_trace_policy_survives_shared_queue_and_persistence(monkeypatch, trace_environment): - from unittest.mock import MagicMock - - provider = MagicMock(side_effect=AssertionError("provider resolution forbidden")) - monkeypatch.setattr(OpsTraceManager, "get_ops_trace_instance", provider) +def test_restricted_trace_policy_survives_shared_queue_and_persistence(monkeypatch, trace_environment, mocker): + provider = mocker.patch.object( + OpsTraceManager, "get_ops_trace_instance", side_effect=AssertionError("provider resolution forbidden") + ) restricted = TraceQueueManager(app_id="restricted", external_tracing_disabled=True) provider.assert_not_called() restricted._enterprise_telemetry_enabled = True diff --git a/api/tests/unit_tests/core/plugin/impl/test_oauth_handler.py b/api/tests/unit_tests/core/plugin/impl/test_oauth_handler.py index 0e486c9672e..4972c75a852 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_oauth_handler.py +++ b/api/tests/unit_tests/core/plugin/impl/test_oauth_handler.py @@ -5,6 +5,7 @@ import pytest from pytest_mock import MockerFixture from werkzeug import Request +from core.plugin.entities.plugin_daemon import PluginOAuthCredentialsResponse from core.plugin.impl.oauth import OAuthHandler # SYNTHETIC values in the proposed server-only contract shape; not deployed daemon support. @@ -73,7 +74,7 @@ class TestOAuthHandler: def fake_stream[**P](*args: P.args, **kwargs: P.kwargs): captured_data.update(kwargs["data"]) - return iter([SimpleNamespace(credentials={"token": "abc"}, metadata={}, expires_at=1)]) + return iter([PluginOAuthCredentialsResponse(credentials={"token": "abc"}, metadata={}, expires_at=1)]) stream_mock = mocker.patch.object( handler, "_request_with_plugin_daemon_response_stream", side_effect=fake_stream @@ -113,7 +114,9 @@ class TestOAuthHandler: stream_mock = mocker.patch.object( handler, "_request_with_plugin_daemon_response_stream", - return_value=iter([SimpleNamespace(credentials={"token": "new"}, metadata={}, expires_at=1)]), + return_value=iter( + [PluginOAuthCredentialsResponse(credentials={"token": "new"}, metadata={}, expires_at=1)] + ), ) response = handler.refresh_credentials( @@ -149,7 +152,9 @@ class TestOAuthHandler: stream_mock = mocker.patch.object( handler, "_request_with_plugin_daemon_response_stream", - return_value=iter([SimpleNamespace(credentials={"token": "new"}, metadata={}, expires_at=1)]), + return_value=iter( + [PluginOAuthCredentialsResponse(credentials={"token": "new"}, metadata={}, expires_at=1)] + ), ) handler.refresh_credentials( @@ -173,7 +178,9 @@ class TestOAuthHandler: stream_mock = mocker.patch.object( handler, "_request_with_plugin_daemon_response_stream", - return_value=iter([SimpleNamespace(credentials={"token": "new"}, metadata={}, expires_at=1)]), + return_value=iter( + [PluginOAuthCredentialsResponse(credentials={"token": "new"}, metadata={}, expires_at=1)] + ), ) handler.refresh_credentials( diff --git a/api/tests/unit_tests/core/plugin/impl/test_tool_manager.py b/api/tests/unit_tests/core/plugin/impl/test_tool_manager.py index bb4c34d03d1..cb438a3cd91 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_tool_manager.py +++ b/api/tests/unit_tests/core/plugin/impl/test_tool_manager.py @@ -9,16 +9,14 @@ from core.plugin.impl.exc import PluginDaemonError from core.plugin.impl.tool import PluginToolManager -def _tool_provider(name: str = "provider") -> SimpleNamespace: - return SimpleNamespace( - plugin_id="org/plugin", - provider=name, - plugin_unique_identifier=f"org/plugin:0.0.1@{'0' * 64}", - declaration=SimpleNamespace( - identity=SimpleNamespace(name=name), - tools=[SimpleNamespace(identity=SimpleNamespace(provider=""))], - ), - ) +def _tool_provider(name: str = "provider"): + from tests.unit_tests.services.dify_builder.test_json_tool_metadata import _provider + + provider = _provider(plugin_id="org/plugin", provider=name, plugin_unique_identifier=f"org/plugin:0.0.1@{'0' * 64}") + provider.declaration.identity.name = name + for tool in provider.declaration.tools: + tool.identity.provider = "" + return provider class TestPluginToolManager: diff --git a/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py b/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py index 06f9b6db609..fda9fb6904e 100644 --- a/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py +++ b/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_node_execution_repository.py @@ -25,6 +25,7 @@ from core.repositories.sqlalchemy_workflow_node_execution_repository import ( _deterministic_json_dump, _filter_by_offload_type, _find_first, + _InputsOutputsTruncationResult, _replace_or_append_offload, ) from graphon.entities import WorkflowNodeExecution @@ -323,8 +324,10 @@ def test_agent_create_receipt_survives_stale_terminal_and_offload_write( if write == "terminal": repo.save(execution) else: - truncated = SimpleNamespace( - truncated_value={"result": "truncated"}, offload=_offload(ExecutionOffLoadType.PROCESS_DATA) + truncated = _InputsOutputsTruncationResult( + file=_upload_file(), + truncated_value={"result": "truncated"}, + offload=_offload(ExecutionOffLoadType.PROCESS_DATA), ) monkeypatch.setattr(repo, "_truncate_and_upload", lambda *_: truncated if write == "offload" else None) repo.save_execution_data(execution) @@ -360,7 +363,8 @@ def test_offload_preserves_ack_committed_during_upload(monkeypatch, sqlite_sessi with sqlite_session_factory() as session, session.begin(): row = session.get(WorkflowNodeExecutionModel, execution.id) row.process_data = json.dumps({"agent_create_attempts": [{**attempt, "child_run_id": "child"}]}) - return SimpleNamespace( + return _InputsOutputsTruncationResult( + file=_upload_file(), truncated_value={"large": "...", "agent_create_attempts": "..."}, offload=_offload(ExecutionOffLoadType.PROCESS_DATA), ) diff --git a/api/tests/unit_tests/core/tools/plugin_tool/test_confirmed_plugin_tool.py b/api/tests/unit_tests/core/tools/plugin_tool/test_confirmed_plugin_tool.py index 62ae6020628..6adf9185e04 100644 --- a/api/tests/unit_tests/core/tools/plugin_tool/test_confirmed_plugin_tool.py +++ b/api/tests/unit_tests/core/tools/plugin_tool/test_confirmed_plugin_tool.py @@ -7,8 +7,9 @@ server-only contract values; they say nothing about deployed daemon support. """ from collections.abc import Generator, Iterator, Mapping +from contextlib import ExitStack from dataclasses import dataclass -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from sqlalchemy.orm import Session @@ -23,6 +24,11 @@ from core.tools.plugin_tool.tool import PluginTool from core.tools.tool_engine import ToolEngine from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError from services.dify_builder.application.gmail_read_policy import normalize_get_message_parameters +from services.dify_builder.real_call_consent_service import ServiceConfirmedToolGuard +from tests.unit_tests.core.tools import test_confirmed_tool_runtime as runtime_fixtures + +owned = runtime_fixtures.owned +consent_owner = runtime_fixtures.consent_owner # SYNTHETIC values in the exact proposed contract shape; never a qualified artifact. SYNTHETIC_HEADERS = { @@ -34,7 +40,7 @@ APPROVED = {"message_id": "18cab_1", "include_body": False, "include_attachments SECRET_SENTINEL = "unit-secret-sentinel-7f3a" -def test_plugin_tool_rejects_after_cast_before_manager_invoke() -> None: +def test_plugin_tool_rejects_after_cast_before_manager_invoke(guard_factory) -> None: approved = normalize_get_message_parameters( {"message_id": "18cab_1", "include_body": False, "include_attachments": False} ) @@ -43,8 +49,7 @@ def test_plugin_tool_rejects_after_cast_before_manager_invoke() -> None: if normalize_get_message_parameters(parameters) != approved: raise BuilderExecutionPolicyError("consent_scope_changed") - guard = Mock() - guard.before_invoke.side_effect = check + guard = guard_factory(check) entity = ToolEntity( identity=ToolIdentity( author="langgenius", @@ -104,7 +109,9 @@ def _entity() -> ToolEntity: ) -def _tool(guard: Mock | None, *, runtime_parameters: dict[str, object] | None = None) -> PluginTool: +def _tool( + guard: ServiceConfirmedToolGuard | None, *, runtime_parameters: dict[str, object] | None = None +) -> PluginTool: runtime = ToolRuntime( tenant_id="tenant", credential_type=CredentialType.OAUTH2, @@ -125,18 +132,28 @@ def _tool(guard: Mock | None, *, runtime_parameters: dict[str, object] | None = ) -def _exact_guard() -> Mock: - """Consent callback spy that admits only the exact approved final envelope.""" - approved = normalize_get_message_parameters(APPROVED) - guard = Mock() +@pytest.fixture +def guard_factory(consent_owner): + """Observe actual guard methods at the consent callback boundary.""" + with ExitStack() as stack: - def check(parameters: Mapping[str, object]) -> None: - if normalize_get_message_parameters(parameters) != approved: - raise BuilderExecutionPolicyError("consent_scope_changed") + def create(check=None): + approved = normalize_get_message_parameters(APPROVED) + if check is None: - guard.before_invoke.side_effect = check - guard.dispatch_headers.return_value = dict(SYNTHETIC_HEADERS) - return guard + def check(parameters): + if normalize_get_message_parameters(parameters) != approved: + raise BuilderExecutionPolicyError("consent_scope_changed") + + _, binding = runtime_fixtures.claimed(consent_owner) + guard = runtime_fixtures.guard_for(consent_owner, binding) + stack.enter_context(patch.object(guard, "before_invoke", side_effect=check)) + stack.enter_context(patch.object(guard, "dispatch_headers", return_value=dict(SYNTHETIC_HEADERS))) + stack.enter_context(patch.object(guard, "observe_json")) + stack.enter_context(patch.object(guard, "finish_stream")) + return guard + + yield create def _success_messages() -> list[ToolInvokeMessage]: @@ -206,8 +223,8 @@ def _invoke( # --- final envelope through ToolEngine ------------------------------------------------------ -def test_tool_engine_merge_is_checked_lazily_before_transport() -> None: - guard = _exact_guard() +def test_tool_engine_merge_is_checked_lazily_before_transport(guard_factory) -> None: + guard = guard_factory() # An extra runtime value merged by ToolEngine (and Tool.invoke) widens the final envelope. tool = _tool(guard, runtime_parameters={"format": "raw"}) transport = Transport(_success_messages()) @@ -233,8 +250,8 @@ def test_tool_engine_merge_is_checked_lazily_before_transport() -> None: guard.finish_stream.assert_not_called() -def test_tool_engine_exact_envelope_dispatches_once_with_pinned_headers() -> None: - guard = _exact_guard() +def test_tool_engine_exact_envelope_dispatches_once_with_pinned_headers(guard_factory) -> None: + guard = guard_factory() tool = _tool(guard) transport = Transport(_success_messages()) with ( @@ -283,8 +300,10 @@ def test_tool_engine_exact_envelope_dispatches_once_with_pinned_headers() -> Non ), ], ) -def test_unexpected_output_is_refused_before_it_reaches_file_transformation(unexpected: ToolInvokeMessage) -> None: - guard = _exact_guard() +def test_unexpected_output_is_refused_before_it_reaches_file_transformation( + unexpected: ToolInvokeMessage, guard_factory +) -> None: + guard = guard_factory() transport = Transport([_success_messages()[0], unexpected, _success_messages()[2]]) seen: list[ToolInvokeMessage] = [] @@ -311,8 +330,8 @@ def _variable(name: str, value: object, *, stream: bool = False) -> ToolInvokeMe ) -def test_audited_message_id_variable_is_forwarded_once_without_observation() -> None: - guard = _exact_guard() +def test_audited_message_id_variable_is_forwarded_once_without_observation(guard_factory) -> None: + guard = guard_factory() transport = Transport([_variable("message_id", "18cab_1"), _success_messages()[2]]) messages = _invoke(_tool(guard), transport) assert [m.type for m in messages] == [ToolInvokeMessage.MessageType.VARIABLE, ToolInvokeMessage.MessageType.JSON] @@ -333,8 +352,8 @@ def test_audited_message_id_variable_is_forwarded_once_without_observation() -> ], ids=["other-name", "mismatched-id", "non-string", "list-value", "streamed", "duplicate"], ) -def test_unaudited_variable_is_refused_before_it_is_yielded(variables: list[ToolInvokeMessage]) -> None: - guard = _exact_guard() +def test_unaudited_variable_is_refused_before_it_is_yielded(variables: list[ToolInvokeMessage], guard_factory) -> None: + guard = guard_factory() transport = Transport([_success_messages()[0], *variables, _success_messages()[2]]) seen: list[ToolInvokeMessage] = [] @@ -357,8 +376,8 @@ def test_unaudited_variable_is_refused_before_it_is_yielded(variables: list[Tool guard.finish_stream.assert_called_once_with(complete=False, declared_failure=False) -def test_consumer_cancellation_finishes_unknown_once_without_reconstruction() -> None: - guard = _exact_guard() +def test_consumer_cancellation_finishes_unknown_once_without_reconstruction(guard_factory) -> None: + guard = guard_factory() transport = Transport(_success_messages()) with ( Session() as session, @@ -377,19 +396,27 @@ def test_consumer_cancellation_finishes_unknown_once_without_reconstruction() -> def test_native_transport_error_stays_native_and_never_reaches_builder_metadata( caplog: pytest.LogCaptureFixture, + guard_factory, ) -> None: - guard = _exact_guard() + guard = guard_factory() transport = Transport(_success_messages(), fail_after=1) with pytest.raises(RuntimeError, match=SECRET_SENTINEL): _invoke(_tool(guard), transport) guard.finish_stream.assert_called_once_with(complete=False, declared_failure=False) # Nothing the guard received, and nothing logged, carries the native error text. - assert SECRET_SENTINEL not in repr(guard.mock_calls) + assert SECRET_SENTINEL not in repr( + [ + guard.before_invoke.mock_calls, + guard.dispatch_headers.mock_calls, + guard.observe_json.mock_calls, + guard.finish_stream.mock_calls, + ] + ) assert SECRET_SENTINEL not in caplog.text -def test_dispatch_header_failure_after_business_claim_is_unknown_not_retried() -> None: - guard = _exact_guard() +def test_dispatch_header_failure_after_business_claim_is_unknown_not_retried(guard_factory) -> None: + guard = guard_factory() guard.dispatch_headers.return_value = {**SYNTHETIC_HEADERS, "X-Dify-Confirmed-Dispatch": "v2"} transport = Transport(_success_messages()) with pytest.raises(BuilderExecutionPolicyError, match="confirmed_dispatch_headers_invalid"): @@ -402,8 +429,8 @@ def test_dispatch_header_failure_after_business_claim_is_unknown_not_retried() - # --- guard lifecycle and ordinary compatibility ------------------------------------------- -def test_fork_preserves_guard_outside_runtime_and_serialization() -> None: - guard = _exact_guard() +def test_fork_preserves_guard_outside_runtime_and_serialization(guard_factory) -> None: + guard = guard_factory() tool = _tool(guard) forked = tool.fork_tool_runtime(ToolRuntime(tenant_id="tenant", credentials={"access_token": "unit-token"})) assert isinstance(forked, PluginTool) diff --git a/api/tests/unit_tests/core/tools/test_confirmed_tool_runtime.py b/api/tests/unit_tests/core/tools/test_confirmed_tool_runtime.py index 7433d91c47b..e4ff9ee9cdd 100644 --- a/api/tests/unit_tests/core/tools/test_confirmed_tool_runtime.py +++ b/api/tests/unit_tests/core/tools/test_confirmed_tool_runtime.py @@ -17,9 +17,7 @@ from collections.abc import Callable, Generator, Iterator from dataclasses import dataclass, field from datetime import timedelta from threading import Lock -from types import SimpleNamespace from typing import Literal -from unittest.mock import Mock from uuid import uuid4 import pytest @@ -237,7 +235,7 @@ def world(consent_owner: Owner, monkeypatch: pytest.MonkeyPatch) -> Iterator[Wor # A fresh request-scoped provider cache, exactly as get_plugin_provider initializes it. contexts.plugin_tool_providers.set({}) contexts.plugin_tool_providers_lock.set(Lock()) - monkeypatch.setattr(tool_manager_module, "time", SimpleNamespace(time=lambda: state.now)) + monkeypatch.setattr(tool_manager_module.time, "time", lambda: state.now) def fetch_tool_provider(_self: PluginToolManager, tenant_id: str, provider: str) -> PluginToolProviderEntity: assert (tenant_id, provider) == (consent_owner.actor.tenant_id, PROVIDER) @@ -329,9 +327,16 @@ def world(consent_owner: Owner, monkeypatch: pytest.MonkeyPatch) -> Iterator[Wor monkeypatch.setattr(OAuthHandler, "_request_with_plugin_daemon_response_stream", refresh_stream) monkeypatch.setattr(PluginToolManager, "_request_with_plugin_daemon_response_stream", invoke_stream) monkeypatch.setattr(ToolProviderCredentialsCache, "delete", cache_delete) + # The confirmed path bypasses the ordinary credential cache entirely. - monkeypatch.setattr(ToolProviderCredentialsCache, "get", Mock(side_effect=AssertionError("ordinary cache read"))) - monkeypatch.setattr(ToolProviderCredentialsCache, "set", Mock(side_effect=AssertionError("ordinary cache write"))) + def forbid_cache_read(*_args, **_kwargs): + raise AssertionError("ordinary cache read") + + def forbid_cache_write(*_args, **_kwargs): + raise AssertionError("ordinary cache write") + + monkeypatch.setattr(ToolProviderCredentialsCache, "get", forbid_cache_read) + monkeypatch.setattr(ToolProviderCredentialsCache, "set", forbid_cache_write) yield state contexts.plugin_tool_providers.set({}) diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py index 095fd79b01c..6a7b3d2b66c 100644 --- a/api/tests/unit_tests/core/tools/test_tool_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_manager.py @@ -17,12 +17,13 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom -from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType +from core.entities.provider_entities import BasicProviderConfig, ProviderConfig, ProviderConfigType from core.helper.provider_cache import NoOpProviderCredentialCache from core.helper.provider_encryption import ProviderConfigEncrypter from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.exc import PluginDaemonNotFoundError, PluginNotFoundError from core.tools.__base.tool_runtime import ToolRuntime +from core.tools.builtin_tool.providers.time.time import WikiPediaProvider from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ( ToolEntity, @@ -41,6 +42,7 @@ from core.tools.workflow_as_tool.tool import WorkflowTool from graphon.runtime import VariablePool from models.base import TypeBase from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider +from tests.unit_tests.services.dify_builder.test_json_tool_metadata import _provider class _CallableSessionProxy: @@ -482,15 +484,38 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( cache.delete.assert_called_once() +def _credential_schema(): + return [ProviderConfig(name="k", type=ProviderConfigType.SECRET_INPUT)] + + +def _native_time_controller(tool_name="weekday"): + controller = WikiPediaProvider() + tool = controller.get_tool("weekday") + assert tool is not None + tool.entity.identity = tool.entity.identity.model_copy(update={"name": tool_name}) + controller.entity = controller.entity.model_copy(update={"credentials_schema": _credential_schema()}) + return controller + + +def _plugin_controller(tool_name="weekday", *, needs_credentials=True): + declaration = _provider().declaration.model_copy(deep=True) + entity = declaration.tools[0] + entity.identity = entity.identity.model_copy(update={"name": tool_name}) + declaration.tools = [entity] + declaration.credentials_schema = _credential_schema() if needs_credentials else [] + declaration.oauth_schema = None + return PluginToolProviderController( + entity=declaration, + plugin_id="langgenius/json_process", + plugin_unique_identifier="unit-artifact", + tenant_id="tenant-1", + ) + + def test_get_tool_runtime_builtin_maps_oauth_refresh_failure_to_credential_error( monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase ): - tool = Mock() - controller = SimpleNamespace( - get_tool=Mock(return_value=tool), - need_credentials=True, - get_credentials_schema_by_type=Mock(return_value=[]), - ) + controller = _native_time_controller() tenant_id = "00000000-0000-0000-0000-000000000001" builtin_provider = _builtin_provider( provider_id="00000000-0000-0000-0000-000000000002", @@ -527,10 +552,7 @@ def test_get_tool_runtime_builtin_maps_oauth_refresh_failure_to_credential_error def test_get_tool_runtime_builtin_plugin_credential_deleted_raises( monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase ): - plugin_controller = object.__new__(PluginToolProviderController) - plugin_controller.entity = SimpleNamespace(credentials_schema=[{"name": "k"}], oauth_schema=None) - plugin_controller.get_tool = Mock(return_value=Mock()) - plugin_controller.get_credentials_schema_by_type = Mock(return_value=[]) + plugin_controller = _plugin_controller() monkeypatch.setattr("core.tools.tool_manager.db", tool_database) with patch.object(ToolManager, "get_builtin_provider", return_value=plugin_controller): @@ -547,10 +569,7 @@ def test_get_tool_runtime_builtin_plugin_credential_deleted_raises( def test_get_tool_runtime_builtin_plugin_without_workspace_credential_raises( monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase ): - plugin_controller = object.__new__(PluginToolProviderController) - plugin_controller.entity = SimpleNamespace(credentials_schema=[{"name": "k"}], oauth_schema=None) - plugin_controller.get_tool = Mock(return_value=Mock()) - plugin_controller.get_credentials_schema_by_type = Mock(return_value=[]) + plugin_controller = _plugin_controller("send_draft") monkeypatch.setattr("core.tools.tool_manager.db", tool_database) with patch.object(ToolManager, "get_builtin_provider", return_value=plugin_controller): @@ -566,10 +585,7 @@ def test_get_tool_runtime_builtin_plugin_without_workspace_credential_raises( def test_get_tool_runtime_hardcoded_provider_without_credential_raises( monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase ): - controller = SimpleNamespace( - get_tool=Mock(return_value=Mock()), - need_credentials=True, - ) + controller = _native_time_controller("legacy-tool") monkeypatch.setattr("core.tools.tool_manager.db", tool_database) with patch.object(ToolManager, "get_builtin_provider", return_value=controller): @@ -657,12 +673,12 @@ def test_get_tool_runtime_workflow_path(monkeypatch: pytest.MonkeyPatch, tool_da def test_get_tool_runtime_plugin_path(): - tool = Mock() - tool.fork_tool_runtime.return_value = "plugin-tool" - with patch.object( - ToolManager, - "get_plugin_provider", - return_value=SimpleNamespace(get_tool=lambda _: tool, need_credentials=False), + controller = _plugin_controller("p", needs_credentials=False) + tool = controller.get_tool("p") + with ( + patch.object(ToolManager, "get_plugin_provider", return_value=controller), + patch.object(controller, "get_tool", return_value=tool), + patch.object(tool, "fork_tool_runtime", return_value="plugin-tool"), ): assert ( ToolManager.get_tool_runtime( @@ -794,8 +810,6 @@ def test_get_workflow_runtime_apply_runtime_parameters(): invoke_from=InvokeFrom.DEBUGGER, tool_invoke_from=ToolInvokeFrom.WORKFLOW, credential_id=None, - draft_tool_workflow_id=None, - draft_tool_app_id=None, execution_driver=None, ) diff --git a/api/tests/unit_tests/core/workflow/generator/test_native_agent_output_contract.py b/api/tests/unit_tests/core/workflow/generator/test_native_agent_output_contract.py index 887f765e28a..09c1c6fca6c 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_native_agent_output_contract.py +++ b/api/tests/unit_tests/core/workflow/generator/test_native_agent_output_contract.py @@ -171,33 +171,42 @@ def test_agent_plan_to_parallel_builder_to_valid_end_graph(monkeypatch): def _run_agent_model_responses(responses): - from types import SimpleNamespace - from unittest.mock import MagicMock + from unittest.mock import patch + + from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage + from graphon.model_runtime.entities.message_entities import AssistantPromptMessage + from tests.unit_tests.core.model_fixtures import make_model_instance results = [ - SimpleNamespace(message=SimpleNamespace(get_text_content=lambda text=text: text), usage=None) + LLMResult( + model="fixture", + prompt_messages=[], + message=AssistantPromptMessage(content=text), + usage=LLMUsage.empty_usage(), + ) for text in responses ] - model = SimpleNamespace(invoke_llm=MagicMock(side_effect=results)) - result = WorkflowGenerator._run_node_builder( - model_instance=model, - model_parameters={}, - provider="fixture", - model_name="fixture", - model_mode="chat", - mode_section="workflow", - instruction="Use only the confirmed published Agent binding", - ideal_output="", - target_node={"id": "node2", "node_type": "agent", "label": "Reviewer", "purpose": "Review contract"}, - plan_json="{}", - tool_catalogue_text="", - tool_catalogue_entries=[], - start_inputs=[], - node_outputs={"node2": ["risks", "summary"]}, - node_types={"node2": "agent"}, - existing_node=None, - ) - return result, model + model = make_model_instance(provider="fixture", model="fixture") + with patch.object(model, "invoke_llm", side_effect=results) as invoke: + result = WorkflowGenerator._run_node_builder( + model_instance=model, + model_parameters={}, + provider="fixture", + model_name="fixture", + model_mode="chat", + mode_section="workflow", + instruction="Use only the confirmed published Agent binding", + ideal_output="", + target_node={"id": "node2", "node_type": "agent", "label": "Reviewer", "purpose": "Review contract"}, + plan_json="{}", + tool_catalogue_text="", + tool_catalogue_entries=[], + start_inputs=[], + node_outputs={"node2": ["risks", "summary"]}, + node_types={"node2": "agent"}, + existing_node=None, + ) + return result, invoke def _observed_bad_native_config(): @@ -229,8 +238,8 @@ def test_real_agent_response_parser_corrects_both_observed_schema_errors_in_one_ canonical["agent_declared_outputs"][0]["array_item"] = {"type": "string"} result, model = _run_agent_model_responses([json.dumps({"config": bad}), json.dumps({"config": canonical})]) assert result == canonical - assert model.invoke_llm.call_count == 2 - correction = json.loads(model.invoke_llm.call_args_list[1].kwargs["prompt_messages"][-1].content) + assert model.call_count == 2 + correction = json.loads(model.call_args_list[1].kwargs["prompt_messages"][-1].content) assert correction["code"] == "INVALID_SCHEMA" assert "version" in correction["detail"] assert "string_type" in correction["detail"] @@ -262,7 +271,7 @@ def test_legacy_agent_builder_config_does_not_enter_native_schema_correction(): legacy = {"version": "1", "agent_strategy_name": "legacy-fixture"} result, model = _run_agent_model_responses([json.dumps({"config": legacy})]) assert result == legacy - assert model.invoke_llm.call_count == 1 + assert model.call_count == 1 def test_agent_system_schema_example_validates_with_native_owners(): diff --git a/api/tests/unit_tests/core/workflow/generator/test_runner.py b/api/tests/unit_tests/core/workflow/generator/test_runner.py index e0e717d363f..1244d362b49 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_runner.py +++ b/api/tests/unit_tests/core/workflow/generator/test_runner.py @@ -12,8 +12,8 @@ import re import threading import time from copy import deepcopy +from decimal import Decimal from itertools import permutations -from types import SimpleNamespace from typing import Any, ClassVar, cast, override from unittest.mock import MagicMock, patch @@ -23,6 +23,7 @@ from jinja2 import Template from core.workflow.generator.prompts.node_builder_prompts import format_node_outputs_section from core.workflow.generator.tool_catalogue import ToolCatalogueEntry from core.workflow.generator.types import GraphDict +from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage from services.workflow_generation.runner import WorkflowGenerator, _find_planned_tool_entry, _parse_node_outputs from tests.unit_tests.config_override import apply_config_overrides @@ -4490,10 +4491,19 @@ class TestWorkflowGeneratorStructuredErrors: # Exactly TWO calls — no third retry, no builder call. assert model_instance.invoke_llm.call_count == 2 - def test_planner_invalid_json_emits_invalid_json_code(self): + def test_planner_invalid_json_emits_invalid_json_code(self, monkeypatch): # Stable code on the JSON-parse failure path so the FE can localise. - model_instance = MagicMock() - model_instance.invoke_llm.return_value = _llm_result("definitely not json") + from graphon.model_runtime.entities.message_entities import AssistantPromptMessage + from tests.unit_tests.core.model_fixtures import make_model_instance + + model_instance = make_model_instance(provider="openai", model="gpt-4o") + response = LLMResult( + model="gpt-4o", + prompt_messages=[], + message=AssistantPromptMessage(content="definitely not json"), + usage=LLMUsage.empty_usage(), + ) + monkeypatch.setattr(model_instance, "invoke_llm", lambda **_kwargs: response) result = WorkflowGenerator.generate_workflow_graph( model_instance=model_instance, model_parameters={}, @@ -5439,9 +5449,15 @@ class TestWorkflowGeneratorStream: assert result["mode"] == "workflow" assert [n["data"]["type"] for n in result["graph"]["nodes"]] == ["start", "llm", "end"] - def test_stream_planner_failure_emits_only_result(self): - model_instance = MagicMock() - model_instance.invoke_llm.side_effect = RuntimeError("planner exploded") + def test_stream_planner_failure_emits_only_result(self, monkeypatch): + from tests.unit_tests.core.model_fixtures import make_model_instance + + model_instance = make_model_instance(provider="openai", model="gpt-4o") + + def failed(**_kwargs): + raise RuntimeError("planner exploded") + + monkeypatch.setattr(model_instance, "invoke_llm", failed) events = list( WorkflowGenerator.generate_workflow_graph_stream( @@ -7253,16 +7269,19 @@ class TestResponseMetadata: @staticmethod def _invoke(responses, *, parameters, validate=None): from graphon.model_runtime.entities.message_entities import UserPromptMessage + from tests.unit_tests.core.model_fixtures import make_model_instance - model = MagicMock() - model.invoke_llm.side_effect = responses - return WorkflowGenerator._invoke_and_parse_json( - model_instance=model, - messages=[UserPromptMessage(content="private-prompt-sentinel")], - model_parameters=parameters, - stage="Planner", - validate=validate, - ) + model = make_model_instance(provider="openai", model="gpt-4o") + selected = iter(responses) + patcher = patch.object(model, "invoke_llm", side_effect=lambda **_kwargs: next(selected)) + with patcher: + return WorkflowGenerator._invoke_and_parse_json( + model_instance=model, + messages=[UserPromptMessage(content="private-prompt-sentinel")], + model_parameters=parameters, + stage="Planner", + validate=validate, + ) @staticmethod def _records(caplog): @@ -7320,19 +7339,23 @@ class TestResponseMetadata: "prompt_tokens=unavailable completion_tokens=unavailable total_tokens=unavailable", ), ( - SimpleNamespace(prompt_tokens=True, completion_tokens="7"), + LLMUsage.empty_usage().model_copy( + update={"prompt_tokens": True, "completion_tokens": "7", "total_tokens": None} + ), {"max_tokens": True}, "requested_max_tokens=unavailable effective_provider_limit=unavailable " "prompt_tokens=unavailable completion_tokens=unavailable total_tokens=unavailable", ), ( - MagicMock(), - {"max_tokens": MagicMock()}, + Decimal(7), + {"max_tokens": Decimal(7)}, "requested_max_tokens=unavailable effective_provider_limit=unavailable " "prompt_tokens=unavailable completion_tokens=unavailable total_tokens=unavailable", ), ( - SimpleNamespace(prompt_tokens=0, completion_tokens=2.5, total_tokens=7), + LLMUsage.empty_usage().model_copy( + update={"prompt_tokens": 0, "completion_tokens": 2.5, "total_tokens": 7} + ), {"max_tokens": "4096"}, "requested_max_tokens=unavailable effective_provider_limit=unavailable " "prompt_tokens=0 completion_tokens=unavailable total_tokens=7", @@ -7342,9 +7365,12 @@ class TestResponseMetadata: def test_metadata_unavailable_counters_are_not_coerced(self, usage, parameters, expected, caplog): from graphon.model_runtime.entities.message_entities import AssistantPromptMessage - response = SimpleNamespace(message=AssistantPromptMessage(content="{}")) - if usage is not None: - response.usage = usage + response = LLMResult( + model="gpt-4o", + prompt_messages=[], + message=AssistantPromptMessage(content="{}"), + usage=LLMUsage.empty_usage(), + ).model_copy(update={"usage": usage}) caplog.set_level("INFO", logger="services.workflow_generation.runner") assert self._invoke([response], parameters=parameters) == {} assert self._records(caplog) == [ @@ -7357,7 +7383,12 @@ class TestResponseMetadata: from graphon.model_runtime.entities.message_entities import AssistantPromptMessage from services.workflow_generation.runner import _StageJSONError - response = SimpleNamespace(message=AssistantPromptMessage(content="private-response-sentinel")) + response = LLMResult( + model="gpt-4o", + prompt_messages=[], + message=AssistantPromptMessage(content="private-response-sentinel"), + usage=LLMUsage.empty_usage(), + ).model_copy(update={"usage": None}) caplog.set_level("INFO", logger="services.workflow_generation.runner") with patch( "services.workflow_generator_response.json_repair.loads", side_effect=ValueError("private-parser-sentinel") 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 2d93222aae8..b55b41d9e1e 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 @@ -10,14 +10,12 @@ from collections.abc import Sequence from copy import deepcopy from pathlib import Path from typing import NoReturn, override -from unittest.mock import Mock import pytest import yaml from sqlalchemy import event, select from sqlalchemy.exc import OperationalError -from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context from core.dify_builder.models import Graph as BuilderGraph from core.dify_builder.models import Inputs @@ -56,6 +54,7 @@ from services.dify_builder.revision import execution_revision from services.workflow_runtime.base_runner import WorkflowBasedAppRunner from services.workflow_runtime.entry import iter_dify_graph_engine_events from services.workflow_runtime.node_factory import DifyNodeFactory +from services.workflow_runtime.workflow_queue import WorkflowAppQueueManager from tests.unit_tests.core.dify_builder.test_execution_policy import fixture from tests.unit_tests.services.dify_builder.test_execution_policy_service import Owned from tests.unit_tests.services.dify_builder.test_execution_policy_service import owned as owned # noqa: PLC0414 @@ -150,7 +149,12 @@ def init_runner_graph( owned: Owned, factory: DifyNodeFactory, context: BuilderExecutionContext, recorder: BuilderExecutionRecorder ) -> Graph: _, actor, app, workflow, _, _ = owned - runner = WorkflowBasedAppRunner(queue_manager=Mock(spec=AppQueueManager), app_id=app.id) + runner = WorkflowBasedAppRunner( + queue_manager=WorkflowAppQueueManager( + task_id="task", user_id=actor.account_id, invoke_from=InvokeFrom.DEBUGGER, app_mode="workflow" + ), + app_id=app.id, + ) return runner._init_graph( graph_config=factory.init_params.graph_config, graph_runtime_state=factory.runtime_state, diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_confirmed_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_confirmed_agent_node.py index 3a31e8f6778..c5f5e91b9e9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_confirmed_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_confirmed_agent_node.py @@ -1,9 +1,7 @@ """Confirmed Agent control stays attached to its durable child and exact inputs.""" -from types import SimpleNamespace -from unittest.mock import MagicMock - import pytest +from dify_agent.client import Client from dify_agent.protocol import ( CreateRunRequest, CreateRunResponse, @@ -12,32 +10,142 @@ from dify_agent.protocol import ( RunWaitingEventData, ) +from clients.agent_backend.client import DifyAgentBackendRunClient from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY +from core.dify_builder.models import Actor from graphon.engine_events.node import NodeRunPauseRequestedEvent from graphon.entities.pause_reason import SchedulingPause from graphon.enums import ErrorStrategy, WorkflowNodeExecutionStatus from graphon.node_events import StreamCompletedEvent from graphon.variables.segments import StringSegment -from runtime_contracts.builder.native.agent_contracts import AgentCreateInserted, AgentParentNodeRef -from services.agent.workflow_run_recovery import AgentCreateAcknowledgementUnknown +from models.dify_builder_native_tests import NativeDispatch +from runtime_contracts.builder.native.agent_contracts import ( + AgentCreateInserted, + AgentNodeAssociation, + AgentParentNodeRef, + AgentProtectedState, + AgentStartPreparation, + AgentTestWorkspaceRef, + AgentWaitAssociation, +) +from runtime_contracts.builder.native.contracts import CheckpointRef +from runtime_contracts.builder.native.runtime_contracts import ( + NativeCheckpointPayload, + NodeJournalState, + ResolvedNativeTestContext, +) +from services.agent.workflow_run_recovery import AgentCreateAcknowledgementUnknown, WorkflowAgentRunRecovery +from services.dify_builder.native_testing.agent_preparation import AgentPreparationService +from services.dify_builder.native_testing.agent_runtime_config import ( + NativeAgentDefinition, + NativeAgentRuntimeConfigBuilder, +) +from services.dify_builder.native_testing.api_service import NativeTestApiService +from services.dify_builder.native_testing.confirmed_agent_commands import ConfirmedAgentCommands from tests.unit_tests.core.workflow.nodes.agent_v2.test_agent_node import _node from tests.unit_tests.services.dify_builder.native_testing.test_agent_context import marker -def _confirmed(monkeypatch, *, existing=False, unknown=False, custom_outputs=None): +def _resolved(context, *, generation=0): + return ResolvedNativeTestContext( + ref=context.ref, + tenant_id="tenant-1", + actor_id="user-1", + app_id="app-1", + workflow_id="workflow-1", + graph={}, + inputs={}, + graph_revision="a" * 64, + input_revision="b" * 64, + resource_manifest=(), + generation=generation, + root_native_run_id=context.native_run_id, + native_run_id=context.native_run_id, + parent_invocation_id=None, + call_depth=0, + budget_ref="budget", + cancellation_ref="cancellation", + execution_mode="native", + ) + + +def _wait(parent, *, event="original-wait", generation=2): + return AgentWaitAssociation( + parent_node=parent, + association_object_ref="association", + agent_generation=generation, + checkpoint_object_ref="owned-wait", + checkpoint_digest="b" * 64, + backend_wait_event_id=event, + ) + + +def _protected(parent, *, event="original-wait"): + return AgentProtectedState( + association_object_ref="association", + association=AgentNodeAssociation( + parent_node=parent, + workflow_native_run_id=parent.ref.request_id, + agent_run_id="original-child", + config_ref="config", + config_digest="b" * 64, + pinned_snapshot_id="snapshot", + home_snapshot_id=None, + workspace=AgentTestWorkspaceRef( + binding_id="binding", + workspace_id="workspace", + backend_binding_ref="backend-binding", + backend_workspace_ref="backend-workspace", + pinned_snapshot_id="snapshot", + home_snapshot_id=None, + workspace_generation=0, + ), + ), + wait=_wait(parent, event=event), + checkpoint={}, + terminal_result_ref=None, + terminal_result=None, + ) + + +def _api(factory, actor=None): + return NativeTestApiService( + session_factory=factory, + actor=actor or Actor(account_id="user-1", tenant_id="tenant-1"), + resolver=None, + admission=None, + resolution=None, + runtime=None, + disclosure=None, + ) + + +def _confirmed(monkeypatch, mocker, sqlite_session_factory, *, existing=False, unknown=False, custom_outputs=None): context = marker() - store = MagicMock() - store.load_context.return_value = SimpleNamespace(generation=0) - store.store_node_inputs.return_value = "input-ref" - preparation = SimpleNamespace(state_store=store, config_builder=MagicMock()) - preparation.config_builder.typed_inputs.return_value = {"query": "typed", "values": {"prior.number": 42}} - commands = MagicMock() - commands.load_wait.return_value = None - recovery = MagicMock() - recovery.recover_existing.return_value = ( - CreateRunResponse(run_id="original-child", status="running") if existing else None + api = _api(sqlite_session_factory) + store = api.store + mocker.patch.object(store, "load_context", return_value=_resolved(context)) + mocker.patch.object(store, "store_node_inputs", return_value="input-ref") + raw = Client(base_url="http://127.0.0.1:1") + backend = DifyAgentBackendRunClient(raw) + config_builder = NativeAgentRuntimeConfigBuilder(loader=api.loader, profile="confirmed") + preparation = AgentPreparationService( + state_store=store, backend=backend, workspace=None, config_builder=config_builder + ) + mocker.patch.object(config_builder, "typed_inputs", return_value={"query": "typed", "values": {"prior.number": 42}}) + commands = ConfirmedAgentCommands(api=api, continuation=None, backend=backend, publish=None) + mocker.patch.object(commands, "load_wait", return_value=None) + mocker.patch.object(commands, "continue_wait") + mocker.patch.object(commands, "prepare_wait") + recovery = WorkflowAgentRunRecovery(sqlite_session_factory) + mocker.patch.object( + recovery, + "recover_existing", + return_value=(CreateRunResponse(run_id="original-child", status="running") if existing else None), + ) + mocker.patch.object( + recovery, "start_or_recover", return_value=CreateRunResponse(run_id="original-child", status="running") ) - recovery.start_or_recover.return_value = CreateRunResponse(run_id="original-child", status="running") if unknown: recovery.recover_existing.side_effect = AgentCreateAcknowledgementUnknown("unknown") node = _node( @@ -45,6 +153,19 @@ def _confirmed(monkeypatch, *, existing=False, unknown=False, custom_outputs=Non declared_outputs=custom_outputs, error_strategy=ErrorStrategy.DEFAULT_VALUE, ) + resolver = node._binding_resolver + mocker.patch.object( + config_builder, + "load_definition", + return_value=NativeAgentDefinition( + node_id="agent-node", + agent_id=resolver.agent.id, + snapshot_id=resolver.snapshot.id, + soul=resolver.snapshot.config_snapshot, + node_job=resolver.binding.node_job_config, + stamp="a" * 64, + ), + ) run_context = node.require_run_context_value(DIFY_RUN_CONTEXT_KEY) run_context.builder_agent_execution = context # A Workflow context has no owning chat conversation. @@ -63,31 +184,41 @@ def _confirmed(monkeypatch, *, existing=False, unknown=False, custom_outputs=Non import services.dify_builder.native_testing.agent_confirmed_workspace as workspace monkeypatch.setattr(workspace, "load_confirmed_agent_parent", lambda **_: parent if existing else None) - prepared = SimpleNamespace( + prepared = AgentStartPreparation( claim=AgentCreateInserted(config_ref="config", config_digest="b" * 64), parent_node=parent, request=CreateRunRequest(composition=RunComposition(layers=[]), idempotency_key="stable-original"), ) - prepare = MagicMock(return_value=prepared) - monkeypatch.setattr(workspace, "prepare_confirmed_agent_request", prepare) - node._runtime_request_builder = MagicMock( - side_effect=AssertionError("confirmed must not use ordinary config builder") + prepare = mocker.patch.object(workspace, "prepare_confirmed_agent_request", return_value=prepared) + mocker.patch.object( + node._runtime_request_builder, "build", side_effect=AssertionError("confirmed must not rebuild ordinary tools") ) - node._runtime_request_builder.build.side_effect = AssertionError("confirmed must not rebuild ordinary tools") - node._agent_backend_client = MagicMock() - node._agent_backend_client.stream_events.return_value = iter( - [ - RunWaitingEvent( - id="waiting-event", run_id="original-child", data=RunWaitingEventData(generation=2, pending_actions=()) - ), - ] + node._agent_backend_client = backend + mocker.patch.object( + backend, "create_run", side_effect=AssertionError("confirmed must not create an ordinary child") ) - commands.prepare_wait.return_value = SimpleNamespace(model_dump=lambda **_: {"checkpoint_object_ref": "owned-wait"}) + mocker.patch.object(backend, "cancel_run_and_wait") + mocker.patch.object( + backend, + "stream_events", + return_value=iter( + [ + RunWaitingEvent( + id="waiting-event", + run_id="original-child", + data=RunWaitingEventData(generation=2, pending_actions=()), + ), + ] + ), + ) + commands.prepare_wait.return_value = _wait(parent, event="waiting-event") return node, context, parent, preparation, commands, recovery, prepare -def test_confirmed_wait_emits_pause_after_checkpoint_owner_without_failure_or_cancel(monkeypatch): - node, context, _, preparation, commands, recovery, prepare = _confirmed(monkeypatch) +def test_confirmed_wait_emits_pause_after_checkpoint_owner_without_failure_or_cancel( + mocker, sqlite_session_factory, monkeypatch +): + node, context, _, preparation, commands, recovery, prepare = _confirmed(monkeypatch, mocker, sqlite_session_factory) events = list(node._run()) assert len(events) == 1 event = events[0] @@ -105,10 +236,11 @@ def test_confirmed_wait_emits_pause_after_checkpoint_owner_without_failure_or_ca assert node.error_strategy is None -def test_restore_continues_same_child_without_compose_or_create(monkeypatch): - node, _, parent, preparation, commands, recovery, prepare = _confirmed(monkeypatch, existing=True) - wait = SimpleNamespace(backend_wait_event_id="original-wait") - protected = SimpleNamespace(wait=wait, association=SimpleNamespace(agent_run_id="original-child")) +def test_restore_continues_same_child_without_compose_or_create(mocker, sqlite_session_factory, monkeypatch): + node, _, parent, preparation, commands, recovery, prepare = _confirmed( + monkeypatch, mocker, sqlite_session_factory, existing=True + ) + protected = _protected(parent) commands.load_wait.return_value = protected commands.continue_wait.return_value = protected events = list(node._run()) @@ -122,8 +254,9 @@ def test_restore_continues_same_child_without_compose_or_create(monkeypatch): prepare.assert_not_called() -def test_node_recovers_terminal_child_after_lost_continue_ack_without_second_continue(monkeypatch): - from contextlib import nullcontext +def test_node_recovers_terminal_child_after_lost_continue_ack_without_second_continue( + mocker, sqlite_session_factory, monkeypatch +): from datetime import UTC, datetime from dify_agent.protocol import ( @@ -138,14 +271,11 @@ def test_node_recovers_terminal_child_after_lost_continue_ack_without_second_con from services.dify_builder.native_testing.confirmed_agent_commands import ConfirmedAgentCommands - node, context, parent, _, commands, recovery, prepare = _confirmed(monkeypatch, existing=True) - wait = SimpleNamespace( - parent_node=parent, - backend_wait_event_id="100-0", - agent_generation=2, - checkpoint_digest="b" * 64, + node, context, parent, _, commands, recovery, prepare = _confirmed( + monkeypatch, mocker, sqlite_session_factory, existing=True ) - protected = SimpleNamespace(wait=wait, association=SimpleNamespace(agent_run_id="original-child")) + protected = _protected(parent, event="100-0") + wait = protected.wait commands.load_wait.return_value = protected resumed = RunResumedEvent(id="101-0", run_id="original-child", data=RunResumedEventData(generation=3)) terminal = RunSucceededEvent( @@ -157,37 +287,76 @@ def test_node_recovers_terminal_child_after_lost_continue_ack_without_second_con ), ) node._agent_backend_client.stream_events.return_value = iter((resumed, terminal)) - raw = MagicMock() - raw.get_run_sync.return_value = RunStatusResponse( - run_id="original-child", - status="succeeded", - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), + raw = node._agent_backend_client.client + mocker.patch.object( + raw, + "get_run_sync", + return_value=RunStatusResponse( + run_id="original-child", + status="succeeded", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ), ) - raw.get_events_sync.return_value = RunEventsResponse( - run_id="original-child", - events=[resumed, terminal], - next_cursor=None, + mocker.patch.object( + raw, + "get_events_sync", + return_value=RunEventsResponse( + run_id="original-child", + events=[resumed, terminal], + next_cursor=None, + ), + ) + mocker.patch.object( + raw, "continue_run_sync", side_effect=AssertionError("terminal child cannot be continued again") ) - raw.continue_run_sync.side_effect = AssertionError("terminal child cannot be continued again") backend = node._agent_backend_client backend.client = raw # The C03 SQL claim is an explicit prevalidated port here; exercise the real # command's readback and public node behavior after a lost continue ACK. - loader = SimpleNamespace( - session_factory=lambda: nullcontext(SimpleNamespace(scalar=lambda _: SimpleNamespace(id="dispatch"))) - ) - store = SimpleNamespace( - load_context=lambda **_: SimpleNamespace(tenant_id="tenant-1", generation=1), - read_checkpoint=lambda **_: SimpleNamespace(agent_associations=(wait,)), - load_agent_association=lambda **_: protected, - ) - api = SimpleNamespace( - actor=None, - loader=loader, - store=store, - lifecycle=SimpleNamespace(claimed_resume_checkpoint=lambda **_: object()), + api = _api(sqlite_session_factory) + with sqlite_session_factory.begin() as session: + session.add( + NativeDispatch( + id="dispatch", + tenant_id="tenant-1", + request_id=context.ref.request_id, + invocation_id=context.ref.invocation_id, + generation=1, + idempotency_digest="c" * 64, + state="claimed", + ) + ) + mocker.patch.object(api.store, "load_context", return_value=_resolved(context, generation=1)) + checkpoint = CheckpointRef(checkpoint_id="checkpoint", payload_digest="d" * 64) + mocker.patch.object(api.lifecycle, "claimed_resume_checkpoint", return_value=checkpoint) + mocker.patch.object( + api.store, + "read_checkpoint", + return_value=NativeCheckpointPayload( + schema_version="1", + native_run_id=context.native_run_id, + generation=1, + generate_entity_json="{}", + graph_runtime_state_json="{}", + response_stream_filter_json="{}", + node_journals=( + NodeJournalState( + invocation_id=parent.ref.invocation_id, + occurrence_path=(parent.execution_id,), + input_result_ref=parent.input_result_ref, + input_digest=parent.input_digest, + cursor=0, + operation_keys=(), + result_refs=(), + ), + ), + child_refs=(), + input_result_refs=(parent.input_result_ref,), + agent_associations=(wait,), + ), ) + mocker.patch.object(api.store, "load_agent_association", return_value=protected) owner = ConfirmedAgentCommands(api=api, continuation=None, backend=backend, publish=None) commands.continue_wait.side_effect = owner.continue_wait (event,) = list(node._run()) @@ -200,8 +369,10 @@ def test_node_recovers_terminal_child_after_lost_continue_ack_without_second_con assert backend.stream_events.call_args.kwargs["after"] == "100-0" -def test_unknown_create_ack_pauses_without_approval_or_failure(monkeypatch): - node, _, _, _, commands, recovery, prepare = _confirmed(monkeypatch, existing=True, unknown=True) +def test_unknown_create_ack_pauses_without_approval_or_failure(mocker, sqlite_session_factory, monkeypatch): + node, _, _, _, commands, recovery, prepare = _confirmed( + monkeypatch, mocker, sqlite_session_factory, existing=True, unknown=True + ) events = list(node._run()) assert len(events) == 1 assert isinstance(events[0], NodeRunPauseRequestedEvent) @@ -214,10 +385,10 @@ def test_unknown_create_ack_pauses_without_approval_or_failure(monkeypatch): node._agent_backend_client.cancel_run_and_wait.assert_not_called() -def test_unverified_wait_never_becomes_resumable_approval(monkeypatch): +def test_unverified_wait_never_becomes_resumable_approval(mocker, sqlite_session_factory, monkeypatch): from runtime_contracts.builder.execution import BuilderExecutionPolicyError - node, _, _, _, commands, _, _ = _confirmed(monkeypatch) + node, _, _, _, commands, _, _ = _confirmed(monkeypatch, mocker, sqlite_session_factory) commands.prepare_wait.side_effect = BuilderExecutionPolicyError("native_agent_checkpoint_unavailable") (event,) = list(node._run()) assert isinstance(event, NodeRunPauseRequestedEvent) @@ -226,11 +397,13 @@ def test_unverified_wait_never_becomes_resumable_approval(monkeypatch): node._agent_backend_client.cancel_run_and_wait.assert_not_called() -def test_lost_continue_ack_pauses_without_streaming_or_another_create(monkeypatch): +def test_lost_continue_ack_pauses_without_streaming_or_another_create(mocker, sqlite_session_factory, monkeypatch): from runtime_contracts.builder.execution import BuilderExecutionPolicyError - node, _, _, _, commands, recovery, prepare = _confirmed(monkeypatch, existing=True) - commands.load_wait.return_value = SimpleNamespace(association=SimpleNamespace(agent_run_id="original-child")) + node, _, parent, _, commands, recovery, prepare = _confirmed( + monkeypatch, mocker, sqlite_session_factory, existing=True + ) + commands.load_wait.return_value = _protected(parent) commands.continue_wait.side_effect = BuilderExecutionPolicyError("native_agent_continuation_unknown") (event,) = list(node._run()) assert isinstance(event, NodeRunPauseRequestedEvent) @@ -241,10 +414,12 @@ def test_lost_continue_ack_pauses_without_streaming_or_another_create(monkeypatc @pytest.mark.parametrize("fault", ["transport", "exhausted"]) -def test_confirmed_stream_ack_loss_preserves_child_without_implicit_cancel(monkeypatch, fault): +def test_confirmed_stream_ack_loss_preserves_child_without_implicit_cancel( + mocker, sqlite_session_factory, monkeypatch, fault +): from clients.agent_backend import AgentBackendTransportError - node, _, _, _, _, recovery, _ = _confirmed(monkeypatch, existing=True) + node, _, _, _, _, recovery, _ = _confirmed(monkeypatch, mocker, sqlite_session_factory, existing=True) if fault == "transport": node._agent_backend_client.stream_events.side_effect = AgentBackendTransportError("lost SSE ACK") else: @@ -257,12 +432,14 @@ def test_confirmed_stream_ack_loss_preserves_child_without_implicit_cancel(monke node._agent_backend_client.cancel_run_and_wait.assert_not_called() -def test_confirmed_output_validation_retry_cannot_allocate_a_second_child(monkeypatch): +def test_confirmed_output_validation_retry_cannot_allocate_a_second_child(mocker, sqlite_session_factory, monkeypatch): from dify_agent.protocol import RunSucceededEvent, RunSucceededEventData from dify_agent.protocol.snapshot import SessionSnapshot node, _, _, _, _, recovery, prepare = _confirmed( monkeypatch, + mocker, + sqlite_session_factory, custom_outputs=[ { "name": "score", @@ -291,18 +468,18 @@ def test_confirmed_output_validation_retry_cannot_allocate_a_second_child(monkey prepare.assert_called_once() -def test_node_factory_injects_confirmed_owners_only_for_server_marker(monkeypatch): +def test_node_factory_injects_confirmed_owners_only_for_server_marker(mocker, sqlite_session_factory, monkeypatch): from services.dify_builder import wiring from services.workflow_runtime.agent_node import DifyAgentNode from services.workflow_runtime.node_factory import DifyNodeFactory - node, _, _, _, _, _, _ = _confirmed(monkeypatch) - factory = object.__new__(DifyNodeFactory) - factory._dify_context = node.require_run_context_value(DIFY_RUN_CONTEXT_KEY) - owners = (object(), object(), object()) - build = MagicMock(return_value=owners) - monkeypatch.setattr(wiring, "build_confirmed_agent_node_owners", build, raising=False) - monkeypatch.setattr("clients.agent_backend.factory.create_agent_backend_run_client", lambda **_: object()) + node, _, _, _, _, _, _ = _confirmed(monkeypatch, mocker, sqlite_session_factory) + factory = DifyNodeFactory(init_params=node.init_params, runtime_state=node.runtime_state) + owners = (node._agent_preparation, node._agent_commands, node._agent_run_recovery) + build = mocker.patch.object(wiring, "build_confirmed_agent_node_owners", return_value=owners) + monkeypatch.setattr( + "clients.agent_backend.factory.create_agent_backend_run_client", lambda **_: node._agent_backend_client + ) kwargs = factory._build_agent_node_init_kwargs(node_class=DifyAgentNode) assert kwargs["agent_preparation"] is owners[0] assert kwargs["agent_commands"] is owners[1] 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 320370e6d7d..2d5650bdb50 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -421,11 +421,12 @@ class TestDifyNodeFactoryResolveContext: class TestDifyNodeFactoryCreateNode: @pytest.fixture - def factory(self): + def factory(self, mocker): factory = object.__new__(node_factory.DifyNodeFactory) factory._native_runtime = None factory.init_params = sentinel.init_params - factory.runtime_state = MagicMock(spec=RuntimeState, variable_pool=MagicMock()) + factory.runtime_state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0) + mocker.patch.object(VariablePool, "get") factory._dify_context = DifyRunContext( builder_execution=None, tenant_id="tenant-id", @@ -1141,9 +1142,10 @@ class TestDifyNodeFactoryCreateNode: class TestDifyNodeFactoryRetrieverAttachmentAccess: @pytest.fixture - def factory(self): + def factory(self, mocker): factory = object.__new__(node_factory.DifyNodeFactory) - factory.runtime_state = MagicMock(spec=RuntimeState, variable_pool=MagicMock()) + factory.runtime_state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0) + mocker.patch.object(VariablePool, "get") return factory def test_retriever_attachment_loader_is_typed_for_llm_node_data_only(self): @@ -1226,7 +1228,7 @@ class TestDifyNodeFactoryRetrieverAttachmentAccess: class TestDifyNodeFactoryModelInstance: @pytest.fixture - def factory(self): + def factory(self, mocker): factory = object.__new__(node_factory.DifyNodeFactory) factory._llm_credentials_provider = sentinel.credentials_provider factory._llm_model_factory = sentinel.model_factory @@ -1271,7 +1273,7 @@ class TestDifyNodeFactoryModelInstance: class TestDifyNodeFactoryMemory: @pytest.fixture - def factory(self): + def factory(self, mocker): factory = object.__new__(node_factory.DifyNodeFactory) factory._dify_context = DifyRunContext( tenant_id="tenant-id", @@ -1280,7 +1282,8 @@ class TestDifyNodeFactoryMemory: user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, ) - factory.runtime_state = MagicMock(spec=RuntimeState, variable_pool=MagicMock()) + factory.runtime_state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0) + mocker.patch.object(VariablePool, "get") return factory def test_returns_none_when_memory_is_not_configured(self, factory): @@ -1399,10 +1402,11 @@ class TestNodeConfigErrorsNameTheNode: } @pytest.fixture - def factory(self): + def factory(self, mocker): factory = object.__new__(node_factory.DifyNodeFactory) factory.init_params = sentinel.init_params - factory.runtime_state = MagicMock(spec=RuntimeState, variable_pool=MagicMock()) + factory.runtime_state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0) + mocker.patch.object(VariablePool, "get") factory._dify_context = DifyRunContext( tenant_id="tenant-id", app_id="app-id", @@ -1602,10 +1606,12 @@ class TestConfirmedToolFactory: factory, graph, reserved, run_id = _confirmed_factory(consent_owner) resolved: list[tuple[object, ...]] = [] - def resolve(*_args: object, confirmed_guard: object = None, **_kwargs: object) -> SimpleNamespace: + def resolve(*_args: object, confirmed_guard: object = None, **_kwargs: object): row = consent_owner.consent_row(reserved.consent_id) resolved.append((row.state, row.native_run_id, row.native_node_execution_id, confirmed_guard)) - return SimpleNamespace(confirmed_guard=confirmed_guard) + from tests.unit_tests.core.tools.plugin_tool.test_confirmed_plugin_tool import _tool + + return _tool(confirmed_guard) monkeypatch.setattr(ToolManager, "get_workflow_tool_runtime", resolve) nodes = [factory.create_node(copy.deepcopy(node)) for node in graph["nodes"]] diff --git a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py index 96d52fd11e1..88fea93750c 100644 --- a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py +++ b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py @@ -20,8 +20,8 @@ def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): env["PYTHONSAFEPATH"] = "1" script = textwrap.dedent( """ - from core.app.apps import workflow_app_runner - from core.workflow import workflow_entry + from services.workflow_runtime import workflow_runner + from services.workflow_runtime import entry as workflow_entry from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE from services.workflow_runtime.node_factory import ( DifyNodeFactory, @@ -29,12 +29,12 @@ def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): resolve_workflow_node_class, ) from core.workflow.nodes.agent import AgentNode - from core.workflow.nodes.agent_v2 import DifyAgentNode + from services.workflow_runtime.agent_node import DifyAgentNode from graphon.enums import BuiltinNodeTypes from services import workflow_service from services.rag_pipeline import rag_pipeline - _ = workflow_entry, workflow_app_runner, workflow_service, rag_pipeline + _ = workflow_entry, workflow_runner, workflow_service, rag_pipeline expected = ( BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL, diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index c1b87ac90e9..6a76222d4ab 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom from core.app.file_access import FileAccessScope, bind_file_access_scope, grant_retriever_segment_access -from core.dify_builder.real_call_consent import ConfirmedToolGuard +from core.dify_builder.real_call_consent import ConfirmedToolBinding, ConfirmedToolGuard from core.llm_generator.output_parser.errors import OutputParserError from core.model_manager import QuotaManagedModelInstance from core.plugin.entities.plugin_daemon import CredentialType @@ -64,7 +64,9 @@ from models.dataset import SegmentAttachmentBinding from models.enums import CreatorUserRole from models.model import StorageType, UploadFile from models.tools import ToolFile +from runtime_contracts.builder.execution import ConfirmedToolExecutionContext, context_digest from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError +from services.dify_builder.real_call_consent_service import BuilderRealCallConsentService, ServiceConfirmedToolGuard from tests.workflow_test_utils import build_test_run_context @@ -1138,18 +1140,51 @@ def test_build_dify_llm_file_saver_wires_runtime_adapters(monkeypatch: pytest.Mo _CONFIRMED_PARAMETERS = {"message_id": "18cab_1", "include_body": False, "include_attachments": False} -def _confirmed_guard(*, node_id: str = "tool-node", node_execution_id: str = "node-execution-id") -> Mock: - guard = Mock() - guard.binding = SimpleNamespace( - node_id=node_id, - native_node_execution_id=node_execution_id, - context=SimpleNamespace(tenant_id="tenant-id", app_id="app-id"), +def _confirmed_guard( + *, mocker=None, node_id="tool-node", node_execution_id="node-execution-id", tenant_id="tenant-id", app_id="app-id" +): + context = ConfirmedToolExecutionContext( + policy_version="builder-confirmed-gmail-read-v1", + request_id="request", + tenant_id=tenant_id, + app_id=app_id, + workflow_id="workflow", + actor_id="actor", + session_id="session", + test_input_id="input", + execution_revision="a" * 64, + graph_revision="b" * 64, + submitted_inputs_digest="c" * 64, + effective_inputs_digest="d" * 64, + context_digest="e" * 64, + mode="confirmed_tool", + sandbox_profile="disabled", + admitted_nodes=(), + consent_id="consent", + scope_digest="f" * 64, + command_id="command", ) - guard.dispatch_headers.return_value = { - "X-Dify-Expected-Plugin-Identifier": "langgenius/dify-gmail:9.9.9@" + "f" * 64, - "X-Dify-Expected-Installation-ID": "synthetic-installation", - "X-Dify-Confirmed-Dispatch": "v1", - } + context = context.model_copy(update={"context_digest": context_digest(context)}) + guard = ServiceConfirmedToolGuard( + BuilderRealCallConsentService(sessionmaker()), + ConfirmedToolBinding( + context=context, node_id=node_id, native_run_id="native-run", native_node_execution_id=node_execution_id + ), + ) + if mocker is not None: + mocker.patch.object(guard, "before_refresh") + mocker.patch.object(guard, "before_invoke") + mocker.patch.object(guard, "observe_json") + mocker.patch.object(guard, "finish_stream") + mocker.patch.object( + guard, + "dispatch_headers", + return_value={ + "X-Dify-Expected-Plugin-Identifier": "langgenius/dify-gmail:9.9.9@" + "f" * 64, + "X-Dify-Expected-Installation-ID": "synthetic-installation", + "X-Dify-Confirmed-Dispatch": "v1", + }, + ) return guard @@ -1171,12 +1206,13 @@ def _builtin_node_data(**changes: object) -> ToolNodeData: return ToolNodeData.model_validate(values) -def test_confirmed_tool_runtime_is_resolved_with_the_guard_for_its_bound_node(monkeypatch: pytest.MonkeyPatch) -> None: - runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) - get_runtime = MagicMock(return_value=runtime_tool) - monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) +def test_confirmed_tool_runtime_is_resolved_with_the_guard_for_its_bound_node( + mocker, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_tool = _confirmed_plugin_tool(_confirmed_guard()) + get_runtime = mocker.patch.object(node_runtime.ToolManager, "get_workflow_tool_runtime", return_value=runtime_tool) monkeypatch.setattr(node_runtime, "get_system_text", lambda _pool, _key: None) - guard = _confirmed_guard() + guard = _confirmed_guard(mocker=mocker) runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard=guard) handle = runtime.get_runtime( @@ -1203,16 +1239,15 @@ def test_confirmed_tool_runtime_is_resolved_with_the_guard_for_its_bound_node(mo ], ) def test_confirmed_tool_runtime_refuses_anything_but_its_exact_bound_node( - monkeypatch: pytest.MonkeyPatch, + mocker, node_id: str, node_execution_id: str | None, changes: dict[str, object], context: dict[str, str], ) -> None: - get_runtime = MagicMock() - monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) - guard = _confirmed_guard() - guard.binding.context = SimpleNamespace(**{"tenant_id": "tenant-id", "app_id": "app-id", **context}) + get_runtime = mocker.patch.object(node_runtime.ToolManager, "get_workflow_tool_runtime") + guard = _confirmed_guard(mocker=mocker) + guard = _confirmed_guard(mocker=mocker, **context) runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard=guard) with pytest.raises(node_runtime.ToolRuntimeResolutionError): @@ -1289,24 +1324,34 @@ class _ConfirmedTransport: return stream() -def _recording_session_maker(engine: Engine) -> tuple[Mock, list[Session]]: - factory = sessionmaker(engine, expire_on_commit=False) - sessions: list[Session] = [] +class _RecordingSessionMaker(sessionmaker): + def __init__(self, engine, *, active=False): + super().__init__(engine, expire_on_commit=False) + self.sessions = [] + self.calls = [] + self.begin_calls = [] + self.active = active - def open_session() -> Session: - session = factory() - sessions.append(session) + def __call__(self, **kwargs): + self.calls.append(kwargs) + session = super().__call__(**kwargs) + self.sessions.append(session) + if self.active: + session.begin() return session @contextmanager - def begin() -> Generator[Session]: - with factory.begin() as session: - sessions.append(session) + def begin(self): + self.begin_calls.append({}) + # Distinguish the begin boundary from a direct call, as the adapter does. + with super().__call__() as session, session.begin(): + self.sessions.append(session) yield session - maker = Mock(side_effect=open_session) - maker.begin = Mock(side_effect=begin) - return maker, sessions + +def _recording_session_maker(engine: Engine): + factory = _RecordingSessionMaker(engine) + return factory, factory.sessions def _confirmed_invoke(runtime: DifyToolNodeRuntime, guard: Mock) -> Generator[ToolRuntimeMessage]: @@ -1319,20 +1364,20 @@ def _confirmed_invoke(runtime: DifyToolNodeRuntime, guard: Mock) -> Generator[To def test_confirmed_invoke_consumes_with_a_closed_session_not_the_ordinary_begin( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine + mocker, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine ) -> None: maker, sessions = _recording_session_maker(sqlite_engine) transport = _ConfirmedTransport(sessions) monkeypatch.setattr(PluginToolManager, "_request_with_plugin_daemon_response_stream", transport) - guard = _confirmed_guard() + guard = _confirmed_guard(mocker=mocker) guard.before_invoke.side_effect = lambda _parameters: transport.observed.append(sessions[-1].in_transaction()) runtime = DifyToolNodeRuntime(_build_run_context(), session_maker=maker, confirmed_guard=guard) messages = list(_confirmed_invoke(runtime, guard)) assert [message.type.value for message in messages] == ["text", "json"] - maker.assert_called_once_with() - maker.begin.assert_not_called() + assert maker.calls == [{}] + assert maker.begin_calls == [] assert transport.calls == 1 assert transport.observed assert not any(transport.observed) @@ -1340,20 +1385,10 @@ def test_confirmed_invoke_consumes_with_a_closed_session_not_the_ordinary_begin( guard.finish_stream.assert_called_once_with(complete=True, declared_failure=False) -def test_confirmed_invoke_refuses_a_session_already_in_transaction( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine -) -> None: - factory = sessionmaker(sqlite_engine, expire_on_commit=False) - - def open_session() -> Session: - session = factory() - session.begin() - return session - - maker = Mock(side_effect=open_session) - transport = Mock() - monkeypatch.setattr(PluginToolManager, "_request_with_plugin_daemon_response_stream", transport) - guard = _confirmed_guard() +def test_confirmed_invoke_refuses_a_session_already_in_transaction(mocker, sqlite_engine: Engine) -> None: + maker = _RecordingSessionMaker(sqlite_engine, active=True) + transport = mocker.patch.object(PluginToolManager, "_request_with_plugin_daemon_response_stream") + guard = _confirmed_guard(mocker=mocker) runtime = DifyToolNodeRuntime(_build_run_context(), session_maker=maker, confirmed_guard=guard) with pytest.raises(node_runtime.ToolRuntimeInvocationError): @@ -1363,12 +1398,12 @@ def test_confirmed_invoke_refuses_a_session_already_in_transaction( def test_confirmed_invoke_cancellation_finishes_unknown_exactly_once( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine + mocker, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine ) -> None: maker, sessions = _recording_session_maker(sqlite_engine) transport = _ConfirmedTransport(sessions) monkeypatch.setattr(PluginToolManager, "_request_with_plugin_daemon_response_stream", transport) - guard = _confirmed_guard() + guard = _confirmed_guard(mocker=mocker) runtime = DifyToolNodeRuntime(_build_run_context(), session_maker=maker, confirmed_guard=guard) stream = _confirmed_invoke(runtime, guard) @@ -1380,12 +1415,12 @@ def test_confirmed_invoke_cancellation_finishes_unknown_exactly_once( def test_confirmed_native_exception_stays_unknown_not_declared_failure( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine + mocker, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine ) -> None: maker, sessions = _recording_session_maker(sqlite_engine) transport = _ConfirmedTransport(sessions, fail_after=1) monkeypatch.setattr(PluginToolManager, "_request_with_plugin_daemon_response_stream", transport) - guard = _confirmed_guard() + guard = _confirmed_guard(mocker=mocker) runtime = DifyToolNodeRuntime(_build_run_context(), session_maker=maker, confirmed_guard=guard) with pytest.raises(node_runtime.ToolRuntimeInvocationError): @@ -1408,7 +1443,7 @@ def test_ordinary_tool_invoke_keeps_its_begin_transaction( return iter(()) monkeypatch.setattr(node_runtime.ToolEngine, "generic_invoke", generic_invoke) - tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) + tool = _confirmed_plugin_tool(_confirmed_guard()) runtime = DifyToolNodeRuntime(_build_run_context(), session_maker=maker) list( @@ -1420,8 +1455,8 @@ def test_ordinary_tool_invoke_keeps_its_begin_transaction( ) ) - maker.begin.assert_called_once_with() - maker.assert_not_called() + assert maker.begin_calls == [{}] + assert maker.calls == [] assert sessions_in_transaction == [True] @@ -1431,8 +1466,9 @@ def test_ordinary_tool_invoke_keeps_its_begin_transaction( class _GuardFactory: """Server-owned factory stand-in; records the actual callback order and arguments.""" - def __init__(self, events: list[str], *, result: Mock | None | Exception = None) -> None: + def __init__(self, events: list[str], *, mocker=None, result: Mock | None | Exception = None) -> None: self.events = events + self.mocker = mocker self.result = result self.calls: list[tuple[str, str]] = [] @@ -1442,28 +1478,27 @@ class _GuardFactory: if isinstance(self.result, Exception): raise self.result if self.result is None: - return _confirmed_guard(node_id=node_id, node_execution_id=node_execution_id) + return _confirmed_guard(mocker=self.mocker, node_id=node_id, node_execution_id=node_execution_id) return self.result -def _recording_tool_manager(monkeypatch: pytest.MonkeyPatch, events: list[str]) -> MagicMock: +def _recording_tool_manager(mocker, events: list[str]) -> MagicMock: def resolve(*_args: object, **kwargs: object) -> PluginTool: events.append("tool_manager") guard = kwargs["confirmed_guard"] - assert isinstance(guard, Mock) + assert isinstance(guard, ServiceConfirmedToolGuard) return _confirmed_plugin_tool(guard) - get_runtime = MagicMock(side_effect=resolve) - monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) + get_runtime = mocker.patch.object(node_runtime.ToolManager, "get_workflow_tool_runtime", side_effect=resolve) return get_runtime def test_guard_factory_claims_at_the_actual_execution_id_before_resolution( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine + mocker, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine ) -> None: events: list[str] = [] - get_runtime = _recording_tool_manager(monkeypatch, events) - factory = _GuardFactory(events) + get_runtime = _recording_tool_manager(mocker, events) + factory = _GuardFactory(events, mocker=mocker) maker, sessions = _recording_session_maker(sqlite_engine) transport = _ConfirmedTransport(sessions) monkeypatch.setattr(PluginToolManager, "_request_with_plugin_daemon_response_stream", transport) @@ -1486,18 +1521,16 @@ def test_guard_factory_claims_at_the_actual_execution_id_before_resolution( ) ) assert [message.type.value for message in messages] == ["text", "json"] - maker.begin.assert_not_called() + assert maker.begin_calls == [] assert not any(transport.observed) guard.finish_stream.assert_called_once_with(complete=True, declared_failure=False) @pytest.mark.parametrize("node_execution_id", [None, ""]) -def test_guard_factory_requires_a_real_execution_id( - monkeypatch: pytest.MonkeyPatch, node_execution_id: str | None -) -> None: +def test_guard_factory_requires_a_real_execution_id(mocker, node_execution_id: str | None) -> None: events: list[str] = [] - get_runtime = _recording_tool_manager(monkeypatch, events) - factory = _GuardFactory(events) + get_runtime = _recording_tool_manager(mocker, events) + factory = _GuardFactory(events, mocker=mocker) runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard_factory=factory) with pytest.raises(node_runtime.ToolRuntimeResolutionError, match="execution_launch_mismatch"): @@ -1516,7 +1549,7 @@ _FACTORY_SECRET = "factory-secret-sentinel-5d1" def _other_tenant_guard() -> Mock: guard = _confirmed_guard(node_id="tool-node", node_execution_id="actual-exec-7") - guard.binding.context = SimpleNamespace(tenant_id="other-tenant", app_id="app-id") + guard = _confirmed_guard(tenant_id="other-tenant", node_execution_id="actual-exec-7") return guard @@ -1532,10 +1565,10 @@ def _other_tenant_guard() -> Mock: ids=["factory-raises", "factory-refuses", "wrong-node", "wrong-execution", "wrong-tenant"], ) def test_failing_or_mismatched_factory_never_falls_through_to_ordinary_resolution( - monkeypatch: pytest.MonkeyPatch, result: Mock | Exception, reason: str + mocker, result: Mock | Exception, reason: str ) -> None: events: list[str] = [] - get_runtime = _recording_tool_manager(monkeypatch, events) + get_runtime = _recording_tool_manager(mocker, events) factory = _GuardFactory(events, result=result) runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard_factory=factory) @@ -1548,9 +1581,8 @@ def test_failing_or_mismatched_factory_never_falls_through_to_ordinary_resolutio get_runtime.assert_not_called() -def test_factory_returning_no_guard_refuses(monkeypatch: pytest.MonkeyPatch) -> None: - get_runtime = MagicMock() - monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) +def test_factory_returning_no_guard_refuses(mocker) -> None: + get_runtime = mocker.patch.object(node_runtime.ToolManager, "get_workflow_tool_runtime") def factory(_node_id: str, _node_execution_id: str) -> ConfirmedToolGuard: return cast(ConfirmedToolGuard, None) @@ -1567,12 +1599,10 @@ def test_factory_returning_no_guard_refuses(monkeypatch: pytest.MonkeyPatch) -> ("node_id", "node_execution_id"), [("tool-node", "actual-exec-7"), ("tool-node", "actual-exec-8"), ("other-node", "actual-exec-9")], ) -def test_bound_factory_guard_is_never_rebuilt_on_replay( - monkeypatch: pytest.MonkeyPatch, node_id: str, node_execution_id: str -) -> None: +def test_bound_factory_guard_is_never_rebuilt_on_replay(mocker, node_id: str, node_execution_id: str) -> None: events: list[str] = [] - get_runtime = _recording_tool_manager(monkeypatch, events) - factory = _GuardFactory(events) + get_runtime = _recording_tool_manager(mocker, events) + factory = _GuardFactory(events, mocker=mocker) runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard_factory=factory) runtime.get_runtime( @@ -1586,41 +1616,39 @@ def test_bound_factory_guard_is_never_rebuilt_on_replay( assert get_runtime.call_count == 1 -def test_configured_but_unbound_factory_refuses_direct_invoke(monkeypatch: pytest.MonkeyPatch) -> None: +def test_configured_but_unbound_factory_refuses_direct_invoke(mocker, sqlite_engine) -> None: events: list[str] = [] - factory = _GuardFactory(events) - transport = Mock() - monkeypatch.setattr(PluginToolManager, "_request_with_plugin_daemon_response_stream", transport) - guard = _confirmed_guard() - maker = Mock() + factory = _GuardFactory(events, mocker=mocker) + transport = mocker.patch.object(PluginToolManager, "_request_with_plugin_daemon_response_stream") + guard = _confirmed_guard(mocker=mocker) + maker = _RecordingSessionMaker(sqlite_engine) runtime = DifyToolNodeRuntime(_build_run_context(), session_maker=maker, confirmed_guard_factory=factory) with pytest.raises(node_runtime.ToolRuntimeInvocationError, match="confirmed_guard_unavailable"): list(_confirmed_invoke(runtime, guard)) assert factory.calls == [] - maker.assert_not_called() - maker.begin.assert_not_called() + assert maker.calls == [] + assert maker.begin_calls == [] guard.before_invoke.assert_not_called() transport.assert_not_called() -def test_bound_guard_refuses_a_handle_for_another_tool(monkeypatch: pytest.MonkeyPatch) -> None: - transport = Mock() - monkeypatch.setattr(PluginToolManager, "_request_with_plugin_daemon_response_stream", transport) - bound = _confirmed_guard() - maker = Mock() +def test_bound_guard_refuses_a_handle_for_another_tool(mocker, sqlite_engine) -> None: + transport = mocker.patch.object(PluginToolManager, "_request_with_plugin_daemon_response_stream") + bound = _confirmed_guard(mocker=mocker) + maker = _RecordingSessionMaker(sqlite_engine) runtime = DifyToolNodeRuntime(_build_run_context(), session_maker=maker, confirmed_guard=bound) with pytest.raises(node_runtime.ToolRuntimeInvocationError, match="execution_launch_mismatch"): - list(_confirmed_invoke(runtime, _confirmed_guard())) - maker.assert_not_called() + list(_confirmed_invoke(runtime, _confirmed_guard(mocker=mocker))) + assert maker.calls == [] transport.assert_not_called() -def test_configured_node_is_refused_before_the_factory_claims(monkeypatch: pytest.MonkeyPatch) -> None: +def test_configured_node_is_refused_before_the_factory_claims(mocker) -> None: events: list[str] = [] - get_runtime = _recording_tool_manager(monkeypatch, events) - factory = _GuardFactory(events) + get_runtime = _recording_tool_manager(mocker, events) + factory = _GuardFactory(events, mocker=mocker) runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard_factory=factory) with pytest.raises(node_runtime.ToolRuntimeResolutionError, match="unsupported_tool_configuration"): @@ -1648,15 +1676,14 @@ _RESOLUTION_SECRET = "resolution-secret-sentinel-8e2" ids=["runtime-error", "value-error", "tool-node-error"], ) def test_unexpected_confirmed_resolution_error_is_sanitized( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, error: Exception, use_factory: bool + mocker, caplog: pytest.LogCaptureFixture, error: Exception, use_factory: bool ) -> None: - get_runtime = MagicMock(side_effect=error) - monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) + get_runtime = mocker.patch.object(node_runtime.ToolManager, "get_workflow_tool_runtime", side_effect=error) if use_factory: - runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard_factory=_GuardFactory([])) + runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard_factory=_GuardFactory([], mocker=mocker)) else: runtime = DifyToolNodeRuntime( - _build_run_context(), confirmed_guard=_confirmed_guard(node_execution_id="actual-exec-7") + _build_run_context(), confirmed_guard=_confirmed_guard(mocker=mocker, node_execution_id="actual-exec-7") ) with pytest.raises(node_runtime.ToolRuntimeResolutionError, match="confirmed_tool_resolution_failed") as raised: @@ -1671,10 +1698,13 @@ def test_unexpected_confirmed_resolution_error_is_sanitized( get_runtime.assert_called_once() -def test_declared_confirmed_policy_refusal_keeps_its_reason(monkeypatch: pytest.MonkeyPatch) -> None: - get_runtime = MagicMock(side_effect=BuilderExecutionPolicyError("credential_cache_unavailable")) - monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) - runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard=_confirmed_guard()) +def test_declared_confirmed_policy_refusal_keeps_its_reason(mocker) -> None: + get_runtime = mocker.patch.object( + node_runtime.ToolManager, + "get_workflow_tool_runtime", + side_effect=BuilderExecutionPolicyError("credential_cache_unavailable"), + ) + runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard=_confirmed_guard(mocker=mocker)) with pytest.raises(node_runtime.ToolRuntimeResolutionError, match="credential_cache_unavailable") as raised: runtime.get_runtime( @@ -1685,10 +1715,13 @@ def test_declared_confirmed_policy_refusal_keeps_its_reason(monkeypatch: pytest. ) assert "confirmed_tool_resolution_failed" not in str(raised.value) + get_runtime.assert_called_once() -def test_ordinary_resolution_error_keeps_native_text(monkeypatch: pytest.MonkeyPatch) -> None: - get_runtime = MagicMock(side_effect=RuntimeError("ordinary provider detail")) - monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) + +def test_ordinary_resolution_error_keeps_native_text(mocker) -> None: + get_runtime = mocker.patch.object( + node_runtime.ToolManager, "get_workflow_tool_runtime", side_effect=RuntimeError("ordinary provider detail") + ) runtime = DifyToolNodeRuntime(_build_run_context()) with pytest.raises(node_runtime.ToolRuntimeResolutionError, match="ordinary provider detail") as raised: @@ -1700,9 +1733,13 @@ def test_ordinary_resolution_error_keeps_native_text(monkeypatch: pytest.MonkeyP ) assert isinstance(raised.value.__cause__, RuntimeError) + get_runtime.assert_called_once() -def test_guard_and_factory_together_are_refused() -> None: + +def test_guard_and_factory_together_are_refused(mocker) -> None: with pytest.raises(BuilderExecutionPolicyError, match="invalid_confirmed_configuration"): DifyToolNodeRuntime( - _build_run_context(), confirmed_guard=_confirmed_guard(), confirmed_guard_factory=_GuardFactory([]) + _build_run_context(), + confirmed_guard=_confirmed_guard(mocker=mocker), + confirmed_guard_factory=_GuardFactory([], mocker=mocker), ) 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 cfd64c2fa5b..649c70263a6 100644 --- a/api/tests/unit_tests/core/workflow/test_restricted_execution.py +++ b/api/tests/unit_tests/core/workflow/test_restricted_execution.py @@ -7,10 +7,10 @@ import math from collections.abc import Callable from copy import deepcopy from typing import TypedDict, cast -from unittest.mock import MagicMock import pytest +from graphon.file import File, FileTransferMethod, FileType from graphon.http import HttpClientProtocol from graphon.nodes.http_request.node import HttpRequestNode from graphon.nodes.start.start_node import StartNode @@ -53,8 +53,16 @@ def test_all_protocol_methods_serve_text_and_append_distinct_attempts(owned: Own typed_client: HttpClientProtocol = client assert typed_client.request_error is PolicyTransportError assert typed_client.max_retries_exceeded_error is not PolicyTransportError + invoke = { + "get": typed_client.get, + "head": typed_client.head, + "post": typed_client.post, + "put": typed_client.put, + "delete": typed_client.delete, + "patch": typed_client.patch, + }[method] for _ in range(2): - result = getattr(typed_client, method)("https://sample.invalid/resolved", max_retries=0) + result = invoke("https://sample.invalid/resolved", max_retries=0) assert result.status_code == 201 assert result.text == "你好" assert result.headers["content-type"] == "text/plain; charset=utf-8" @@ -123,15 +131,29 @@ def test_file_collaborators_deny_and_record(owned: Owned, entry: str) -> None: def invoke() -> None: if entry == "download": - node._file_manager.download(MagicMock()) + node._file_manager.download( + File( + file_id="file", + tenant_id="tenant", + file_type=FileType.IMAGE, + transfer_method=FileTransferMethod.TOOL_FILE, + related_id="tool-file", + filename="image.png", + extension=".png", + mime_type="image/png", + size=1, + storage_key="file-key", + ) + ) elif entry == "build_from_mapping": node._file_reference_factory.build_from_mapping(mapping={}) elif entry == "factory": node._tool_file_manager_factory() + elif entry == "create_file_by_raw": + node._file_manager.create_file_by_raw(file_binary=b"x", mimetype="x") else: - getattr(node._file_manager, entry)( - **({"file_binary": b"x", "mimetype": "x"} if entry == "create_file_by_raw" else {"tool_file_id": "x"}) - ) + assert entry == "get_file_generator_by_tool_file_id" + node._file_manager.get_file_generator_by_tool_file_id(tool_file_id="x") with pytest.raises(PolicyTransportError, match="execution_policy: http_file_capability"): invoke() @@ -144,7 +166,12 @@ def test_forbidden_raw_type_refused_before_concrete_validation_or_model_resoluti ) -> None: factory, _, _ = prepare_native(owned, "scalar.yml", inputs={"text": "x"}) - forbidden = MagicMock(side_effect=AssertionError("effectful class validation reached")) + calls = [] + + def forbidden(*args, **kwargs): + calls.append((args, kwargs)) + raise AssertionError("effectful class validation reached") + monkeypatch.setattr(factory, "_validate_resolved_node_data", forbidden) monkeypatch.setattr(factory, "_resolve_llm_model_reference", forbidden) if node_type != "new-effect": @@ -152,12 +179,11 @@ def test_forbidden_raw_type_refused_before_concrete_validation_or_model_resoluti node_type=node_type, node_version="1", node_data={"type": node_type} ) monkeypatch.setattr(forbidden_class, "validate_node_data", forbidden) - monkeypatch.setattr(forbidden_class, "__init__", forbidden) node = deepcopy(factory.init_params.graph_config["nodes"][0]) node["data"]["type"] = node_type with pytest.raises(BuilderExecutionPolicyError): factory.create_node(node) - forbidden.assert_not_called() + assert calls == [] @pytest.mark.parametrize("entry", ["create", "graph_init", "constructor"]) @@ -170,7 +196,12 @@ def test_raw_identity_and_config_gate_runs_before_concrete_validation( factory, _, _ = prepare_native(owned, "scalar.yml", inputs={"text": "x"}) node = deepcopy(factory.init_params.graph_config["nodes"][0]) - forbidden = MagicMock(side_effect=AssertionError("concrete validation reached")) + calls = [] + + def forbidden(*args, **kwargs): + calls.append((args, kwargs)) + raise AssertionError("concrete validation reached") + monkeypatch.setattr(StartNode, "validate_node_data", forbidden) if mutation == "class": @@ -205,7 +236,7 @@ def test_raw_identity_and_config_gate_runs_before_concrete_validation( with pytest.raises(BuilderExecutionPolicyError): invoke() - forbidden.assert_not_called() + assert calls == [] def test_with_runtime_state_keeps_exact_policy_recorder_and_private_key(owned: Owned) -> None: @@ -334,7 +365,11 @@ def test_actual_resolved_class_is_guarded_before_its_validator_when_registry_cha owned: Owned, monkeypatch: pytest.MonkeyPatch ) -> None: factory, _, _ = prepare_native(owned, "scalar.yml", inputs={"text": "x"}) - forbidden = MagicMock(side_effect=AssertionError("unreviewed concrete validator reached")) + calls = [] + + def forbidden(*args, **kwargs): + calls.append((args, kwargs)) + raise AssertionError("unreviewed concrete validator reached") class ChangedStart: validate_node_data = forbidden @@ -349,7 +384,7 @@ def test_actual_resolved_class_is_guarded_before_its_validator_when_registry_cha monkeypatch.setattr(factory, "_resolve_node_class", lambda **_: next(classes)) with pytest.raises(BuilderExecutionPolicyError): factory.create_node(factory.init_params.graph_config["nodes"][0]) - forbidden.assert_not_called() + assert calls == [] @pytest.mark.parametrize("change", ["binding", "implementation_version"]) @@ -376,7 +411,12 @@ def test_default_restricted_branch_still_denies_tool_nodes_after_confirmed_branc from core.tools.tool_manager import ToolManager from tests.unit_tests.core.dify_builder.test_gmail_read_policy import gmail_graph, tool_data - resolved = MagicMock() + resolutions = [] + + def resolved(*args, **kwargs): + resolutions.append((args, kwargs)) + raise AssertionError("unadmitted Tool resolution reached") + monkeypatch.setattr(ToolManager, "get_workflow_tool_runtime", resolved) with pytest.raises(BuilderExecutionPolicyError, match="unsupported_node_implementation"): admitted_node_bindings(snapshot(gmail_graph())) @@ -385,4 +425,4 @@ def test_default_restricted_branch_still_denies_tool_nodes_after_confirmed_branc with pytest.raises(BuilderExecutionPolicyError, match="unsupported_node_implementation"): factory.create_node({"id": "tool", "data": tool_data()}) assert not hasattr(factory, "_confirmed_tool_runtime") - resolved.assert_not_called() + assert resolutions == [] diff --git a/api/tests/unit_tests/core/workflow/test_template_rendering.py b/api/tests/unit_tests/core/workflow/test_template_rendering.py index d7f0f91829c..018d2c4ad02 100644 --- a/api/tests/unit_tests/core/workflow/test_template_rendering.py +++ b/api/tests/unit_tests/core/workflow/test_template_rendering.py @@ -1,5 +1,3 @@ -from types import SimpleNamespace - import pytest from core.helper.code_executor.code_executor import CodeExecutionError, CodeExecutor @@ -7,6 +5,7 @@ from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer from graphon.nodes.code.entities import CodeLanguage from graphon.template_rendering import TemplateRenderError from services.dify_builder.native_testing.control import NativePolicyStop +from services.workflow_runtime.node_factory import DefaultWorkflowCodeExecutor from tests.unit_tests.services.dify_builder.native_testing.test_call_adapter import REF @@ -22,7 +21,9 @@ def test_explicit_executor_owns_template_without_ordinary_fallback(monkeypatch): "execute_workflow_code_template", lambda **_kw: pytest.fail("ordinary fallback"), ) - renderer = CodeExecutorJinja2TemplateRenderer(executor=SimpleNamespace(execute=execute)) + executor = DefaultWorkflowCodeExecutor() + monkeypatch.setattr(executor, "execute", execute) + renderer = CodeExecutorJinja2TemplateRenderer(executor=executor) assert renderer.render_template("{{ secret }}", {"secret": "private"}) == "rendered" assert observed == [ { @@ -55,12 +56,15 @@ def test_default_renderer_preserves_existing_executor_call(monkeypatch): ) def test_explicit_renderer_translates_only_code_error_and_preserves_native_control( error, + monkeypatch, ): def execute(**kwargs): del kwargs raise error - renderer = CodeExecutorJinja2TemplateRenderer(executor=SimpleNamespace(execute=execute)) + executor = DefaultWorkflowCodeExecutor() + monkeypatch.setattr(executor, "execute", execute) + renderer = CodeExecutorJinja2TemplateRenderer(executor=executor) if type(error) is CodeExecutionError: with pytest.raises(TemplateRenderError, match="fixed"): renderer.render_template("x", {}) @@ -70,7 +74,9 @@ def test_explicit_renderer_translates_only_code_error_and_preserves_native_contr assert caught.value is error -def test_explicit_renderer_retains_native_string_result_validation(): - renderer = CodeExecutorJinja2TemplateRenderer(executor=SimpleNamespace(execute=lambda **_kw: {"result": 3})) +def test_explicit_renderer_retains_native_string_result_validation(monkeypatch): + executor = DefaultWorkflowCodeExecutor() + monkeypatch.setattr(executor, "execute", lambda **_kw: {"result": 3}) + renderer = CodeExecutorJinja2TemplateRenderer(executor=executor) with pytest.raises(TemplateRenderError, match="must be a string"): renderer.render_template("x", {}) diff --git a/api/tests/unit_tests/database_fixtures.py b/api/tests/unit_tests/database_fixtures.py new file mode 100644 index 00000000000..b39bbe8dfe7 --- /dev/null +++ b/api/tests/unit_tests/database_fixtures.py @@ -0,0 +1,15 @@ +"""Bind actual SQLAlchemy sessions to owners without replacing their database operations.""" + +from dataclasses import dataclass + +from sqlalchemy.engine import Connection, Engine +from sqlalchemy.orm import Session, scoped_session + + +@dataclass(frozen=True) +class BoundDatabase: + session: Session | scoped_session[Session] + + @property + def engine(self) -> Engine | Connection: + return self.session.get_bind() diff --git a/api/tests/unit_tests/extensions/test_ext_logging.py b/api/tests/unit_tests/extensions/test_ext_logging.py index 372a1dba093..d7bcb5d0884 100644 --- a/api/tests/unit_tests/extensions/test_ext_logging.py +++ b/api/tests/unit_tests/extensions/test_ext_logging.py @@ -14,11 +14,15 @@ from libs.sensitive_http_telemetry import ( sensitive_http_telemetry, ) from tests.unit_tests.config_override import apply_config_overrides +from tests.unit_tests.logging_helpers import protect_existing_log_routes @pytest.fixture def application_logging(monkeypatch): """Own actual app sinks and restore the original process logging configuration.""" + from libs.sensitive_http_telemetry import _HTTP_LOGGERS + + protect_existing_log_routes(monkeypatch, emitters=(*_HTTP_LOGGERS, "sqlalchemy")) root = logging.getLogger() engine = logging.getLogger("sqlalchemy.engine") root_handlers, root_level = root.handlers, root.level @@ -37,9 +41,10 @@ def application_logging(monkeypatch): monkeypatch.setattr(ext_logging.sys, "stdout", output) try: ext_logging.init_app(DifyApp(__name__)) + owned_handlers = root.handlers[:] yield output finally: - for handler in root.handlers: + for handler in owned_handlers: handler.close() for handler, filters in original_filters.items(): handler.filters = filters diff --git a/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py index e722b53f52e..9f4d12b337c 100644 --- a/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py +++ b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py @@ -1149,15 +1149,16 @@ class TestRedisShardedSubscription: assert result == mock_pubsub.get_sharded_message.return_value def test_get_subscription_ack_uses_target_node_for_cluster_client( - self, mock_pubsub: MagicMock, monkeypatch: pytest.MonkeyPatch + self, mock_pubsub: MagicMock, monkeypatch: pytest.MonkeyPatch, mocker ): class DummyRedisCluster: - def __init__(self): - self.get_node_from_key = MagicMock(return_value="node-1") + def get_node_from_key(self, _key): + return "node-1" monkeypatch.setattr("libs.broadcast_channel.redis.sharded_channel.RedisCluster", DummyRedisCluster) client = DummyRedisCluster() + mocker.spy(client, "get_node_from_key") subscription = _RedisShardedSubscription( client=client, pubsub=mock_pubsub, diff --git a/api/tests/unit_tests/libs/key_providers/test_rsa_key_provider.py b/api/tests/unit_tests/libs/key_providers/test_rsa_key_provider.py index c651c3f9eac..59f32e0a3f2 100644 --- a/api/tests/unit_tests/libs/key_providers/test_rsa_key_provider.py +++ b/api/tests/unit_tests/libs/key_providers/test_rsa_key_provider.py @@ -8,7 +8,6 @@ provider leaves open is visible. """ from collections.abc import Iterator -from types import SimpleNamespace import pytest from Crypto.PublicKey import RSA @@ -20,6 +19,7 @@ import models.engine from libs import gmpy2_pkcs10aep_cipher from libs.key_providers.rsa_key_provider import RSAKeyProvider from models.account import Tenant +from tests.unit_tests.database_fixtures import BoundDatabase _PRIVATE_KEY = RSA.generate(2048) _PUBLIC_PEM = _PRIVATE_KEY.publickey().export_key().decode() @@ -49,11 +49,11 @@ class TxWitness: @pytest.fixture def scoped( - sqlite_engine: Engine, sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch + sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch ) -> Iterator[scoped_session[Session]]: """The request-scoped session the provider sees as ``db.session``.""" registry = scoped_session(sqlite_session_factory) - monkeypatch.setattr(models.engine, "db", SimpleNamespace(session=registry, engine=sqlite_engine)) + monkeypatch.setattr(models.engine, "db", BoundDatabase(registry)) try: monkeypatch.setattr(key_provider_module, "db", models.engine.db) yield registry diff --git a/api/tests/unit_tests/libs/test_sensitive_http_telemetry.py b/api/tests/unit_tests/libs/test_sensitive_http_telemetry.py index 8983a132f80..68b813572f1 100644 --- a/api/tests/unit_tests/libs/test_sensitive_http_telemetry.py +++ b/api/tests/unit_tests/libs/test_sensitive_http_telemetry.py @@ -22,6 +22,7 @@ from dify_app import DifyApp from extensions import ext_logging from extensions.otel import instrumentation from tests.unit_tests.config_override import apply_config_overrides +from tests.unit_tests.logging_helpers import protect_existing_log_routes CANARY = "nw4-secret-query-canary" URL = f"https://example.invalid/private-path?token={CANARY}" @@ -43,10 +44,14 @@ def application_logs( logfile = tmp_path / "application.log" apply_config_overrides(monkeypatch, LOG_FILE=str(logfile), LOG_LEVEL="INFO", LOG_OUTPUT_FORMAT="text", LOG_TZ="") owned_handlers: list[logging.Handler] = [] + engine = logging.getLogger("sqlalchemy.engine") + monkeypatch.setattr(engine, "level", engine.level) + monkeypatch.setattr(engine, "propagate", engine.propagate) def initialize() -> tuple[io.StringIO, Path]: # Pytest attaches its capture handlers between setup and call phases. # Initialize the real app during the call, preserving those test sinks. + protect_existing_log_routes(monkeypatch, emitters=telemetry()._HTTP_LOGGERS) root.handlers = [] monkeypatch.setattr(ext_logging.sys, "stdout", output) ext_logging.init_app(DifyApp(__name__)) @@ -56,7 +61,7 @@ def application_logs( try: yield initialize finally: - for handler in set(owned_handlers + root.handlers).difference(old_handlers): + for handler in set(owned_handlers).difference(old_handlers): handler.close() root.handlers = old_handlers root.setLevel(old_level) diff --git a/api/tests/unit_tests/libs/test_token.py b/api/tests/unit_tests/libs/test_token.py index e4ae2bf4128..9c702c82eca 100644 --- a/api/tests/unit_tests/libs/test_token.py +++ b/api/tests/unit_tests/libs/test_token.py @@ -20,16 +20,24 @@ class MockRequest: path: str = "/console/api/test" -def test_extract_access_token(): +@pytest.mark.parametrize("secure", [False, True]) +def test_extract_access_token(config_overrides, secure): + scheme = "https" if secure else "http" + config_overrides( + CONSOLE_WEB_URL=f"{scheme}://console.example.invalid", + CONSOLE_API_URL=f"{scheme}://api.example.invalid", + COOKIE_DOMAIN="", + ) + def _mock_request(headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]) -> Request: return cast(Request, MockRequest(headers, cookies, args)) test_cases = [ (_mock_request({"Authorization": "Bearer 123"}, {}, {}), "123", "123"), - (_mock_request({}, {COOKIE_NAME_ACCESS_TOKEN: "123"}, {}), "123", None), + (_mock_request({}, {token._real_cookie_name(COOKIE_NAME_ACCESS_TOKEN): "123"}, {}), "123", None), (_mock_request({}, {}, {}), None, None), (_mock_request({"Authorization": "Bearer_aaa 123"}, {}, {}), None, None), - (_mock_request({}, {COOKIE_NAME_WEBAPP_ACCESS_TOKEN: "123"}, {}), None, "123"), + (_mock_request({}, {token._real_cookie_name(COOKIE_NAME_WEBAPP_ACCESS_TOKEN): "123"}, {}), None, "123"), ] for request, expected_console, expected_webapp in test_cases: assert extract_access_token(request) == expected_console diff --git a/api/tests/unit_tests/logging_helpers.py b/api/tests/unit_tests/logging_helpers.py new file mode 100644 index 00000000000..76aae7f4d58 --- /dev/null +++ b/api/tests/unit_tests/logging_helpers.py @@ -0,0 +1,24 @@ +"""Reversible initialization of test-owned logging routes.""" + +import logging + +import pytest + +from libs.sensitive_http_telemetry import SensitiveHttpLogFilter + + +def protect_existing_log_routes(monkeypatch: pytest.MonkeyPatch, *, emitters: tuple[str, ...]) -> None: + """Qualify imported SDK sinks for a test without changing runtime readiness. + + Provider collection imports SDKs that configure direct handlers: Langfuse + uses httpx and MLflow uses sqlalchemy.engine. Test-owned logging must protect + its existing routes as well as root sinks. Monkeypatch restores SDK setup. + """ + for name, logger in logging.Logger.manager.loggerDict.copy().items(): + if isinstance(logger, logging.Logger) and any( + name == emitter or name.startswith(emitter + ".") or emitter.startswith(name + ".") for emitter in emitters + ): + monkeypatch.setattr(logger, "propagate", True) + monkeypatch.setattr(logger, "level", logging.NOTSET) + for handler in logger.handlers: + monkeypatch.setattr(handler, "filters", [*handler.filters, SensitiveHttpLogFilter()]) diff --git a/api/tests/unit_tests/migrations/test_agent_workspace_migration.py b/api/tests/unit_tests/migrations/test_agent_workspace_migration.py index b75921d8d81..6fe1bbf0177 100644 --- a/api/tests/unit_tests/migrations/test_agent_workspace_migration.py +++ b/api/tests/unit_tests/migrations/test_agent_workspace_migration.py @@ -18,6 +18,7 @@ _MIGRATION_PATH = ( class _MigrationModule(Protocol): op: Operations upgrade: Callable[[], None] + downgrade: Callable[[], None] def _load_migration_module() -> _MigrationModule: @@ -142,3 +143,21 @@ def test_upgrade_replaces_runtime_sessions_with_workspace_schema() -> None: for table_name in ("conversations", "agent_config_drafts", "workflow_node_executions"): caller_columns = {column["name"] for column in inspector.get_columns(table_name)} assert "agent_workspace_binding_id" in caller_columns + + +def test_mysql_workspace_downgrade_restores_portable_runtime_session_types() -> None: + from io import StringIO + + module = _load_migration_module() + output = StringIO() + module.op = Operations( + MigrationContext.configure(dialect_name="mysql", opts={"as_sql": True, "output_buffer": output}) + ) + module.downgrade() + sql = output.getvalue() + assert "id CHAR(36) NOT NULL" in sql + assert "session_snapshot LONGTEXT NOT NULL" in sql + assert "composition_layer_specs LONGTEXT NOT NULL" in sql + assert "DEFAULT 'active'" in sql + assert "uuidv7()" not in sql + assert "::character varying" not in sql diff --git a/api/tests/unit_tests/migrations/test_builder_native_offline_migrations.py b/api/tests/unit_tests/migrations/test_builder_native_offline_migrations.py new file mode 100644 index 00000000000..7f08c5ffe6f --- /dev/null +++ b/api/tests/unit_tests/migrations/test_builder_native_offline_migrations.py @@ -0,0 +1,105 @@ +"""Native migration exports retain trigger protections and downgrade evidence guards.""" + +import logging +import logging.config +from io import StringIO +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from flask import Flask +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + +_MIGRATIONS_DIR = Path(__file__).resolve().parents[3] / "migrations" + + +@pytest.mark.parametrize("dialect", ["postgresql", "mysql"]) +@pytest.mark.parametrize("upgrade", [True, False], ids=["upgrade", "downgrade"]) +def test_native_migrations_export_sql_without_database_reads( + dialect: str, upgrade: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + output = StringIO() + # The real environment always applies the CLI log ini, which closes handlers + # and disables unrelated application loggers. Only isolate that setup hook. + config = Config(str(_MIGRATIONS_DIR / "alembic.ini"), output_buffer=output) + monkeypatch.setattr(logging.config, "fileConfig", lambda *_args, **_kwargs: None) + loggers = [ + logging.getLogger(), + *(logger for logger in logging.Logger.manager.loggerDict.copy().values() if isinstance(logger, logging.Logger)), + ] + routes = { + logger: (logger.disabled, logger.level, logger.propagate, tuple(logger.handlers), tuple(logger.filters)) + for logger in loggers + } + config.set_main_option("script_location", str(_MIGRATIONS_DIR)) + app = Flask(__name__) + app.config["SQLALCHEMY_DATABASE_URI"] = "postgresql://" if dialect == "postgresql" else "mysql+pymysql://" + database = SQLAlchemy(app) + Migrate(app, database) + + with app.app_context(): + try: + if upgrade: + command.upgrade(config, "base:head", sql=True) + else: + command.downgrade(config, "head:base", sql=True) + finally: + database.engine.dispose() + + assert { + logger: (logger.disabled, logger.level, logger.propagate, tuple(logger.handlers), tuple(logger.filters)) + for logger in loggers + } == routes + sql = output.getvalue() + assert "SELECT current_schema()" not in sql + if dialect == "mysql": + assert "LANGUAGE plpgsql" not in sql + assert "DO $$" not in sql + assert "IS DISTINCT FROM" not in sql + assert " IN ACCESS EXCLUSIVE MODE" not in sql + assert "SIGNAL SQLSTATE '45000'" in sql + if upgrade: + for trigger in ( + "native_attempts_immutable_update", + "native_attempts_immutable_delete", + "native_outcomes_immutable_update", + "native_outcomes_immutable_delete", + "native_payload_objects_immutable_update", + "native_continuations_immutable_delete", + "native_effect_dispatch_claims_immutable_update", + "native_read_owner_immutable", + "code_physical_monotone_update", + "code_physical_monotone_delete", + ): + assert f"CREATE TRIGGER {trigger}" in sql + else: + assert "LOCK TABLES dify_builder_native_effect_dispatch_claims WRITE" in sql + assert "LOCK TABLES dify_builder_native_code_physical_operations WRITE" in sql + assert "CALL builder_downgrade_guard()" in sql + assert "UNLOCK TABLES" in sql + else: + verb = "CREATE" if upgrade else "DROP" + for function in ( + "builder_native_immutable_ledger", + "builder_native_immutable_read_owner", + "builder_code_physical_monotone", + ): + assert f"{verb} FUNCTION {function}()" in sql + if upgrade and dialect == "postgresql": + assert "CREATE TRIGGER native_ledger_immutable" in sql + assert "CREATE TRIGGER native_read_owner_immutable" in sql + assert "CREATE TRIGGER code_physical_monotone" in sql + if not upgrade: + for reason in ( + "native continuation recovery data must be retained", + "native invocation read owner evidence must be retained", + "native dispatch claim evidence must be retained", + "Code physical evidence must be retained", + "terminate native waits before downgrade", + ): + assert reason in sql + if dialect == "postgresql": + assert "LOCK TABLE dify_builder_native_effect_dispatch_claims IN ACCESS EXCLUSIVE MODE" in sql + assert "LOCK TABLE dify_builder_native_code_physical_operations IN ACCESS EXCLUSIVE MODE" in sql diff --git a/api/tests/unit_tests/migrations/test_builder_rename_migration.py b/api/tests/unit_tests/migrations/test_builder_rename_migration.py new file mode 100644 index 00000000000..6167a9fc86c --- /dev/null +++ b/api/tests/unit_tests/migrations/test_builder_rename_migration.py @@ -0,0 +1,47 @@ +"""The Builder rename retains indexes and unique constraints on both databases.""" + +import importlib.util +from io import StringIO +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +@pytest.mark.parametrize("dialect", ["postgresql", "mysql"]) +def test_builder_rename_exports_dialect_specific_index_and_constraint_renames(dialect: str) -> None: + path = ( + Path(__file__).parents[3] + / "migrations/versions/2026_08_23_1200-df1b0114de40_rename_workflow_copilot_to_dify_builder.py" + ) + spec = importlib.util.spec_from_file_location("builder_rename", path) + assert spec is not None + assert spec.loader is not None + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + output = StringIO() + migration.__dict__["op"] = Operations( + MigrationContext.configure(dialect_name=dialect, opts={"as_sql": True, "output_buffer": output}) + ) + migration.upgrade() + migration.downgrade() + sql = output.getvalue() + if dialect == "mysql": + assert ( + "ALTER TABLE dify_builder_sessions RENAME INDEX workflow_copilot_session_tenant_idx " + "TO dify_builder_session_tenant_idx" in sql + ) + assert ( + "ALTER TABLE dify_builder_sessions RENAME INDEX dify_builder_session_tenant_idx " + "TO workflow_copilot_session_tenant_idx" in sql + ) + assert ( + "RENAME INDEX workflow_copilot_conversation_item_session_seq_key " + "TO dify_builder_conversation_item_session_seq_key" in sql + ) + assert "ALTER INDEX" not in sql + assert "RENAME CONSTRAINT" not in sql + else: + assert 'ALTER INDEX "workflow_copilot_session_tenant_idx" RENAME TO "dify_builder_session_tenant_idx"' in sql + assert 'RENAME CONSTRAINT "workflow_copilot_session_pkey" TO "dify_builder_session_pkey"' in sql diff --git a/api/tests/unit_tests/models/test_dify_builder_typed_models.py b/api/tests/unit_tests/models/test_dify_builder_typed_models.py new file mode 100644 index 00000000000..d1bdbc36386 --- /dev/null +++ b/api/tests/unit_tests/models/test_dify_builder_typed_models.py @@ -0,0 +1,117 @@ +"""Builder dataclass mapping keeps SQL defaults and identity allocation at INSERT.""" + +import dataclasses +import inspect +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from models import dify_builder, dify_builder_native_tests +from models.base import TypeBase + +MODELS = tuple( + model + for module in (dify_builder, dify_builder_native_tests) + for model in vars(module).values() + if isinstance(model, type) and model.__module__ == module.__name__ and hasattr(model, "__table__") +) + + +@pytest.mark.parametrize("model", MODELS, ids=lambda model: model.__name__) +def test_builder_models_have_explicit_keyword_only_dataclass_constructors(model): + assert issubclass(model, TypeBase) + assert dataclasses.is_dataclass(model) + parameters = inspect.signature(model).parameters + assert parameters + assert all(parameter.kind == inspect.Parameter.KEYWORD_ONLY for parameter in parameters.values()) + assert all(field.init for field in dataclasses.fields(model)) + + +def _values(model): + """Valid required values; omit every SQL/Python default and nullable field.""" + overrides = { + "state": "prepared" if model is dify_builder.DifyBuilderExecutionRequest else "running", + "kind": "context", + "decision": "approved", + "outcome": "unknown", + "category": "configuration", + "dispatch_index": 1, + } + if model is dify_builder.DifyBuilderRealCallConsent: + overrides["state"] = "prepared" + elif model is dify_builder_native_tests.NativeEffectOperation: + overrides["state"] = "pending" + elif model is dify_builder_native_tests.NativeDispatch: + overrides["state"] = "queued" + values = {} + for column in model.__table__.columns: + if column.nullable or column.default is not None or column.server_default is not None: + continue + if column.name in overrides: + value = overrides[column.name] + elif isinstance(column.type, sa.JSON): + value = [] if column.name in {"pending_ids", "resolution"} else {} + elif isinstance(column.type, sa.Boolean): + value = False + elif isinstance(column.type, sa.Integer): + value = 0 + elif isinstance(column.type, sa.DateTime): + value = datetime.now(UTC) + else: + value = str(uuid4()) + values[column.name] = value + return values + + +@pytest.mark.parametrize("model", MODELS, ids=lambda model: model.__name__) +def test_builder_insert_lifecycle_keeps_server_defaults_and_caller_ids(model): + engine = sa.create_engine("sqlite://") + try: + model.__table__.create(engine) + row = model(**_values(model)) + generated_id = model.__table__.columns.get("id") + if generated_id is not None and generated_id.default is not None: + assert row.id is None + for name in ("created_at", "updated_at"): + if name in model.__table__.columns: + assert getattr(row, name) is None + with Session(engine) as session: + session.add(row) + session.flush() + identity = sa.inspect(row).identity + assert identity is not None + assert all(identity) + for name in ("created_at", "updated_at"): + if name in model.__table__.columns: + assert isinstance(getattr(row, name), datetime) + if model is dify_builder.DifyBuilderSession: + assert row.version == 1 + session.commit() + assert session.get(model, identity) is row + if generated_id is not None: + supplied = str(uuid4()) + second = model(**{**_values(model), "id": supplied}) + session.add(second) + session.flush() + assert second.id == supplied + finally: + engine.dispose() + + +def test_execution_request_observations_have_independent_constructor_defaults(): + first = dify_builder.DifyBuilderExecutionRequest(**_values(dify_builder.DifyBuilderExecutionRequest)) + second = dify_builder.DifyBuilderExecutionRequest(**_values(dify_builder.DifyBuilderExecutionRequest)) + first.observations.append({"event": "started"}) + assert second.observations == [] + + +@pytest.mark.parametrize("model", MODELS, ids=lambda model: model.__name__) +def test_builder_rows_keep_orm_identity_equality_and_hashing(model): + values = _values(model) + first, second = model(**values), model(**values) + assert first == first + assert first != second + assert len({first, second}) == 2 diff --git a/api/tests/unit_tests/repositories/test_workflow_draft_sync_repository.py b/api/tests/unit_tests/repositories/test_workflow_draft_sync_repository.py index e9f1f8caf80..95496f98616 100644 --- a/api/tests/unit_tests/repositories/test_workflow_draft_sync_repository.py +++ b/api/tests/unit_tests/repositories/test_workflow_draft_sync_repository.py @@ -1,5 +1,4 @@ import json -from unittest.mock import Mock import pytest @@ -9,11 +8,11 @@ from repositories.workflow_draft_sync_repository import ServerDraftChange, Workf @pytest.mark.parametrize("previous", [None, {"nodes": [], "edges": []}]) def test_graphs_stay_opaque_to_lua_and_round_trip_without_changing_values( - previous: dict[str, object] | None, monkeypatch: pytest.MonkeyPatch + previous: dict[str, object] | None, monkeypatch: pytest.MonkeyPatch, mocker ) -> None: - redis = Mock() - redis.eval.return_value = 1 - monkeypatch.setattr(repo_module, "redis_client", redis) + redis = repo_module.redis_client + mocker.patch.object(redis, "eval", return_value=1) + mocker.patch.object(redis, "get") monkeypatch.setattr(repo_module, "serialize_redis_name", lambda key: f"prefix:{key}") repository = WorkflowDraftSyncRepository() change = ServerDraftChange( diff --git a/api/tests/unit_tests/services/agent/test_workflow_run_recovery.py b/api/tests/unit_tests/services/agent/test_workflow_run_recovery.py index 0c8669b982c..a75ad24cbb4 100644 --- a/api/tests/unit_tests/services/agent/test_workflow_run_recovery.py +++ b/api/tests/unit_tests/services/agent/test_workflow_run_recovery.py @@ -2,12 +2,11 @@ import dataclasses import json -from types import SimpleNamespace import httpx import pytest from dify_agent.client import Client -from dify_agent.protocol import CreateRunRequest, RunComposition +from dify_agent.protocol import CreateRunRequest, CreateRunResponse, RunComposition from sqlalchemy.orm import sessionmaker from clients.agent_backend import AgentBackendTransportError, DifyAgentBackendRunClient @@ -54,15 +53,18 @@ def test_create_intent_is_committed_before_transport_and_survives_service_restar assert restarted.process_data(scope=_scope())["kept"] == "value" -def test_recovery_needs_no_executable_request_and_never_posts(recovery): +def test_recovery_needs_no_executable_request_and_never_posts(recovery, mocker): assert recovery.recover_existing(scope=_scope(), client=None, on_persisted=lambda _data: None) is None recovery.reserve(scope=_scope(), attempt=0, key="saved", digest="a" * 64) seen = [] - response = SimpleNamespace(run_id="original-child") - client = SimpleNamespace(lookup_run=lambda request: seen.append(request) or response) + response = CreateRunResponse(run_id="original-child", status="succeeded") + client = DifyAgentBackendRunClient(Client(base_url="http://test.invalid")) + mocker.patch.object(client, "lookup_run", side_effect=lambda request: seen.append(request) or response) + create = mocker.patch.object(client, "create_run", side_effect=AssertionError("recovery must not post")) assert recovery.recover_existing(scope=_scope(), client=client, on_persisted=lambda _data: None) is response assert [(item.idempotency_key, item.request_digest) for item in seen] == [("saved", "a" * 64)] assert recovery.process_data(scope=_scope())["agent_create_attempts"][0]["child_run_id"] == "original-child" + create.assert_not_called() @pytest.mark.parametrize("field", ["tenant_id", "app_id", "workflow_id", "workflow_run_id", "node_id", "execution_id"]) diff --git a/api/tests/unit_tests/services/data_migration/test_import_service.py b/api/tests/unit_tests/services/data_migration/test_import_service.py index 28e9d81d19a..c026c058a20 100644 --- a/api/tests/unit_tests/services/data_migration/test_import_service.py +++ b/api/tests/unit_tests/services/data_migration/test_import_service.py @@ -1,8 +1,5 @@ -from contextlib import nullcontext from dataclasses import dataclass -from types import SimpleNamespace from typing import cast -from unittest.mock import Mock import pytest import yaml @@ -13,6 +10,7 @@ from core.tools.entities.tool_entities import ApiProviderSchemaType from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.model import App, AppMode from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider +from models.workflow import Workflow from services.app_dsl_service import Import from services.data_migration import import_service from services.data_migration.entities import ( @@ -487,18 +485,35 @@ def test_workflow_tool_import_publishes_referenced_app_before_create( def test_ensure_workflow_app_is_published_updates_current_workflow( + mocker, monkeypatch: pytest.MonkeyPatch, database: Database, ) -> None: _, account = _persist_tenant_account(database.session) app_id = "00000000-0000-0000-0000-000000000001" _persist_app(database.session, app_id=app_id) - publish = Mock(return_value=SimpleNamespace(id="published-workflow")) - monkeypatch.setattr(import_service, "WorkflowService", Mock(return_value=SimpleNamespace(publish_workflow=publish))) - monkeypatch.setattr( - import_service, - "sessionmaker", - lambda _engine: SimpleNamespace(begin=lambda: nullcontext(database.session)), + from services import workflow_service + + draft = Workflow.new( + tenant_id="tenant-1", + app_id=app_id, + type="workflow", + version=Workflow.VERSION_DRAFT, + graph='{"nodes":[],"edges":[]}', + features="{}", + created_by=account.id, + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + database.session.add(draft) + database.session.commit() + monkeypatch.setattr(workflow_service, "db", database) + prepare = mocker.spy(import_service.WorkflowService, "prepare_draft_tool_publish") + publish = mocker.patch.object( + import_service.WorkflowService, + "publish_workflow", + return_value=Workflow(id="published-workflow"), ) MigrationImportService()._ensure_workflow_app_is_published( ImportTarget("tenant-1", "target", "account-1", "owner@example.com"), @@ -507,7 +522,12 @@ def test_ensure_workflow_app_is_published_updates_current_workflow( session=database.session, ) + database.session.expire_all() assert database.session.get(App, app_id).workflow_id == "published-workflow" + prepare.assert_called_once() + assert prepare.call_args.kwargs["app_model"].id == app_id + publish.assert_called_once() + assert publish.call_args.kwargs["marked_name"] == "Migration import" @pytest.mark.parametrize("id_strategy", [IdStrategy.PRESERVE_ID, IdStrategy.GENERATE_NEW_ID]) diff --git a/api/tests/unit_tests/services/dify_builder/draft_tool_inventory.py b/api/tests/unit_tests/services/dify_builder/draft_tool_inventory.py index 00f775279e6..82a9940fd74 100644 --- a/api/tests/unit_tests/services/dify_builder/draft_tool_inventory.py +++ b/api/tests/unit_tests/services/dify_builder/draft_tool_inventory.py @@ -1,12 +1,10 @@ """ENG-1200 external daemon boundary with real declarations and credential SQL.""" from dataclasses import dataclass -from types import SimpleNamespace from typing import cast import pytest from sqlalchemy.engine import Engine -from sqlalchemy.orm import sessionmaker from core.dify_builder.models import Graph from repositories.credentials.query_repository import CredentialQueryRepository @@ -15,6 +13,7 @@ from tests.unit_tests.services.dify_builder.installed_tool_inventory import ( TENANT_ID, FakeTool, FakeToolDaemon, + InventoryDatabase, bind_database, forbid_secret_and_business_calls, install_fake_daemon, @@ -45,13 +44,13 @@ def draft_tools(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Draft from services.dify_builder.agent import resources bind_database(monkeypatch, sqlite_engine) - queries = CredentialQueryRepository(session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False)) - application = SimpleNamespace(credential_queries=queries) + application = resources.application_services() + queries = application.credential_queries # Preserve the actual application-services accessor even for consumers # imported before this fixture; replace only the external composition root. monkeypatch.setitem(current_app.extensions, application_module._EXTENSION_KEY, application) monkeypatch.setattr(resources, "application_services", application_module.application_services) - monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=sqlite_engine)) + monkeypatch.setattr(workflow_module, "db", InventoryDatabase(engine=sqlite_engine)) auth = provider_payload( "acme/huddle", "huddle", diff --git a/api/tests/unit_tests/services/dify_builder/installed_tool_inventory.py b/api/tests/unit_tests/services/dify_builder/installed_tool_inventory.py index 3ee706f37bf..27b4aa6eaaf 100644 --- a/api/tests/unit_tests/services/dify_builder/installed_tool_inventory.py +++ b/api/tests/unit_tests/services/dify_builder/installed_tool_inventory.py @@ -17,7 +17,6 @@ import copy import json from collections.abc import Callable, Iterable, Sequence from dataclasses import dataclass, field -from types import SimpleNamespace from typing import NoReturn, cast from urllib.parse import urlsplit @@ -32,7 +31,6 @@ from models.credential_permission import CredentialPermission from models.credential_permission import CredentialType as CredentialPermissionType from models.enums import PermissionEnum from models.tools import BuiltinToolProvider -from repositories.credentials.query_repository import CredentialQueryRepository TENANT_ID = "aaaaaaaa-0000-4000-8000-000000000001" OTHER_TENANT_ID = "aaaaaaaa-0000-4000-8000-000000000002" @@ -43,6 +41,11 @@ ACCOUNT_C = "bbbbbbbb-0000-4000-8000-00000000000c" SECRET_MARKER = "ENCRYPTED-SECRET-MUST-NOT-BE-READ" +@dataclass(frozen=True) +class InventoryDatabase: + engine: Engine + + @dataclass(frozen=True) class FakeTool: name: str @@ -331,14 +334,20 @@ def install_fake_daemon(monkeypatch: pytest.MonkeyPatch, daemon: FakeToolDaemon) def bind_database(monkeypatch: pytest.MonkeyPatch, engine: Engine) -> None: """Point inventory database handles and its credential query owner at SQLite.""" import core.tools.tool_manager as tool_manager_module + from configs import dify_config + from extensions.ext_application_services import build_application_services + from extensions.ext_redis import redis_client from services.dify_builder.agent import resources - database = SimpleNamespace(engine=engine) + database = InventoryDatabase(engine=engine) monkeypatch.setattr(resources, "db", database, raising=False) - credential_queries = CredentialQueryRepository(session_factory=sessionmaker(bind=engine, expire_on_commit=False)) - monkeypatch.setattr( - resources, "application_services", lambda: SimpleNamespace(credential_queries=credential_queries) + services = build_application_services( + database_client=sessionmaker(bind=engine, expire_on_commit=False), + deployment_edition=dify_config.DEPLOYMENT_EDITION, + initialization_password="", + redis=redis_client, ) + monkeypatch.setattr(resources, "application_services", lambda: services) monkeypatch.setattr(tool_manager_module, "db", database) @@ -425,7 +434,7 @@ def forbid_secret_and_business_calls(monkeypatch: pytest.MonkeyPatch) -> list[st (ToolManager, "get_tool_runtime"), ] for owner, attribute in targets: - monkeypatch.setattr(owner, attribute, _forbidden(f"{getattr(owner, '__name__', owner)}.{attribute}")) + monkeypatch.setattr(owner, attribute, _forbidden(f"{owner.__name__}.{attribute}")) import core.tools.tool_manager as tool_manager_module for attribute in ("create_provider_encrypter", "create_tool_provider_encrypter"): diff --git a/api/tests/unit_tests/services/dify_builder/native_owner_fixtures.py b/api/tests/unit_tests/services/dify_builder/native_owner_fixtures.py new file mode 100644 index 00000000000..42f1da589ea --- /dev/null +++ b/api/tests/unit_tests/services/dify_builder/native_owner_fixtures.py @@ -0,0 +1,60 @@ +"""Real SQLite-backed operation owners for focused Builder boundary tests.""" + +from collections.abc import Generator +from typing import cast + +import pytest +from sqlalchemy import Table, create_engine +from sqlalchemy.orm import Session, sessionmaker + +from core.dify_builder.models import Actor +from models.dify_builder import DifyBuilderExecutionRequest +from models.dify_builder_native_tests import ( + NativeEffectAttempt, + NativeEffectDispatchClaim, + NativeEffectOperation, + NativeEffectOutcome, + NativeInvocationState, + NativePayloadObject, + NativeTestInvocation, + NativeTestScope, +) +from services.dify_builder.native_testing.agent_operations import AgentOperationBindingResolver, AgentOperationService +from services.dify_builder.native_testing.repository import OwnedNativeTestLoader +from services.dify_builder.native_testing.state_store import NativeExecutionStateStore + + +@pytest.fixture +def operations_service() -> Generator[AgentOperationService]: + engine = create_engine("sqlite://") + for model in ( + DifyBuilderExecutionRequest, + NativeEffectAttempt, + NativeEffectDispatchClaim, + NativeEffectOperation, + NativeEffectOutcome, + NativeInvocationState, + NativePayloadObject, + NativeTestInvocation, + NativeTestScope, + ): + cast(Table, model.__table__).create(engine) + sessions = sessionmaker(engine, expire_on_commit=False) + loader = OwnedNativeTestLoader( + sessions, actor=Actor(account_id="actor", tenant_id="tenant"), resolver=AgentOperationBindingResolver() + ) + + def assessment(**_kwargs: object) -> None: + pytest.fail("unexpected reasoning assessment") + + try: + yield AgentOperationService(loader=loader, state_store=NativeExecutionStateStore(loader), assessor=assessment) + finally: + engine.dispose() + + +@pytest.fixture +def preparation_sql(operations_service: AgentOperationService) -> Generator[Session]: + """Close the actual preparation writer even when a boundary assertion fails.""" + with operations_service.loader.session_factory() as session: + yield session diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_associations.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_associations.py index e28a4c198d7..dd10eed7759 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_associations.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_associations.py @@ -6,13 +6,25 @@ unit doubles. Actual PostgreSQL/tenant crypto witnesses remain normal-CI only. import importlib import importlib.util +from collections.abc import Callable +from dataclasses import dataclass, replace import pytest from pydantic import ValidationError +from sqlalchemy import inspect +from models.workflow import Workflow from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest +from runtime_contracts.builder.native.agent_contracts import AgentParentNodeRef from runtime_contracts.builder.native.contracts import NativeTestContextRef -from runtime_contracts.builder.native.runtime_contracts import NativeCheckpointPayload, NodeJournalState +from runtime_contracts.builder.native.runtime_contracts import ( + NativeCheckpointPayload, + NodeJournalState, + ResolvedNativeTestContext, +) +from services.dify_builder.native_testing.private_reads import PrivateReadOwner +from services.dify_builder.native_testing.state_store import NativeExecutionStateStore +from tests.unit_tests.services.dify_builder.native_testing.test_node_inputs import LoaderStandIn EXECUTION = "81d492fd-05ce-46a8-8c82-a35d4b343d48" REF = NativeTestContextRef(request_id="request", invocation_id="inv", context_digest="a" * 64) @@ -228,12 +240,23 @@ def test_contract_purity_guard_detects_runtime_dependencies(dependency, placemen assert dependency not in runtime_imports(f"if TYPE_CHECKING:\n {statement}") +@dataclass +class AgentStateCase: + store: NativeExecutionStateStore + loader: LoaderStandIn + ref: NativeTestContextRef + context: ResolvedNativeTestContext + parent: AgentParentNodeRef + blobs: dict[str, bytes] + events: list[str] + after_readback: list[Callable[[], None]] + + @pytest.fixture def state_case(monkeypatch): """Actual state/codec; explicit SQL/permission/encryption/storage boundary doubles.""" import json from contextlib import contextmanager - from types import SimpleNamespace from core.dify_builder.models import Actor from models.account import ( @@ -287,7 +310,8 @@ def state_case(monkeypatch): ( row for row in self.owner.rows - if isinstance(row, entity) and all(getattr(row, key) == value for key, value in params.items()) + if isinstance(row, entity) + and all(inspect(row).attrs[key].value == value for key, value in params.items()) ), None, ) @@ -311,7 +335,7 @@ def state_case(monkeypatch): "current_snapshot_id", "binding_type", ): - assert getattr(row, field) == value + assert inspect(row).attrs[field].value == value return row.id if entity is App else row return super().scalar(query) @@ -368,15 +392,16 @@ def state_case(monkeypatch): } ) job = WorkflowNodeJobConfig(workflow_prompt="Unit Agent task") + account = Account(name="Native owner", email="native-owner@example.invalid", status=AccountStatus.ACTIVE) + account.id = "actor" + tenant = Tenant(name="Native tenant", status=TenantStatus.NORMAL) + tenant.id = "tenant" + membership = TenantAccountJoin(tenant_id="tenant", account_id="actor", role=TenantAccountRole.OWNER) + membership.id = "membership" loader.resources = { - Account: SimpleNamespace(id="actor", status=AccountStatus.ACTIVE), - Tenant: SimpleNamespace(id="tenant", status=TenantStatus.NORMAL), - TenantAccountJoin: SimpleNamespace( - id="membership", - tenant_id="tenant", - account_id="actor", - role=TenantAccountRole.OWNER, - ), + Account: account, + Tenant: tenant, + TenantAccountJoin: membership, App: App(id="app", tenant_id="tenant", status="normal"), Agent: Agent( id="agent", @@ -539,7 +564,7 @@ def state_case(monkeypatch): def authority(owner, ref): assert not owner.writing, "permission outside SQL writer" owner.load(ref=ref) - return SimpleNamespace( + return PrivateReadOwner( tenant_id="tenant", actor_id="actor", app_id=loader.state.app_id, @@ -564,7 +589,7 @@ def state_case(monkeypatch): monkeypatch.setattr( module, "validate_invocation_workflow", - lambda *_args, **_kwargs: SimpleNamespace(graph=json.dumps(graph), version="published"), + lambda *_args, **_kwargs: Workflow(graph=json.dumps(graph), version="published"), ) monkeypatch.setattr(module, "native_workflow_revision", lambda _workflow: "revision") store = module.NativeExecutionStateStore(loader) @@ -585,7 +610,7 @@ def state_case(monkeypatch): input_digest=canonical_digest(INPUTS), ) events.clear() - return SimpleNamespace( + return AgentStateCase( store=store, loader=loader, ref=loader.ref, @@ -853,7 +878,8 @@ def test_backend_generation_seven_cp_local_zero_remains_readable_at_local_two_sc c = state_case _, wait, raw = wait_state(c) cp = c.store.commit_checkpoint(ref=c.ref, generation=0, payload=payload_for(c, wait)) - c.loader.state.generation = c.loader.owned.generation = 2 + c.loader.state.generation = 2 + c.loader.owned = replace(c.loader.owned, generation=2) c.loader.scope.generation = 3 restored = c.store.read_checkpoint(ref=c.ref, checkpoint=cp) assert restored.generation == 0 @@ -1002,13 +1028,13 @@ def test_read_checkpoint_rechecks_selected_outer_metadata_after_private_io(state c.store.read_checkpoint(ref=c.ref, checkpoint=checkpoint_ref) -def test_actual_operation_cursor_finish_is_untouched_by_agent_associations(state_case): - from unittest.mock import Mock - +def test_actual_operation_cursor_finish_is_untouched_by_agent_associations(state_case, mocker): + from services.dify_builder.native_testing.authorization import EffectAuthorizationService from services.dify_builder.native_testing.operation_journal import NodeOperationJournal c = state_case - authorization = Mock() + authorization = EffectAuthorizationService(c.loader) + before_call = mocker.spy(authorization, "before_call") journal = NodeOperationJournal(state=c.store, authorization=authorization) cursor = journal.open(ref=c.ref, occurrence_path=(EXECUTION,)) cursor.bind_input(input_result_ref=c.parent.input_result_ref, inputs=INPUTS) @@ -1018,7 +1044,7 @@ def test_actual_operation_cursor_finish_is_untouched_by_agent_associations(state assert cursor.snapshot() == original assert original.cursor == 0 assert original.operation_keys == original.result_refs == () - authorization.before_call.assert_not_called() + before_call.assert_not_called() def test_actual_sql_workspace_backend_reference_cannot_drift(state_case): @@ -1058,7 +1084,8 @@ def test_current_local_generation_controls_config_write_without_shared_counter_a from runtime_contracts.builder.execution import BuilderExecutionPolicyError c = state_case - c.loader.state.generation = c.loader.owned.generation = 2 + c.loader.state.generation = 2 + c.loader.owned = replace(c.loader.owned, generation=2) c.loader.scope.generation = 3 with pytest.raises(BuilderExecutionPolicyError, match="native_generation_conflict"): c.store.store_agent_config( diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_risk_assessor.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_risk_assessor.py index 394b19de0fa..d4a994ca66e 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_risk_assessor.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_risk_assessor.py @@ -1,20 +1,40 @@ -from types import SimpleNamespace - import pytest +from core.tools.entities.tool_entities import ToolEntity from runtime_contracts.builder.execution import BuilderExecutionPolicyError +from services.dify_builder.native_testing.agent_operations import AgentResolvedTool from services.dify_builder.native_testing.agent_risk_assessor import assess_call +from services.dify_builder.native_testing.agent_tool_declarations import AgentToolMetadata def call(reason): return assess_call( goal="Summarize one test message", - tool=SimpleNamespace( + tool=AgentResolvedTool( + provider_type="builtin", + credential_id="account-1", + credential_version="v1", + runtime_parameters={}, + verified_plugin=True, tool_name="read_message", provider_id="official/mail/mail", account_ref="account-1", account_label="Test mailbox", - metadata=SimpleNamespace(entity=SimpleNamespace(description=SimpleNamespace(llm="Read one message by ID"))), + metadata=AgentToolMetadata( + entity=ToolEntity.model_validate( + { + "identity": { + "name": "read_message", + "author": "test", + "provider": "official/mail/mail", + "label": {"en_US": "Read message"}, + }, + "description": {"human": {"en_US": "Read message"}, "llm": "Read one message by ID"}, + } + ), + implementation_revision="test-v1", + requires_credentials=True, + ), ), target={"mailbox": "test"}, parameters={"message_id": "message-1", "token": "private-value"}, diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_authorization.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_authorization.py index d3cc4165d47..e98b8142eb8 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_authorization.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_authorization.py @@ -207,15 +207,36 @@ def test_operation_scope_preserves_typed_parameters_and_excludes_transport_corre def _binding_boundary(*, frozen, current): - from types import SimpleNamespace + from sqlalchemy.orm import sessionmaker - from runtime_contracts.builder.native.contracts import operation_digest + from core.dify_builder.models import Actor + from runtime_contracts.builder.native.contracts import NativeTestContextRef, operation_digest from services.dify_builder.native_testing.authorization import EffectAuthorizationService + from services.dify_builder.native_testing.http_binding import NativeHttpBindingResolver + from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader effect = intent() - loader = SimpleNamespace(resolver=SimpleNamespace(resolve_binding=lambda **_kwargs: current)) - owned = SimpleNamespace(ref=SimpleNamespace(invocation_id="inv"), bindings={operation_digest(effect.key): frozen}) - return EffectAuthorizationService(loader)._binding(None, owned, effect) + resolver = NativeHttpBindingResolver() + loader = OwnedNativeTestLoader( + sessionmaker(), actor=Actor(tenant_id="tenant", account_id="actor"), resolver=resolver + ) + owned = OwnedNativeTest( + ref=NativeTestContextRef(request_id="request", invocation_id=effect.key.invocation_id, context_digest="a" * 64), + bindings={operation_digest(effect.key): frozen}, + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + generation=0, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", + ) + with pytest.MonkeyPatch.context() as boundary: + boundary.setattr(resolver, "resolve_binding", lambda **_kwargs: current) + return EffectAuthorizationService(loader)._binding(None, owned, effect) @pytest.mark.parametrize("invalid", [None, True, 1, "", "OPERATION", "other", ["operation"]]) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_claimed_resume_checkpoint.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_claimed_resume_checkpoint.py index 86fec980d7e..9944cea01fc 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_claimed_resume_checkpoint.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_claimed_resume_checkpoint.py @@ -2,11 +2,13 @@ from contextlib import contextmanager from copy import deepcopy +from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from types import SimpleNamespace import pytest +from sqlalchemy import inspect +from core.dify_builder.models import Actor from models.dify_builder import DifyBuilderExecutionRequest from models.dify_builder_native_tests import ( NativeContinuation, @@ -20,12 +22,39 @@ from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import CheckpointRef, NativeTestContextRef from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService from services.dify_builder.native_testing.private_reads import PrivateReadOwner +from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader from tests.unit_tests.services.dify_builder.native_testing.test_host_capacity import SQL NOW = datetime(2026, 10, 16, tzinfo=UTC) REF = NativeTestContextRef(request_id="request", invocation_id="root", context_digest="a" * 64) +@dataclass +class ClaimedCheckpointCase: + service: NativeTestLifecycleService + session: SQL + state: NativeInvocationState + scope: NativeTestScope + request: DifyBuilderExecutionRequest + dispatch: NativeDispatch + continuation: NativeContinuation + obj: NativePayloadObject + events: list[str] + authority: PrivateReadOwner + loader: OwnedNativeTestLoader + ref: NativeTestContextRef + + +def checkpoint_metadata_snapshot(session: SQL): + """Snapshot actual ORM values to prove metadata selection never mutates rows.""" + return deepcopy( + { + model: [tuple(inspect(row).attrs[column.key].value for column in inspect(model).columns) for row in rows] + for model, rows in session.rows.items() + } + ) + + class NoPrivateIO: def __getattr__(self, name): raise AssertionError("metadata handoff must not use private store: " + name) @@ -34,7 +63,8 @@ class NoPrivateIO: @pytest.fixture def metadata_case(monkeypatch): events = [] - state = SimpleNamespace( + state = NativeInvocationState( + resolution={}, tenant_id="tenant", request_id="request", invocation_id="root", @@ -48,10 +78,25 @@ def metadata_case(monkeypatch): pending_ids=[], wait_reason=None, ) - scope = SimpleNamespace( - tenant_id="tenant", request_id="request", generation=1, cancelled=False, expires_at=NOW + timedelta(minutes=20) + scope = NativeTestScope( + task_approved=False, + max_calls=16, + max_dispatches=16, + max_cost_units=16, + budget_limits={}, + budget_spent={}, + profile={}, + tenant_id="tenant", + request_id="request", + generation=1, + cancelled=False, + expires_at=NOW + timedelta(minutes=20), ) - request = SimpleNamespace( + request = DifyBuilderExecutionRequest( + actor_id="actor", + session_id="session", + test_input_id="input", + context={}, id="request", tenant_id="tenant", context_digest=REF.context_digest, @@ -59,7 +104,8 @@ def metadata_case(monkeypatch): workflow_id="workflow", state="running", ) - dispatch = SimpleNamespace( + dispatch = NativeDispatch( + idempotency_digest="f" * 64, id="dispatch", tenant_id="tenant", request_id="request", @@ -69,7 +115,7 @@ def metadata_case(monkeypatch): checkpoint_id="checkpoint", claim_expires_at=NOW + timedelta(minutes=10), ) - continuation = SimpleNamespace( + continuation = NativeContinuation( tenant_id="tenant", request_id="request", invocation_id="root", @@ -78,7 +124,8 @@ def metadata_case(monkeypatch): payload_digest="b" * 64, payload_ref="protected-key", ) - obj = SimpleNamespace( + obj = NativePayloadObject( + value_digest="b" * 64, id="checkpoint", tenant_id="tenant", request_id="request", @@ -96,7 +143,9 @@ def metadata_case(monkeypatch): NativeContinuation: [continuation], NativePayloadObject: [obj], NativeTestInvocation: [ - SimpleNamespace(tenant_id="tenant", request_id="request", invocation_id="root", active=True) + NativeTestInvocation( + tenant_id="tenant", request_id="request", invocation_id="root", active=True, bindings={} + ) ], } session = SQL(rows, events) @@ -113,9 +162,23 @@ def metadata_case(monkeypatch): events.append("normal-owner") if request.state not in {"running", "waiting"}: raise BuilderExecutionPolicyError("native_request_terminal") - return SimpleNamespace(ref=ref, tenant_id="tenant", actor_id="actor", expires_at=scope.expires_at), scope + return OwnedNativeTest( + ref=ref, + tenant_id="tenant", + actor_id="actor", + expires_at=scope.expires_at, + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=1, + task_approved=False, + state=request.state, + ), scope - loader = SimpleNamespace(session_factory=factory, _load=load) + loader = OwnedNativeTestLoader(factory, actor=Actor(tenant_id="tenant", account_id="actor"), resolver=object()) + monkeypatch.setattr(loader, "_load", load) authority = PrivateReadOwner( tenant_id="tenant", actor_id="actor", @@ -141,7 +204,7 @@ def metadata_case(monkeypatch): monkeypatch.setattr("services.dify_builder.native_testing.lifecycle.validate_private_read", private_authority) service = NativeTestLifecycleService(loader, state_store=NoPrivateIO(), clock=lambda: NOW) - return SimpleNamespace( + return ClaimedCheckpointCase( service=service, session=session, state=state, @@ -159,10 +222,10 @@ def metadata_case(monkeypatch): def test_returns_actual_claimed_checkpoint_metadata_without_io_or_mutation(metadata_case): c = metadata_case - before = deepcopy(c.session.rows) + before = checkpoint_metadata_snapshot(c.session) actual = c.service.claimed_resume_checkpoint(ref=c.ref, generation=1, dispatch_id="dispatch") assert actual == CheckpointRef(checkpoint_id="checkpoint", payload_digest="b" * 64) - assert c.session.rows == before + assert checkpoint_metadata_snapshot(c.session) == before assert c.events[0] == "current-permission" assert "normal-owner" in c.events @@ -211,7 +274,9 @@ def test_missing_acknowledged_owner_metadata_refuses(metadata_case, model): ) def test_foreign_metadata_cannot_supply_claimed_checkpoint(metadata_case, row_name, field, value): c = metadata_case - setattr(getattr(c, row_name), field, value) + setattr( + {"dispatch": c.dispatch, "state": c.state, "continuation": c.continuation, "obj": c.obj}[row_name], field, value + ) with pytest.raises(BuilderExecutionPolicyError): c.service.claimed_resume_checkpoint(ref=c.ref, generation=1, dispatch_id="dispatch") @@ -253,10 +318,10 @@ def test_current_execution_drift_refuses_without_mutation(metadata_case, fault): c.scope.cancelled = True else: c.scope.expires_at = NOW - before = deepcopy(c.session.rows) + before = checkpoint_metadata_snapshot(c.session) with pytest.raises(BuilderExecutionPolicyError): c.service.claimed_resume_checkpoint(ref=c.ref, generation=1, dispatch_id="dispatch") - assert c.session.rows == before + assert checkpoint_metadata_snapshot(c.session) == before @pytest.mark.parametrize( diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_executor.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_executor.py index 916e2051853..31852a6e9e2 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_executor.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_executor.py @@ -1,9 +1,8 @@ """Real native nodes/transformers/C02; explicit SQL and deployment doubles only.""" from copy import deepcopy +from dataclasses import dataclass from datetime import UTC, datetime -from types import SimpleNamespace -from typing import cast from uuid import uuid4 import pytest @@ -16,14 +15,24 @@ from runtime_contracts.builder.execution import ( canonical_digest, ) from services.dify_builder.native_testing.call_adapter import NativeCallAdapter +from services.dify_builder.native_testing.checkpoint_bridge import NativeCheckpointBridge from services.dify_builder.native_testing.control import ( BoundaryOwnerState, NativeApprovalWait, NativePolicyStop, marked_native_node_class, ) +from services.dify_builder.native_testing.host_execution import NativeHostLaunch +from services.dify_builder.native_testing.host_lifetime import NativeHostLifetime from services.dify_builder.native_testing.operation_journal import NodeOperationJournal +from services.dify_builder.native_testing.private_reads import PrivateReadOwner +from services.dify_builder.native_testing.repository import OwnedNativeTest from services.dify_builder.native_testing.runtime_bridge import NativeNodePreparation, NativeRuntimeBridge +from services.dify_builder.native_testing.sandbox.host import NativeCodeHostOwner +from services.dify_builder.native_testing.sandbox.qualification import ( + NativeCodeBindingProducer, + NativeCodeQualification, +) from tests.unit_tests.services.dify_builder.native_testing.test_call_adapter import ( REF, Authorization, @@ -33,6 +42,31 @@ from tests.unit_tests.services.dify_builder.native_testing.test_code_qualificati qualification, raw_code, ) +from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import ( + DurablePorts, + NoCheckpointLifecycle, +) +from tests.unit_tests.services.dify_builder.native_testing.test_runtime_bridge import SyntheticSink, _api + + +@dataclass +class CodeExecutorCase: + q: NativeCodeQualification + seen: list + events: list + state: State + auth: Authorization + journal: NodeOperationJournal + producer: NativeCodeBindingProducer + node: CodeNode + association: NativeNodePreparation + raw: dict + installed: list[dict] + owner: NativeCodeHostOwner + + +def refuse_constructor_collaborators(**_kwargs): + raise AssertionError("node preparation collaborators are not used by executor tests") @pytest.fixture @@ -70,13 +104,15 @@ def case(monkeypatch): monkeypatch.setattr(state, "store_node_inputs", store_inputs, raising=False) runtime = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) - bridge = SimpleNamespace( - _boundary_owner=BoundaryOwnerState(ref=REF), - _control_sink=SimpleNamespace(record_control=lambda **_kw: None), - resolve=lambda ref: state.load_context(ref=ref), + bridge = NativeRuntimeBridge( + state=state, + boundary_owner=BoundaryOwnerState(ref=REF), + control_sink=SyntheticSink(), + collaborators=refuse_constructor_collaborators, + node_arguments=refuse_constructor_collaborators, ) association = NativeNodePreparation( - bridge=cast(NativeRuntimeBridge, bridge), + bridge=bridge, ref=REF, node_id="code", node_class=marked_native_node_class(CodeNode, marked=True), @@ -93,9 +129,58 @@ def case(monkeypatch): code_limits=module.code_limits(), ) association.attach(node) - loader = SimpleNamespace(load=lambda **_kw: SimpleNamespace(expires_at=datetime(2030, 1, 1, tzinfo=UTC))) - monkeypatch.setattr(module, "validate_private_read", lambda *_a: SimpleNamespace(generation=0)) + api = _api() + loader = api.loader + context = state.context + owned = OwnedNativeTest( + ref=context.ref, + tenant_id=context.tenant_id, + actor_id=context.actor_id, + app_id=context.app_id, + workflow_id=context.workflow_id, + session_id="session", + test_input_id="input", + bindings={}, + generation=0, + task_approved=True, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", + ) + monkeypatch.setattr(loader, "load", lambda **_kw: owned) + authority = PrivateReadOwner( + tenant_id=context.tenant_id, + actor_id=context.actor_id, + app_id=context.app_id, + workflow_id=context.workflow_id, + parent_invocation_id=None, + context_object_id="context", + generation=0, + graph_revision=context.graph_revision, + root_revision_matches=True, + input_revision=context.input_revision, + expires_at=owned.expires_at, + cancelled=False, + active=True, + request_state="running", + ) + monkeypatch.setattr(module, "validate_private_read", lambda *_a: authority) journal = NodeOperationJournal(state=state, authorization=auth) + dispatch_id = str(uuid4()) + lifetime = NativeHostLifetime(context=context, dispatch_id=dispatch_id) + ports = DurablePorts() + host = NativeHostLaunch( + context=context, + dispatch_id=dispatch_id, + lifetime=lifetime, + journal=journal, + checkpoints=NativeCheckpointBridge(state=state, lifecycle=NoCheckpointLifecycle()), + fence_pending=ports.fence_pending, + stop_sealer=ports, + authorization=auth, + http_baseline=(1, 1, 1), + ) + owner = NativeCodeHostOwner(host=host, loader=loader) + monkeypatch.setattr(owner, "check_current", lambda _acquisition: None) producer = module.NativeCodeBindingProducer( association=association, loader=loader, @@ -105,7 +190,8 @@ def case(monkeypatch): ) installed = [] monkeypatch.setattr(producer, "_install", lambda **kw: installed.append(kw)) - return SimpleNamespace( + return CodeExecutorCase( + owner=owner, q=q, seen=seen, events=events, @@ -179,7 +265,7 @@ def test_code_result_replay_has_zero_sdk_io_and_real_transformer(case, monkeypat journal=case.journal, max_result_bytes=8 << 20, ) - owner = SimpleNamespace(check_current=lambda _a: None, acquisition=object()) + owner = case.owner execute = NativeCodeExecutor(producer=case.producer, calls=calls, owner=owner, on_prepared=lambda: None) assert execute.execute(language=CodeLanguage.PYTHON3, code=case.raw["code"], inputs={}) == { "result": "actual parsed" @@ -281,7 +367,7 @@ def test_current_selector_change_after_claim_refuses_before_transport(case, monk execute = module.NativeCodeExecutor( producer=case.producer, calls=Calls(), - owner=SimpleNamespace(check_current=lambda _a: None, acquisition=object()), + owner=case.owner, on_prepared=lambda: None, ) with pytest.raises(BuilderExecutionPolicyError): @@ -317,7 +403,7 @@ def test_wait_then_claim_preserves_logical_intent_and_replay_never_constructs_tr execute = module.NativeCodeExecutor( producer=case.producer, calls=calls, - owner=SimpleNamespace(check_current=lambda _a: None, acquisition=object()), + owner=case.owner, on_prepared=lambda: None, ) case.auth.decisions[0] = ApprovalWait(pending_operation_id="pending") diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_host.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_host.py index e74755bc7f1..f232857eaa6 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_host.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_host.py @@ -1,11 +1,13 @@ """Actual retained lifetime and C02 wrapper, external persistence is a double.""" +from dataclasses import dataclass +from datetime import datetime from time import monotonic from uuid import uuid4 import pytest -from runtime_contracts.builder.native.contracts import ApprovalWait +from runtime_contracts.builder.native.contracts import ApprovalWait, NativeTestContextRef from services.dify_builder.native_testing.call_adapter import NativeCallAdapter from services.dify_builder.native_testing.control import ( NativeApprovalWait, @@ -21,6 +23,19 @@ from tests.unit_tests.services.dify_builder.native_testing.test_host_execution i ) +@dataclass +class ReservedCodeScope: + """Metadata returned by the labeled physical persistence boundary.""" + + scope_expires_at: datetime + scope_spec_digest: str + operation_id: str + ref: NativeTestContextRef + scope_id: str + scope_generation: int + destroyed_at: datetime | None + + def code_case(): from services.dify_builder.native_testing.host_execution import NativeHostLaunch @@ -125,8 +140,6 @@ class PhysicalStore: self.row = None def reserve_scope(self, **kwargs): - from types import SimpleNamespace - assert self.owner.host.lifetime.code_participants self.events.append("reserve_scope") assert self.row is None @@ -135,7 +148,7 @@ class PhysicalStore: ) from runtime_contracts.builder.execution import canonical_digest - self.row = SimpleNamespace( + self.row = ReservedCodeScope( scope_expires_at=native.deadline, scope_spec_digest=canonical_digest(native.model_dump(mode="json")), operation_id=kwargs["permit"].operation_id, diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_physical_repository.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_physical_repository.py index 53cadf221d3..c0d8cf1d78b 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_physical_repository.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_physical_repository.py @@ -1,26 +1,40 @@ -"""Ordinary unit witnesses; fake storage cannot qualify SQL races or triggers.""" +"""Real SQLite transition witnesses; PostgreSQL timeout transport is the boundary.""" + +from __future__ import annotations from contextlib import contextmanager -from copy import deepcopy +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from time import monotonic -from types import SimpleNamespace -from typing import cast from uuid import uuid4 import pytest from pydantic import ValidationError +from sqlalchemy import create_engine, event, inspect +from sqlalchemy.orm import Session, sessionmaker +from core.dify_builder.models import Actor +from models.dify_builder_native_code import NativeCodePhysicalOperation +from models.dify_builder_native_tests import ( + NativeDispatch, + NativeEffectAttempt, + NativeEffectDispatchClaim, + NativeEffectOperation, + NativeInvocationState, + NativeTestScope, +) from runtime_contracts.builder.execution import ( BuilderExecutionPolicyError, canonical_digest, ) from runtime_contracts.builder.native.contracts import NativeTestContextRef -from services.dify_builder.native_testing.repository import OwnedNativeTestLoader +from services.dify_builder.native_testing.api_service import NativeTestApiService +from services.dify_builder.native_testing.repository import OwnedNativeTest from services.dify_builder.native_testing.sandbox.contracts import ( CodeAcquisitionRef, CodePhysicalRef, ) +from services.dify_builder.native_testing.sandbox.repository import NativeCodePhysicalRepository def fixed_transaction_deadline(): @@ -68,6 +82,84 @@ def physical(): ) +@dataclass +class TransitionCase: + repo: NativeCodePhysicalRepository + physical: CodePhysicalRef + row: NativeCodePhysicalOperation + current: Current + committed: list[dict[str, object]] + calls: list[dict[str, object]] + + +@dataclass +class PhysicalOwnerCase: + repo: NativeCodePhysicalRepository + args: dict[str, object] + stored: list[NativeCodePhysicalOperation] + rows: dict[type, object] + operation: NativeEffectOperation + attempt: NativeEffectAttempt + claim: NativeEffectDispatchClaim + root: NativeDispatch + state: NativeInvocationState + current: Current + events: list[str] + session: Session + owned: OwnedNativeTest + scope: NativeTestScope + + +def _loader(factory=None): + return NativeTestApiService( + session_factory=factory or sessionmaker(), + actor=Actor(account_id="actor", tenant_id="tenant"), + resolver=None, + admission=None, + resolution=None, + runtime=None, + disclosure=None, + ).loader + + +def _scope(p, *, expires_at=None): + return NativeTestScope( + tenant_id=p.tenant_id, + request_id=p.ref.request_id, + generation=0, + task_approved=True, + cancelled=False, + expires_at=expires_at or datetime.now(UTC) + timedelta(minutes=1), + budget_limits={}, + budget_spent={}, + profile={}, + max_calls=1, + max_dispatches=1, + max_cost_units=1, + ) + + +def _owned(p): + return OwnedNativeTest( + ref=p.ref, + tenant_id=p.tenant_id, + actor_id=p.actor_id, + app_id=uid(), + workflow_id=uid(), + session_id=uid(), + test_input_id=uid(), + bindings={}, + generation=0, + task_approved=True, + expires_at=datetime.now(UTC) + timedelta(minutes=1), + state="running", + ) + + +def _row_snapshot(row): + return {column.key: inspect(row).attrs[column.key].value for column in inspect(type(row)).column_attrs} + + @pytest.mark.parametrize( ("field", "value"), [ @@ -100,18 +192,47 @@ class Current: raise BuilderExecutionPolicyError("stale_acquisition") +def _timeout_loader(mocker, p, *, events, execute, expires_at, record_begin=False): + """Real PostgreSQL dialect and Session transaction; timeout transport is the seam.""" + + class TimeoutSession(Session): + pass + + engine = create_engine("postgresql+psycopg2://unused@127.0.0.1:1/unused") + factory = sessionmaker(bind=engine, class_=TimeoutSession) + if record_begin: + event.listen(TimeoutSession, "after_transaction_create", lambda *_: events.append("begin")) + event.listen(TimeoutSession, "after_commit", lambda *_: events.append("commit")) + mocker.patch.object(TimeoutSession, "execute", side_effect=execute) + loader = _loader(factory) + mocker.patch.object(loader, "load", return_value=None) + mocker.patch.object(loader, "_load", return_value=(_owned(p), _scope(p, expires_at=expires_at))) + return loader + + @pytest.fixture -def transition(monkeypatch): +def transition(monkeypatch, sqlite_session): from services.dify_builder.native_testing.sandbox.repository import ( NativeCodePhysicalRepository, ) p = physical() - row = SimpleNamespace(**p.model_dump(exclude={"ref", "acquisition"})) + row = NativeCodePhysicalOperation( + **p.model_dump(exclude={"ref", "acquisition", "id"}), + request_id=p.ref.request_id, + invocation_id=p.ref.invocation_id, + host_dispatch_id=p.acquisition.root_dispatch_id, + host_acquisition_id=p.acquisition.acquisition_id, + root_invocation_id=p.ref.invocation_id, + host_generation=p.acquisition.generation, + ) + row.id = p.id + sqlite_session.add(row) + sqlite_session.commit() current = Current() - loader = SimpleNamespace() + loader = _loader() repo = NativeCodePhysicalRepository( - cast(OwnedNativeTestLoader, loader), + loader, acquisition_owner=current, transaction_deadline=fixed_transaction_deadline(), ) @@ -121,19 +242,21 @@ def transition(monkeypatch): @contextmanager def transaction(**kwargs): calls.append(kwargs) - saved = deepcopy(vars(row)) + if sqlite_session.in_transaction() or sqlite_session.dirty: + sqlite_session.commit() try: - yield SimpleNamespace(flush=lambda: None), None, None + with sqlite_session.begin(): + yield sqlite_session, None, None except BaseException: - vars(row).clear() - vars(row).update(saved) + sqlite_session.refresh(row) + sqlite_session.commit() raise else: - committed.append(deepcopy(vars(row))) + committed.append(_row_snapshot(row)) monkeypatch.setattr(repo, "_transaction", transaction) monkeypatch.setattr(repo, "_physical_row", lambda **_kwargs: row) - return SimpleNamespace( + return TransitionCase( repo=repo, physical=p, row=row, @@ -242,7 +365,7 @@ def test_lost_commit_response_never_makes_a_second_job_reservation(transition, m @pytest.fixture -def owner_case(monkeypatch): +def owner_case(monkeypatch, sqlite_session): from shellctl.shared.schemas import NativeScopeLimits, NativeScopeSpec from models.dify_builder_native_code import NativeCodePhysicalOperation @@ -325,34 +448,54 @@ def owner_case(monkeypatch): "request_id": p.ref.request_id, "invocation_id": p.ref.invocation_id, } - operation = SimpleNamespace( + operation = NativeEffectOperation( **owner, id=p.operation_id, operation_digest=operation_digest(key), intent_digest=canonical_digest(intent.model_dump(mode="json")), state="claimed", + scope_digest="a" * 64, ) - attempt = SimpleNamespace( + attempt = NativeEffectAttempt( **owner, id=p.attempt_id, operation_id=p.operation_id, execution_id=p.native_execution_id, expires_at=now + timedelta(seconds=30), + grant_id=uid(), + dispatch_index=1, + permit_digest="a" * 64, + cost_units=1, + budget_reservation_ref="budget", + invocation_digest="b" * 64, + budget={}, + profile={}, ) - claim = SimpleNamespace( + claim = NativeEffectDispatchClaim( **owner, id=p.effect_claim_id, operation_id=p.operation_id, attempt_id=p.attempt_id, + claimed_at=now, ) - root = SimpleNamespace( + root = NativeDispatch( **owner, id=p.acquisition.root_dispatch_id, generation=0, state="claimed", claim_expires_at=now + timedelta(seconds=30), + idempotency_digest="c" * 64, + ) + state = NativeInvocationState( + **owner, + generation=0, + parent_invocation_id=None, + cleanup_complete=False, + context_object_id="context", + state="running", + pending_ids=[], + resolution=[], ) - state = SimpleNamespace(**owner, generation=0, parent_invocation_id=None, cleanup_complete=False) rows = { NativeEffectOperation: operation, NativeEffectAttempt: attempt, @@ -363,44 +506,34 @@ def owner_case(monkeypatch): stored = [] events = [] - class Session: - def get(self, model, identity): - if model is NativeCodePhysicalOperation: - return next((r for r in stored if r.id == identity), None) - item = rows.get(model) - return item if item and item.id == identity else None + session = sqlite_session + session.add_all([operation, attempt, claim, root, state]) + session.commit() + from sqlalchemy import event - def scalar(self, query): - model = query.column_descriptions[0]["entity"] - if model is NativeCodePhysicalOperation: - return stored[0].id if stored else None - return rows.get(model) + def added(_session, instance): + if isinstance(instance, NativeCodePhysicalOperation): + stored.append(instance) - def scalars(self, query): - del query - return [state] - - def add(self, row): - stored.append(row) - - def flush(self): - events.append("flush") - - session = Session() + event.listen(session, "after_attach", added) + event.listen(session, "before_flush", lambda *_args: events.append("flush")) current = Current() repo = NativeCodePhysicalRepository( - cast(OwnedNativeTestLoader, SimpleNamespace()), + _loader(), acquisition_owner=current, transaction_deadline=fixed_transaction_deadline(), ) - owned = SimpleNamespace(**owner, ref=p.ref, actor_id=p.actor_id) - scope = SimpleNamespace(expires_at=now + timedelta(minutes=1)) + owned = _owned(p) + scope = _scope(p) @contextmanager def transaction(**kwargs): current.check_current(kwargs["acquisition"]) - yield session, owned, scope - current.check_current(kwargs["acquisition"]) + if session.in_transaction() or session.dirty: + session.commit() + with session.begin(): + yield session, owned, scope + current.check_current(kwargs["acquisition"]) events.append("commit") current.check_current(kwargs["acquisition"]) @@ -430,7 +563,7 @@ def owner_case(monkeypatch): "profile_digest": p.profile_digest, "physical_deadline": now + timedelta(seconds=15), } - return SimpleNamespace( + return PhysicalOwnerCase( repo=repo, args=args, stored=stored, @@ -477,7 +610,11 @@ def test_scope_reservation_rechecks_real_intent_without_nested_permit_transactio ) def test_disconnected_owner_chain_cannot_reserve_scope(owner_case, row, field): t = owner_case - setattr(getattr(t, row), field, uid()) + setattr( + {"operation": t.operation, "attempt": t.attempt, "claim": t.claim, "root": t.root, "state": t.state}[row], + field, + uid(), + ) with pytest.raises(BuilderExecutionPolicyError): t.repo.reserve_scope(**t.args) assert t.stored == [] @@ -528,90 +665,57 @@ def test_aborted_scope_reservation_cannot_retry_same_delivery(owner_case, monkey assert t.stored == [] -def test_initial_lock_timeout_precedes_ownership_read_and_current_fences_commit( - monkeypatch, -): - del monkeypatch - from services.dify_builder.native_testing.sandbox.repository import ( - NativeCodePhysicalRepository, - ) +def test_initial_lock_timeout_precedes_ownership_read_and_current_fences_commit(mocker): + from services.dify_builder.native_testing.sandbox.repository import NativeCodePhysicalRepository p = physical() events = [] class Owner: def check_current(self, acquisition): - del acquisition + assert acquisition == p.acquisition events.append("current") - class Session: - def get_bind(self): - return SimpleNamespace(dialect=SimpleNamespace(name="postgresql")) + def timeout(statement, params): + assert "set_config" in str(statement) + assert 0 < int(params["limit"][:-2]) <= 1000 + events.append("timeout") - def execute(self, statement, params): - assert "set_config" in str(statement) - assert 0 < int(params["limit"][:-2]) <= 1000 - events.append("timeout") - - @contextmanager - def begin(): - events.append("begin") - yield Session() - events.append("commit") + loader = _timeout_loader( + mocker, + p, + events=events, + execute=timeout, + expires_at=datetime.now(UTC) + timedelta(seconds=30), + record_begin=True, + ) def load(session, **kwargs): - del session + assert isinstance(session, Session) assert events[-1] == "timeout" assert kwargs["lock"] is True events.append("load") - return None, SimpleNamespace(expires_at=datetime.now(UTC) + timedelta(seconds=30)) + return _owned(p), _scope(p) - loader = SimpleNamespace(session_factory=SimpleNamespace(begin=begin), _load=load) + loader._load.side_effect = load repo = NativeCodePhysicalRepository( - cast(OwnedNativeTestLoader, loader), - acquisition_owner=Owner(), - transaction_deadline=fixed_transaction_deadline(), + loader, acquisition_owner=Owner(), transaction_deadline=fixed_transaction_deadline() ) with repo._transaction(ref=p.ref, acquisition=p.acquisition, settlement=True): events.append("mutation") - assert events == [ - "current", - "begin", - "timeout", - "load", - "mutation", - "current", - "commit", - "current", - ] + assert events == ["current", "begin", "timeout", "load", "mutation", "current", "commit", "current"] -def test_admission_expiring_during_writer_rolls_back_before_commit(monkeypatch): +def test_admission_expiring_during_writer_rolls_back_before_commit(mocker, monkeypatch): from services.dify_builder.native_testing.sandbox import repository as module p = physical() events = [] deadline = datetime.now(UTC) + timedelta(seconds=1) - class Session: - def get_bind(self): - return SimpleNamespace(dialect=SimpleNamespace(name="postgresql")) - - def execute(self, *args, **kwargs): - pass - - @contextmanager - def begin(): - yield Session() - events.append("commit") - - loader = SimpleNamespace( - session_factory=SimpleNamespace(begin=begin), - load=lambda **_kwargs: None, - _load=lambda *_args, **_kwargs: (None, SimpleNamespace(expires_at=deadline)), - ) + loader = _timeout_loader(mocker, p, events=events, execute=lambda *_args, **_kwargs: None, expires_at=deadline) repo = module.NativeCodePhysicalRepository( - cast(OwnedNativeTestLoader, loader), + loader, acquisition_owner=Current(), transaction_deadline=fixed_transaction_deadline(), ) @@ -624,7 +728,7 @@ def test_admission_expiring_during_writer_rolls_back_before_commit(monkeypatch): @pytest.mark.parametrize("settlement", [False, True]) -def test_transaction_uses_exact_operation_budget_and_refuses_expiry_before_commit(monkeypatch, settlement): +def test_transaction_uses_exact_operation_budget_and_refuses_expiry_before_commit(mocker, monkeypatch, settlement): from services.dify_builder.native_testing.sandbox import repository as module p = physical() @@ -632,23 +736,11 @@ def test_transaction_uses_exact_operation_budget_and_refuses_expiry_before_commi clock = [10.0] monkeypatch.setattr(module, "monotonic", lambda: clock[0], raising=False) - class Session: - def get_bind(self): - return SimpleNamespace(dialect=SimpleNamespace(name="postgresql")) + def timeout(_statement, params): + limits.append(int(params["limit"][:-2])) - def execute(self, statement, params): - del statement - limits.append(int(params["limit"][:-2])) - - @contextmanager - def begin(): - yield Session() - events.append("commit") - - loader = SimpleNamespace( - session_factory=SimpleNamespace(begin=begin), - load=lambda **_kwargs: None, - _load=lambda *_args, **_kwargs: (None, SimpleNamespace(expires_at=datetime.now(UTC) + timedelta(seconds=30))), + loader = _timeout_loader( + mocker, p, events=events, execute=timeout, expires_at=datetime.now(UTC) + timedelta(seconds=30) ) def transaction_deadline(is_settlement): @@ -656,7 +748,7 @@ def test_transaction_uses_exact_operation_budget_and_refuses_expiry_before_commi return 10.05 if is_settlement else 10.03 repo = module.NativeCodePhysicalRepository( - cast(OwnedNativeTestLoader, loader), acquisition_owner=Current(), transaction_deadline=transaction_deadline + loader, acquisition_owner=Current(), transaction_deadline=transaction_deadline ) def expire_during_transaction(): @@ -676,27 +768,23 @@ def test_transaction_uses_exact_operation_budget_and_refuses_expiry_before_commi @pytest.mark.parametrize("deadline", [float("nan"), float("inf"), True, None, "later", -1.0]) -def test_invalid_or_expired_transaction_deadline_never_opens_writer(deadline): +def test_invalid_or_expired_transaction_deadline_never_opens_writer(mocker, deadline): from services.dify_builder.native_testing.sandbox.repository import ( NativeCodePhysicalRepository, ) p = physical() - @contextmanager - def begin(): - pytest.fail("invalid budget opened SQL writer") - yield - - loader = SimpleNamespace(session_factory=SimpleNamespace(begin=begin), load=lambda **_kwargs: None) - repo = NativeCodePhysicalRepository( - cast(OwnedNativeTestLoader, loader), acquisition_owner=Current(), transaction_deadline=lambda _: deadline - ) + factory = sessionmaker() + begin = mocker.patch.object(factory, "begin", side_effect=AssertionError("invalid budget opened SQL writer")) + loader = _loader(factory) + repo = NativeCodePhysicalRepository(loader, acquisition_owner=Current(), transaction_deadline=lambda _: deadline) with ( pytest.raises(BuilderExecutionPolicyError), repo._transaction(ref=p.ref, acquisition=p.acquisition, settlement=True), ): pytest.fail("invalid budget admitted mutation") + begin.assert_not_called() @pytest.mark.parametrize("cap", ["candidate", "logical", "attempt", "scope", "root"]) @@ -715,7 +803,7 @@ def test_admission_persists_once_bounded_wire_spec_separate_from_logical_intent( if cap == "root": t.root.claim_expires_at = deadline else: - getattr(t, cap).expires_at = deadline + {"scope": t.scope, "attempt": t.attempt}[cap].expires_at = deadline original_intent = t.args["intent"].model_dump(mode="json") physical = t.repo.reserve_scope(**t.args) assert physical.scope_expires_at == deadline diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_qualification.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_qualification.py index 49e37a12964..06a38c7f025 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_qualification.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_qualification.py @@ -1,9 +1,20 @@ -from types import SimpleNamespace +from collections.abc import Callable from typing import cast import pytest from runtime_contracts.builder.execution import BuilderExecutionPolicyError +from services.dify_builder.native_testing.sandbox.qualification import NativeCodeCapability + + +class CapabilityOwner: + """Declared qualification boundary whose capability checks are observed by tests.""" + + def __init__(self, validate: Callable[[NativeCodeCapability], object]): + self._validate = validate + + def validate(self, *, capability: NativeCodeCapability): + return self._validate(capability) def capability(): @@ -34,7 +45,7 @@ def qualification(): ) seen = [] - owner = SimpleNamespace(validate=lambda **kw: seen.append(kw["capability"])) + owner = CapabilityOwner(seen.append) return NativeCodeQualification(capability=capability(), owner=owner), seen @@ -47,7 +58,7 @@ def test_capability_dto_and_artifact_hashes_do_not_grant_authority(): with pytest.raises(BuilderExecutionPolicyError): NativeCodeQualification(capability=capability(), owner=cast(NativeCodeQualificationOwner, None)) with pytest.raises(BuilderExecutionPolicyError): - NativeCodeQualification(capability=capability(), owner=SimpleNamespace(validate=lambda **_kw: True)) + NativeCodeQualification(capability=capability(), owner=CapabilityOwner(lambda _capability: True)) def test_qualification_rechecks_exact_complete_snapshot_for_each_use(): @@ -81,7 +92,7 @@ def test_invalid_qualified_identity_refuses_before_owner(field, value): with pytest.raises(BuilderExecutionPolicyError): NativeCodeQualification( capability=cap, - owner=SimpleNamespace(validate=lambda **_kw: pytest.fail("invalid capability reached owner")), + owner=CapabilityOwner(lambda _capability: pytest.fail("invalid capability reached owner")), ) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_commands.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_commands.py index f76be7ce4b7..8c8130ef54c 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_commands.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_commands.py @@ -1,16 +1,36 @@ """Command composition at the existing ordinary resume task boundary.""" import importlib -from datetime import UTC -from types import SimpleNamespace +from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest +from dify_agent.client import Client +from sqlalchemy.orm import sessionmaker +from clients.agent_backend import DifyAgentBackendRunClient from core.dify_builder.models import Actor +from models.dify_builder_native_tests import NativeTestScope +from models.workflow import WorkflowPause from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.agent_context import BuilderAgentExecutionContext -from runtime_contracts.builder.native.contracts import NativeTestContextRef +from runtime_contracts.builder.native.agent_contracts import ( + AgentNodeAssociation, + AgentParentNodeRef, + AgentProtectedState, + AgentTestWorkspaceRef, + AgentWaitAssociation, +) +from runtime_contracts.builder.native.contracts import NativeTestContextRef, ResumeReceipt +from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext +from services.dify_builder.native_testing.api_service import NativeTestApiService +from services.dify_builder.native_testing.confirmed_agent_continuation import ( + ConfirmedAgentContinuation, + OwnedParentPause, +) +from services.dify_builder.native_testing.repository import OwnedNativeTest +from tests.unit_tests.core.app.entities.test_app_invoke_entities import _create_workflow_generate_entity +from tests.unit_tests.services.dify_builder.native_testing.test_runtime_contracts import CONTEXT REF = NativeTestContextRef(request_id="request", invocation_id="invocation", context_digest="a" * 64) RUN = str(uuid4()) @@ -33,14 +53,142 @@ def envelope(): } -def entity(): - return SimpleNamespace(builder_agent_execution=BuilderAgentExecutionContext(ref=REF, native_run_id=RUN)) +def entity(*, marked=True): + result = _create_workflow_generate_entity() + result.builder_agent_execution = BuilderAgentExecutionContext(ref=REF, native_run_id=RUN) if marked else None + return result + + +def _api(factory=None, *, actor=ACTOR): + return NativeTestApiService( + session_factory=factory or sessionmaker(), + actor=actor, + resolver=None, + admission=None, + resolution=None, + runtime=None, + disclosure=None, + ) + + +def _context(*, generation=1, scope=None): + return ResolvedNativeTestContext.model_validate( + { + **CONTEXT, + "ref": REF, + "tenant_id": scope.tenant_id if scope else ACTOR.tenant_id, + "app_id": scope.app_id if scope else "app", + "workflow_id": scope.workflow_id if scope else "workflow", + "native_run_id": RUN, + "root_native_run_id": RUN, + "generation": generation, + } + ) + + +def _owned(): + return OwnedNativeTest( + ref=REF, + tenant_id=ACTOR.tenant_id, + actor_id=ACTOR.account_id, + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=1, + task_approved=True, + expires_at=datetime.now(UTC) + timedelta(hours=1), + state="running", + ) + + +def _scope(): + return NativeTestScope( + request_id=REF.request_id, + tenant_id=ACTOR.tenant_id, + generation=1, + task_approved=True, + cancelled=False, + expires_at=datetime.now(UTC) + timedelta(hours=1), + budget_limits={}, + budget_spent={}, + profile={}, + max_calls=1, + max_dispatches=1, + max_cost_units=1, + ) + + +def _continuation(api, backend=None): + return ConfirmedAgentContinuation( + loader=api.loader, + store=api.store, + operations=None, + backend=backend, + lifecycle=api.lifecycle, + ) + + +def _protected(): + parent = AgentParentNodeRef( + ref=REF, + execution_id="00000000-0000-4000-8000-000000000001", + input_result_ref="input", + input_digest="b" * 64, + ) + workspace = AgentTestWorkspaceRef( + binding_id="binding", + workspace_id="workspace", + backend_binding_ref="backend-binding", + backend_workspace_ref="backend-workspace", + pinned_snapshot_id="snapshot", + home_snapshot_id=None, + workspace_generation=0, + ) + return AgentProtectedState( + association_object_ref="association", + association=AgentNodeAssociation( + parent_node=parent, + workflow_native_run_id=RUN, + agent_run_id="child", + config_ref="config", + config_digest="c" * 64, + pinned_snapshot_id="snapshot", + home_snapshot_id=None, + workspace=workspace, + ), + wait=AgentWaitAssociation( + parent_node=parent, + association_object_ref="association", + agent_generation=0, + checkpoint_object_ref="checkpoint", + checkpoint_digest="d" * 64, + backend_wait_event_id="100-0", + ), + checkpoint={}, + terminal_result_ref=None, + terminal_result=None, + ) + + +def _pause(runtime=None): + from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper + from graphon.runtime import RuntimeState, VariablePool + + return OwnedParentPause( + pause_id="pause", + state_object_key="pause-object", + native_run_id=RUN, + state=WorkflowResumptionContext( + serialized_graph_runtime_state="{}", generate_entity=_WorkflowGenerateEntityWrapper(entity=entity()) + ), + runtime=runtime or RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0), + ) def test_ordinary_legacy_resume_keeps_its_existing_path(): - assert module().guard_confirmed_agent_resume( - payload={"workflow_run_id": RUN}, generate_entity=SimpleNamespace(builder_agent_execution=None) - ) + assert module().guard_confirmed_agent_resume(payload={"workflow_run_id": RUN}, generate_entity=entity(marked=False)) def test_actual_resume_task_refuses_malformed_marked_envelope_before_unscoped_storage(): @@ -90,13 +238,11 @@ def test_guard_uses_claim_owner_and_drops_redelivery_without_second_resume(): assert len(calls) == 2 -def test_resume_publishes_only_after_committed_lifecycle_resume(): +def test_resume_publishes_only_after_committed_lifecycle_resume(mocker): events = [] - receipt = SimpleNamespace(generation=1, dispatch_id="dispatch") - api = SimpleNamespace( - actor=ACTOR, - lifecycle=SimpleNamespace(resume=lambda **_: events.append("committed") or receipt), - ) + receipt = ResumeReceipt(request_id=REF.request_id, generation=1, dispatch_id="dispatch", status="queued") + api = _api() + mocker.patch.object(api.lifecycle, "resume", side_effect=lambda **_: events.append("committed") or receipt) commands = module().ConfirmedAgentCommands(api=api, continuation=None, backend=None, publish=None) commands._request = lambda **_: None commands._root_ref = lambda **_: REF @@ -111,12 +257,12 @@ def test_resume_publishes_only_after_committed_lifecycle_resume(): assert events == ["committed", "published"] -def test_actual_sql_outbox_survives_lost_enqueue_ack_and_reuses_dispatch(sqlite_session_factory): +def test_actual_sql_outbox_survives_lost_enqueue_ack_and_reuses_dispatch(mocker, sqlite_session_factory): from contextlib import contextmanager from models.dify_builder_native_tests import NativeDispatch - owned = SimpleNamespace(tenant_id="tenant") + owned = _owned() @contextmanager def locked(**_values): @@ -138,12 +284,9 @@ def test_actual_sql_outbox_survives_lost_enqueue_ack_and_reuses_dispatch(sqlite_ claim_expires_at=None, ) ) - loader = SimpleNamespace(session_factory=sqlite_session_factory, locked=locked) - api = SimpleNamespace( - actor=ACTOR, - loader=loader, - store=SimpleNamespace(load_context=lambda **_: SimpleNamespace(native_run_id=RUN, tenant_id="tenant")), - ) + api = _api(sqlite_session_factory) + mocker.patch.object(api.loader, "locked", side_effect=locked) + mocker.patch.object(api.store, "load_context", return_value=_context()) envelopes = [] def publish(**values): @@ -214,13 +357,8 @@ def test_continuation_readback_recovers_same_generation_without_second_continue( backend = DifyAgentBackendRunClient( Client(base_url="http://test.invalid", sync_http_client=httpx.Client(transport=httpx.MockTransport(transport))) ) - commands = module().ConfirmedAgentCommands( - api=SimpleNamespace(actor=ACTOR), continuation=None, backend=backend, publish=None - ) - protected = SimpleNamespace( - association=SimpleNamespace(agent_run_id="child"), - wait=SimpleNamespace(agent_generation=0, backend_wait_event_id="100-0"), - ) + commands = module().ConfirmedAgentCommands(api=_api(), continuation=None, backend=backend, publish=None) + protected = _protected() if observed is None: with pytest.raises(BuilderExecutionPolicyError, match="native_agent_continuation_unknown"): commands._continuation_observed(protected=protected) @@ -229,7 +367,7 @@ def test_continuation_readback_recovers_same_generation_without_second_continue( assert calls == ["/runs/child", "/runs/child/events"] -def test_readback_accepts_actual_backend_retained_cursor_on_empty_event_page(): +def test_readback_accepts_actual_backend_retained_cursor_on_empty_event_page(mocker): from dify_agent.protocol.schemas import RunEventsResponse, RunResumedEvent, RunResumedEventData, RunStatusResponse cursors = [] @@ -247,25 +385,25 @@ def test_readback_accepts_actual_backend_retained_cursor_on_empty_event_page(): status = RunStatusResponse( run_id="child", status="running", created_at="2026-10-17T00:00:00Z", updated_at="2026-10-17T00:00:01Z" ) + raw = Client(base_url="http://127.0.0.1:1") + mocker.patch.object(raw, "get_run_sync", return_value=status) + mocker.patch.object(raw, "get_events_sync", side_effect=events) commands = module().ConfirmedAgentCommands( - api=SimpleNamespace(actor=ACTOR), + api=_api(), continuation=None, publish=None, - backend=SimpleNamespace(client=SimpleNamespace(get_run_sync=lambda _: status, get_events_sync=events)), - ) - protected = SimpleNamespace( - association=SimpleNamespace(agent_run_id="child"), - wait=SimpleNamespace(agent_generation=0, backend_wait_event_id="100-0"), + backend=DifyAgentBackendRunClient(raw), ) + protected = _protected() assert commands._continuation_observed(protected=protected) assert cursors == ["100-0", "101-0"] terminal_status = status.model_copy(update={"status": "succeeded"}) - commands.backend.client.get_run_sync = lambda _: terminal_status + raw.get_run_sync.return_value = terminal_status with pytest.raises(BuilderExecutionPolicyError, match="native_agent_continuation_unknown"): commands._continuation_observed(protected=protected, require_terminal=True) -def test_completed_child_recovery_requeues_original_claim_only_after_terminal_proof(sqlite_session_factory): +def test_completed_child_recovery_requeues_original_claim_only_after_terminal_proof(mocker, sqlite_session_factory): """Actual SQL source identity; lifecycle's independently tested fencing port is explicit.""" from contextlib import contextmanager @@ -273,7 +411,7 @@ def test_completed_child_recovery_requeues_original_claim_only_after_terminal_pr from models.dify_builder_native_tests import NativeDispatch, NativeEffectOperation, NativeInvocationState - owned = SimpleNamespace(tenant_id="tenant") + owned = _owned() with sqlite_session_factory.begin() as sql: for table in (NativeDispatch.__table__, NativeEffectOperation.__table__, NativeInvocationState.__table__): table.create(sql.connection(), checkfirst=True) @@ -327,7 +465,7 @@ def test_completed_child_recovery_requeues_original_claim_only_after_terminal_pr @contextmanager def locked(**_values): with sqlite_session_factory.begin() as sql: - yield sql, owned, SimpleNamespace(cancelled=False) + yield sql, owned, _scope() events = [] @@ -339,20 +477,20 @@ def test_completed_child_recovery_requeues_original_claim_only_after_terminal_pr dispatch.state = "queued" events.append("lifecycle-fence-recover") - api = SimpleNamespace( - actor=ACTOR, - loader=SimpleNamespace(session_factory=sqlite_session_factory, locked=locked), - lifecycle=SimpleNamespace(recover_dispatch=recover_dispatch), - store=SimpleNamespace(load_context=lambda **_: SimpleNamespace(generation=1, tenant_id="tenant")), - ) - continuation = SimpleNamespace( - _parent_rows=lambda *_, **__: (None, SimpleNamespace(id="pause", state_object_key="pause-object")) - ) + api = _api(sqlite_session_factory) + mocker.patch.object(api.loader, "locked", side_effect=locked) + mocker.patch.object(api.lifecycle, "recover_dispatch", side_effect=recover_dispatch) + mocker.patch.object(api.store, "load_context", return_value=_context()) + continuation = _continuation(api) + pause = WorkflowPause(workflow_id="workflow", workflow_run_id=RUN, state_object_key="pause-object") + pause.id = "pause" + mocker.patch.object(continuation, "_parent_rows", return_value=(None, pause)) commands = module().ConfirmedAgentCommands(api=api, continuation=continuation, backend=None, publish=None) - commands.load_wait = lambda **_: SimpleNamespace() - commands._terminal_recovery_proof = lambda **_: SimpleNamespace(pause_id="pause", state_object_key="pause-object") + protected = _protected() + mocker.patch.object(commands, "load_wait", return_value=protected) + mocker.patch.object(commands, "_terminal_recovery_proof", return_value=_pause()) commands._publish_one = lambda **values: events.append(("publish", values)) - assert commands._recover_terminal_continuation(ref=REF, parent_node=SimpleNamespace()) + assert commands._recover_terminal_continuation(ref=REF, parent_node=protected.association.parent_node) assert events == [ "lifecycle-fence-recover", ("publish", {"ref": REF, "generation": 1, "dispatch_id": "original-dispatch"}), @@ -361,7 +499,7 @@ def test_completed_child_recovery_requeues_original_claim_only_after_terminal_pr assert len(sql.scalars(select(NativeDispatch)).all()) == 1 -def test_stored_profile_router_uses_actual_scoped_sql_request(sqlite_session_factory): +def test_stored_profile_router_uses_actual_scoped_sql_request(mocker, sqlite_session_factory): from models.dify_builder import DifyBuilderExecutionRequest ids = {name: str(uuid4()) for name in ("tenant", "actor", "session", "app", "workflow", "input")} @@ -386,11 +524,15 @@ def test_stored_profile_router_uses_actual_scoped_sql_request(sqlite_session_fac observations=[], ) ) + api = _api(sqlite_session_factory, actor=actor) + confirmed = module().ConfirmedAgentCommands(api=api, continuation=None, backend=None, publish=None) + mocker.patch.object(confirmed, "get", return_value="confirmed") + mocker.patch.object(api, "get", return_value="legacy") service = module().ProfiledNativeTestCommands( sessions=sqlite_session_factory, actor=actor, - confirmed_factory=lambda: SimpleNamespace(get=lambda **_: "confirmed"), - legacy_factory=lambda: SimpleNamespace(get=lambda **_: "legacy"), + confirmed_factory=lambda: confirmed, + legacy_factory=lambda: api, ) assert ( service.get(actor=actor, session_id=ids["session"], request_id=request_ids["builder-agent-confirmed-v1"]) @@ -427,7 +569,7 @@ def test_terminal_projection_does_not_erase_unknown_effects(): def test_lost_create_ack_status_recovery_uses_saved_sql_identity_and_no_second_post( - sqlite_session_factory, monkeypatch + mocker, sqlite_session_factory, monkeypatch ): """Real SQL intent, SDK HTTP and Graphon state; C03 payload ports are explicit doubles.""" import json @@ -516,28 +658,23 @@ def test_lost_create_ack_status_recovery_uses_saved_sql_identity_and_no_second_p client=backend, on_persisted=lambda _: None, ) - context = SimpleNamespace( - ref=REF, - tenant_id=scope.tenant_id, - app_id=scope.app_id, - workflow_id=scope.workflow_id, - native_run_id=RUN, - generation=0, - ) + context = _context(generation=0, scope=scope) parent = AgentParentNodeRef( ref=REF, execution_id=scope.execution_id, input_result_ref="inputs", input_digest="b" * 64 ) - store = SimpleNamespace( - load_context=lambda **_: context, - load_agent_association=lambda **_: SimpleNamespace(association=SimpleNamespace(agent_run_id="original-child")), + api = _api(sqlite_session_factory) + mocker.patch.object(api.store, "load_context", return_value=context) + protected = _protected().model_copy( + update={"association": _protected().association.model_copy(update={"agent_run_id": "original-child"})} ) + mocker.patch.object(api.store, "load_agent_association", return_value=protected) monkeypatch.setattr(agent_confirmed_workspace, "load_confirmed_agent_parent", lambda **_: parent) events = [] - continuation = SimpleNamespace( - read_parent_pause=lambda **_: SimpleNamespace(runtime=runtime), - persist_parent_wait=lambda **values: events.append(("parent-commit", values)), + continuation = _continuation(api, backend) + mocker.patch.object(continuation, "read_parent_pause", return_value=_pause(runtime)) + mocker.patch.object( + continuation, "persist_parent_wait", side_effect=lambda **values: events.append(("parent-commit", values)) ) - api = SimpleNamespace(actor=ACTOR, loader=SimpleNamespace(session_factory=sqlite_session_factory), store=store) commands = module().ConfirmedAgentCommands(api=api, continuation=continuation, backend=backend, publish=None) commands.prepare_wait = lambda **values: events.append(("selected-checkpoint", values)) assert commands._recover_pending_ack(ref=REF) @@ -562,7 +699,7 @@ def test_lost_create_ack_status_recovery_uses_saved_sql_identity_and_no_second_p ], ) def test_public_pause_read_refreshes_callback_and_preserves_only_live_owned_resume( - sqlite_session_factory, recovery_raises, after + mocker, sqlite_session_factory, recovery_raises, after ): from datetime import datetime, timedelta @@ -627,18 +764,13 @@ def test_public_pause_read_refreshes_callback_and_preserves_only_live_owned_resu reads.append("read") return initial if len(reads) == 1 else fresh - owned = SimpleNamespace(tenant_id=ACTOR.tenant_id) - api = SimpleNamespace( - actor=ACTOR, - get=get, - loader=SimpleNamespace(session_factory=sqlite_session_factory, _load=lambda *_args, **_kwargs: (owned, None)), - ) - commands = module().ConfirmedAgentCommands( - api=api, - continuation=SimpleNamespace(read_parent_pause=lambda **kwargs: pause_checks.append(kwargs)), - backend=None, - publish=None, - ) + owned = _owned() + api = _api(sqlite_session_factory) + mocker.patch.object(api, "get", side_effect=get) + mocker.patch.object(api.loader, "_load", return_value=(owned, None)) + continuation = _continuation(api) + mocker.patch.object(continuation, "read_parent_pause", side_effect=lambda **kwargs: pause_checks.append(kwargs)) + commands = module().ConfirmedAgentCommands(api=api, continuation=continuation, backend=None, publish=None) commands._request = lambda **_kwargs: None commands._root_ref = lambda **_kwargs: REF diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py index f93afe0a3ad..e9a8cbf0110 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py @@ -1,14 +1,18 @@ """Terminal C03 evidence closes the original Builder attempt, without another run.""" import json -from datetime import datetime -from types import SimpleNamespace +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest -from sqlalchemy import select +from dify_agent.client import Client +from sqlalchemy import Engine, select +from sqlalchemy.orm import sessionmaker -from core.dify_builder.models import DifyBuilderContext, EntryMode, Session +from clients.agent_backend import DifyAgentBackendRunClient +from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper +from core.dify_builder.models import DifyBuilderContext, EntryMode, Run, Session from core.dify_builder.state import PcState from models.dify_builder import ( DifyBuilderExecutionRequest, @@ -21,21 +25,158 @@ from models.dify_builder_native_tests import NativeEffectOperation, NativeInvoca from models.enums import CreatorUserRole from models.workflow import WorkflowNodeExecutionModel, WorkflowRun from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest +from runtime_contracts.builder.native.agent_context import BuilderAgentExecutionContext +from runtime_contracts.builder.native.agent_contracts import ( + AgentNodeAssociation, + AgentParentNodeRef, + AgentProtectedState, + AgentTestWorkspaceRef, + AgentWaitAssociation, +) from runtime_contracts.builder.native.contracts import NativeTestContextRef +from runtime_contracts.builder.native.runtime_contracts import ( + NativeCheckpointPayload, + NodeJournalState, + ResolvedNativeTestContext, +) from services.dify_builder import dify_port +from services.dify_builder.native_testing.api_service import NativeTestApiService from services.dify_builder.native_testing.confirmed_agent_commands import ( ConfirmedAgentCommands, confirmed_parent_outcome, ) +from services.dify_builder.native_testing.confirmed_agent_continuation import ( + ConfirmedAgentContinuation, + OwnedParentPause, +) +from services.dify_builder.native_testing.private_reads import PrivateReadOwner from services.dify_builder.native_testing.public_contracts import NativeTestPublicReceipt -from services.dify_builder.native_testing.repository import native_workflow_revision +from services.dify_builder.native_testing.repository import OwnedNativeTest, native_workflow_revision from services.dify_builder.repository import SqlDifyBuilderRepository from services.dify_builder.revision import executable_graph_revision, execution_revision -from tests.builder_agent_fixtures import make_case +from tests.builder_agent_fixtures import AgentCase, make_case +from tests.unit_tests.core.app.entities.test_app_invoke_entities import _create_workflow_generate_entity + + +@dataclass +class _CompletionDatabase: + engine: Engine + + +@dataclass +class CompletedOwner: + factory: sessionmaker + repo: SqlDifyBuilderRepository + case: AgentCase + session: Session + context: DifyBuilderContext + verify_id: str + saved_id: str + native_id: str + request_id: str + ref: NativeTestContextRef + commands: ConfirmedAgentCommands + reservation: Run + child_status: str = "cancelled" + cancel_calls: list[str] = field(default_factory=list) + paused: OwnedParentPause | None = None + writer_depth: int = 0 + + +def _api(factory, actor): + return NativeTestApiService( + session_factory=factory, + actor=actor, + resolver=None, + admission=None, + resolution=None, + runtime=None, + disclosure=None, + ) + + +def _owned(owner, *, generation=1): + return OwnedNativeTest( + ref=owner.ref, + tenant_id=owner.case.actor.tenant_id, + actor_id=owner.case.actor.account_id, + app_id=owner.case.workflow.app_id, + workflow_id=owner.case.workflow.id, + session_id=owner.session.id, + test_input_id=owner.saved_id, + bindings={}, + generation=generation, + task_approved=True, + expires_at=datetime.now(UTC) + timedelta(hours=1), + state="running", + ) + + +def _context(owner, *, generation=1): + return ResolvedNativeTestContext( + ref=owner.ref, + tenant_id=owner.case.actor.tenant_id, + actor_id=owner.case.actor.account_id, + app_id=owner.case.workflow.app_id, + workflow_id=owner.case.workflow.id, + graph=owner.case.workflow.graph_dict, + inputs={}, + graph_revision="a" * 64, + input_revision="b" * 64, + resource_manifest=(), + generation=generation, + root_native_run_id=owner.native_id, + native_run_id=owner.native_id, + parent_invocation_id=None, + call_depth=0, + budget_ref="budget", + cancellation_ref="cancel", + execution_mode="native", + ) + + +def _protected(owner, execution_id): + parent = AgentParentNodeRef( + ref=owner.ref, execution_id=execution_id, input_result_ref="input", input_digest="b" * 64 + ) + wait = AgentWaitAssociation( + parent_node=parent, + association_object_ref="association", + agent_generation=0, + checkpoint_object_ref="checkpoint", + checkpoint_digest="d" * 64, + backend_wait_event_id="100-0", + ) + association = AgentNodeAssociation( + parent_node=parent, + workflow_native_run_id=owner.native_id, + agent_run_id="original-child", + config_ref="config", + config_digest="c" * 64, + pinned_snapshot_id="snapshot", + home_snapshot_id=None, + workspace=AgentTestWorkspaceRef( + binding_id="binding", + workspace_id="workspace", + backend_binding_ref="backend-binding", + backend_workspace_ref="backend-workspace", + pinned_snapshot_id="snapshot", + home_snapshot_id=None, + workspace_generation=0, + ), + ) + return AgentProtectedState( + association_object_ref="association", + association=association, + wait=wait, + checkpoint={}, + terminal_result_ref=None, + terminal_result=None, + ) @pytest.fixture -def completed(sqlite_session_factory, monkeypatch, request): +def completed(mocker, sqlite_session_factory, monkeypatch, request): factory = sqlite_session_factory with factory.begin() as sql: case = make_case(sql) @@ -75,10 +216,10 @@ def completed(sqlite_session_factory, monkeypatch, request): ) ) monkeypatch.setattr(dify_port, "_session_factory", lambda: factory) - monkeypatch.setattr(dify_port, "db", SimpleNamespace(engine=factory.kw["bind"])) + monkeypatch.setattr(dify_port, "db", _CompletionDatabase(engine=factory.kw["bind"])) from services import workflow_service - monkeypatch.setattr(workflow_service, "db", SimpleNamespace(engine=factory.kw["bind"])) + monkeypatch.setattr(workflow_service, "db", _CompletionDatabase(engine=factory.kw["bind"])) from services.dify_builder import session_lock monkeypatch.setattr(session_lock, "acquire", lambda _: "completion-token") @@ -108,7 +249,11 @@ def completed(sqlite_session_factory, monkeypatch, request): "session_id": session.id, "test_input_id": saved_id, } - if getattr(request, "param", "legacy") == "confirmed": + try: + profile = request.param + except AttributeError: + profile = "legacy" + if profile == "confirmed": from services.dify_builder.native_testing.repository import admitted_workflow_revision request_context["revision_basis"] = "confirmed-canvas-v1" @@ -238,15 +383,17 @@ def completed(sqlite_session_factory, monkeypatch, request): outcome="succeeded", evidence_refs=(), ) + api = _api(factory, case.actor) + mocker.patch.object(api, "get", return_value=receipt) commands = ConfirmedAgentCommands( - api=SimpleNamespace(actor=case.actor, loader=SimpleNamespace(session_factory=factory), get=lambda **_: receipt), + api=api, continuation=None, backend=None, publish=None, ) commands._request = lambda **_: None commands._root_ref = lambda **_: ref - return SimpleNamespace( + return CompletedOwner( factory=factory, repo=repo, case=case, @@ -365,7 +512,7 @@ def test_completion_uses_the_immutable_admitted_revision_basis(completed, change assert (owner.repo.get_run(owner.verify_id).status == "succeeded") is expected -def test_resume_finalizes_actual_c03_before_closing_original_builder_run(completed): +def test_resume_finalizes_actual_c03_before_closing_original_builder_run(mocker, completed): from contextlib import contextmanager from models.dify_builder_native_tests import NativeDispatch, NativeTestInvocation @@ -405,22 +552,14 @@ def test_resume_finalizes_actual_c03_before_closing_original_builder_run(complet with owner.factory.begin() as sql: yield ( sql, - SimpleNamespace(ref=owner.ref, tenant_id=owner.case.actor.tenant_id, state="running"), + _owned(owner), sql.get(NativeTestScope, owner.request_id), ) - loader = SimpleNamespace(session_factory=owner.factory, locked=locked) - owner.commands.api.loader = loader - owner.commands.api.store = SimpleNamespace( - load_context=lambda **_: SimpleNamespace( - native_run_id=owner.native_id, - tenant_id=owner.case.actor.tenant_id, - app_id=owner.case.workflow.app_id, - workflow_id=owner.case.workflow.id, - actor_id=owner.case.actor.account_id, - ) - ) - owner.commands.api.lifecycle = NativeTestLifecycleService(loader, state_store=None) + loader = owner.commands.api.loader + mocker.patch.object(loader, "locked", side_effect=locked) + mocker.patch.object(owner.commands.api.store, "load_context", return_value=_context(owner)) + owner.commands.api.lifecycle = NativeTestLifecycleService(loader, state_store=owner.commands.api.store) owner.commands.finish_resume(ref=owner.ref, generation=1, dispatch_id="original-dispatch") assert owner.repo.get_run(owner.verify_id).status == "succeeded" assert owner.repo.get_session(owner.session.id)[0].current_state == PcState.BUILD_REVIEW @@ -549,7 +688,7 @@ def test_declined_terminal_proof_closes_same_builder_attempt_and_returns_editabl @pytest.fixture -def declining(completed, monkeypatch, request): +def declining(mocker, completed, monkeypatch, request): """Real original SQL owners; private checkpoint reader and backend are bounded ports.""" from models.dify_builder_native_tests import ( NativeContinuation, @@ -562,16 +701,22 @@ def declining(completed, monkeypatch, request): from tests.unit_tests.services.dify_builder.native_testing.test_confirmed_agent_continuation import saved_state owner = completed - generation = getattr(request, "param", 1) + try: + generation = request.param + except AttributeError: + generation = 1 saved, execution_id = saved_state(ref=owner.ref, run_id=owner.native_id) - pause_id = str(uuid4()) - owned = SimpleNamespace( - ref=owner.ref, - tenant_id=owner.case.actor.tenant_id, - actor_id=owner.case.actor.account_id, - app_id=owner.case.workflow.app_id, - workflow_id=owner.case.workflow.id, + entity = _create_workflow_generate_entity().model_copy( + update={ + "builder_agent_execution": BuilderAgentExecutionContext(ref=owner.ref, native_run_id=owner.native_id), + } ) + saved = WorkflowResumptionContext( + serialized_graph_runtime_state=saved.serialized_graph_runtime_state, + generate_entity=_WorkflowGenerateEntityWrapper(entity=entity), + ) + pause_id = str(uuid4()) + owned = _owned(owner, generation=generation) with owner.factory.begin() as sql: for model in ( WorkflowPause, @@ -650,32 +795,49 @@ def declining(completed, monkeypatch, request): value_digest="e" * 64, ) ) - parent = SimpleNamespace(ref=owner.ref, execution_id=execution_id) - wait = SimpleNamespace(parent_node=parent) - protected = SimpleNamespace(association=SimpleNamespace(agent_run_id="original-child")) - paused = SimpleNamespace( + protected = _protected(owner, execution_id) + parent, wait = protected.association.parent_node, protected.wait + from graphon.runtime import RuntimeState + + paused = OwnedParentPause( pause_id=pause_id, state_object_key="original-pause", native_run_id=owner.native_id, state=saved, - runtime=__import__("graphon.runtime", fromlist=["RuntimeState"]).RuntimeState.from_snapshot( - saved.serialized_graph_runtime_state - ), + runtime=RuntimeState.from_snapshot(saved.serialized_graph_runtime_state), ) - - from services.dify_builder.native_testing.repository import OwnedNativeTestLoader - - owner.commands.api.loader = OwnedNativeTestLoader(owner.factory, actor=owner.case.actor, resolver=object()) - owner.commands.continuation = SimpleNamespace( - read_parent_pause=lambda **_: paused, _stored_waits=lambda **_: (wait,) + api = owner.commands.api + continuation = ConfirmedAgentContinuation( + loader=api.loader, store=api.store, operations=None, backend=None, lifecycle=api.lifecycle ) - owner.commands.api.store = SimpleNamespace( - load_agent_association=lambda **_: protected, - read_checkpoint=lambda **_: SimpleNamespace( + mocker.patch.object(continuation, "read_parent_pause", return_value=paused) + mocker.patch.object(continuation, "_stored_waits", return_value=(wait,)) + owner.commands.continuation = continuation + mocker.patch.object(api.store, "load_agent_association", return_value=protected) + mocker.patch.object( + api.store, + "read_checkpoint", + return_value=NativeCheckpointPayload( + schema_version="1", native_run_id=owner.native_id, generation=generation, - agent_associations=(wait,), + generate_entity_json="{}", graph_runtime_state_json=saved.serialized_graph_runtime_state, + response_stream_filter_json="{}", + node_journals=( + NodeJournalState( + invocation_id=owner.ref.invocation_id, + occurrence_path=(execution_id,), + input_result_ref=parent.input_result_ref, + input_digest=parent.input_digest, + cursor=0, + operation_keys=(), + result_refs=(), + ), + ), + child_refs=(), + input_result_refs=(parent.input_result_ref,), + agent_associations=(wait,), ), ) @@ -687,7 +849,7 @@ def declining(completed, monkeypatch, request): sql.scalar(select(NativeTestInvocation)).active = False sql.scalar(select(NativeEffectOperation)).state = "not_dispatched" - owner.commands.api.lifecycle = SimpleNamespace(cancel=revoke) + mocker.patch.object(owner.commands.api.lifecycle, "cancel", side_effect=revoke) owner.child_status = "cancelled" owner.cancel_calls = [] @@ -704,7 +866,11 @@ def declining(completed, monkeypatch, request): run_id=child, status=owner.child_status, created_at=datetime(2026, 1, 1), updated_at=datetime(2026, 1, 1) ) - owner.commands.backend = SimpleNamespace(cancel_run=cancel_child, client=SimpleNamespace(get_run_sync=child_status)) + raw = Client(base_url="http://127.0.0.1:1") + mocker.patch.object(raw, "get_run_sync", side_effect=child_status) + backend = DifyAgentBackendRunClient(raw) + mocker.patch.object(backend, "cancel_run", side_effect=cancel_child) + owner.commands.backend = backend def public(**_): with owner.factory() as sql: @@ -728,16 +894,27 @@ def declining(completed, monkeypatch, request): owner.commands.api.get = public monkeypatch.setattr( "services.dify_builder.native_testing.confirmed_agent_commands.validate_private_read", - lambda *_: SimpleNamespace(root_revision_matches=True), - ) - monkeypatch.setattr( - "services.dify_builder.native_testing.confirmed_agent_cancellation.storage", - SimpleNamespace(load=lambda _: b"snapshot"), - ) - monkeypatch.setattr( - "services.dify_builder.native_testing.confirmed_agent_cancellation.WorkflowResumptionContext", - SimpleNamespace(loads=lambda _: saved), + lambda *_: PrivateReadOwner( + tenant_id=owned.tenant_id, + actor_id=owned.actor_id, + app_id=owned.app_id, + workflow_id=owned.workflow_id, + parent_invocation_id=None, + context_object_id="context", + generation=generation, + graph_revision="a" * 64, + root_revision_matches=True, + input_revision=None, + expires_at=owned.expires_at, + cancelled=False, + active=True, + request_state="running", + ), ) + from services.dify_builder.native_testing import confirmed_agent_cancellation + + mocker.patch.object(confirmed_agent_cancellation.storage, "load", return_value=b"snapshot") + mocker.patch.object(confirmed_agent_cancellation.WorkflowResumptionContext, "loads", return_value=saved) owner.paused = paused return owner @@ -1130,7 +1307,7 @@ def test_clean_physical_receipt_cannot_finish_confirmed_paused_parent_without_lo @pytest.fixture -def retrying_decline(declining, monkeypatch): +def retrying_decline(mocker, declining, monkeypatch): """Retain real active-reader refusal and integrity-checked SQL object reads.""" from contextlib import contextmanager @@ -1256,7 +1433,14 @@ def retrying_decline(declining, monkeypatch): def active_association(**_): loader.load(ref=owner.ref) - return SimpleNamespace(association=association) + return AgentProtectedState( + association_object_ref="association", + association=association, + wait=wait, + checkpoint={}, + terminal_result_ref=None, + terminal_result=None, + ) def active_pause(**_): loader.load(ref=owner.ref) @@ -1268,11 +1452,13 @@ def retrying_decline(declining, monkeypatch): owner.commands.api.store.read_checkpoint = lambda **_: payload owner.commands.api.store._read_object = state_store.NativeExecutionStateStore._read_object + objects[owner.paused.state_object_key] = owner.paused.state.dumps().encode() + def load_object(key): assert owner.writer_depth == 0 return objects[key] - monkeypatch.setattr(state_store, "storage", SimpleNamespace(load=load_object)) + mocker.patch.object(state_store.storage, "load", side_effect=load_object) monkeypatch.setattr(state_store.encrypter, "decrypt_token", lambda _tenant, raw: raw) owner.child_status = "waiting" diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_continuation.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_continuation.py index 8edad06dc7f..97db90820e6 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_continuation.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_continuation.py @@ -1,16 +1,21 @@ """Ordinary Graphon checkpoint validation; no model, provider or business I/O.""" import importlib -from types import SimpleNamespace +from dataclasses import dataclass +from datetime import UTC, datetime from uuid import uuid4 import pytest +from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity +from core.dify_builder.models import Actor from graphon.entities.pause_reason import SchedulingPause from graphon.runtime import RuntimeState, VariablePool from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.agent_context import BuilderAgentExecutionContext from runtime_contracts.builder.native.contracts import NativeTestContextRef +from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader +from tests.unit_tests.services.dify_builder.native_testing.test_observation_stream import entity as workflow_entity def owner(): @@ -21,15 +26,25 @@ REF = NativeTestContextRef(request_id="request", invocation_id="invocation", con RUN = str(uuid4()) +@dataclass +class PausedAgentSnapshot: + generate_entity: WorkflowAppGenerateEntity + serialized_graph_runtime_state: str + + def get_generate_entity(self) -> WorkflowAppGenerateEntity: + return self.generate_entity + + def saved_state(*, paused=True, ref=REF, run_id=RUN): runtime = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) runtime.graph_execution.start() execution = runtime.graph_execution.get_or_create_node_execution(frame_id="root", node_id="agent") if paused: runtime.graph_execution.pause(SchedulingPause(message="Agent approval")) - entity = SimpleNamespace(builder_agent_execution=BuilderAgentExecutionContext(ref=ref, native_run_id=run_id)) - state = SimpleNamespace( - get_generate_entity=lambda: entity, + entity = workflow_entity() + entity.builder_agent_execution = BuilderAgentExecutionContext(ref=ref, native_run_id=run_id) + state = PausedAgentSnapshot( + generate_entity=entity, serialized_graph_runtime_state=runtime.dumps(), ) return state, execution.execution_id @@ -96,17 +111,26 @@ def test_pending_intent_requires_actual_saved_operation_and_parent_occurrence(): @pytest.mark.parametrize("drift", [None, "actor", "tenant", "app", "workflow", "resumed", "missing_pause", "running"]) -def test_actual_sql_parent_pause_requires_complete_owned_run_chain(sqlite_session_factory, drift): +def test_actual_sql_parent_pause_requires_complete_owned_run_chain(sqlite_session_factory, drift, monkeypatch): """Real SQL predicates; C02 loader is an explicit already-authorized port.""" - from datetime import datetime - from models.dify_builder import DifyBuilderExecutionRequest from models.workflow import WorkflowPause, WorkflowRun ids = {name: str(uuid4()) for name in ("request", "tenant", "app", "workflow", "actor", "session", "input", "run")} ref = REF.model_copy(update={"request_id": ids["request"]}) - owned = SimpleNamespace( - tenant_id=ids["tenant"], actor_id=ids["actor"], app_id=ids["app"], workflow_id=ids["workflow"] + owned = OwnedNativeTest( + ref=ref, + tenant_id=ids["tenant"], + actor_id=ids["actor"], + app_id=ids["app"], + workflow_id=ids["workflow"], + session_id=ids["session"], + test_input_id=ids["input"], + bindings={}, + generation=0, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", ) with sqlite_session_factory.begin() as sql: for table in (DifyBuilderExecutionRequest.__table__, WorkflowRun.__table__, WorkflowPause.__table__): @@ -153,8 +177,12 @@ def test_actual_sql_parent_pause_requires_complete_owned_run_chain(sqlite_sessio resumed_at=datetime.now() if drift == "resumed" else None, ) ) + loader = OwnedNativeTestLoader( + sqlite_session_factory, actor=Actor(tenant_id=owned.tenant_id, account_id=owned.actor_id), resolver=object() + ) + monkeypatch.setattr(loader, "_load", lambda *_, **__: (owned, None)) continuation = owner().ConfirmedAgentContinuation( - loader=SimpleNamespace(_load=lambda *_, **__: (owned, None)), + loader=loader, store=None, operations=None, backend=None, diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_context_binding.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_context_binding.py index 11d89a9bc26..95d53f1cd74 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_context_binding.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_context_binding.py @@ -1,73 +1,94 @@ """Trusted reload must finish before any marked run reaches native setup.""" -from unittest.mock import Mock +from datetime import datetime import pytest +from sqlalchemy.orm import sessionmaker +from core.dify_builder.models import Actor from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import NativeTestContextRef -from runtime_contracts.builder.native.runtime_contracts import NativeStatePort, ResolvedNativeTestContext +from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext +from services.dify_builder.native_testing.repository import OwnedNativeTestLoader +from services.dify_builder.native_testing.state_store import NativeExecutionStateStore from tests.unit_tests.services.dify_builder.native_testing.test_runtime_contracts import CONTEXT, REF_DATA from tests.unit_tests.services.dify_builder.test_execution_policy_service import owned as owned # noqa: PLC0414 from tests.unit_tests.services.dify_builder.test_native_agent_binding import agent_case as agent_case # noqa: PLC0414 +def stored_context_boundary(monkeypatch, *, context=None, failure=None): + """Actual state owner with only its protected-object read method replaced.""" + loader = OwnedNativeTestLoader( + sessionmaker(), actor=Actor(tenant_id="tenant", account_id="actor"), resolver=object() + ) + state = NativeExecutionStateStore(loader) + calls = [] + + def load_context(*, ref): + calls.append(ref) + if failure is not None: + raise failure + assert context is not None, "refused marker reached protected context IO" + return context + + monkeypatch.setattr(state, "load_context", load_context) + return state, calls + + def binding(): from services.dify_builder.native_testing.context_binding import NativeContextUnavailable, resolve_native_context return NativeContextUnavailable, resolve_native_context -def test_required_scope_never_falls_back_to_ordinary(): +def test_required_scope_never_falls_back_to_ordinary(monkeypatch): error, resolve = binding() - state = Mock(spec=NativeStatePort) + state, calls = stored_context_boundary(monkeypatch) with pytest.raises(error, match="^native_context_missing$"): resolve(required=True, ref=None, state=state) - state.load_context.assert_not_called() + assert calls == [] -def test_unmarked_ordinary_run_does_not_load_native_state(): +def test_unmarked_ordinary_run_does_not_load_native_state(monkeypatch): _, resolve = binding() - state = Mock(spec=NativeStatePort) + state, calls = stored_context_boundary(monkeypatch) assert resolve(required=False, ref=None, state=state) is None - state.load_context.assert_not_called() + assert calls == [] -def test_unknown_policy_never_reaches_owner(): +def test_unknown_policy_never_reaches_owner(monkeypatch): error, resolve = binding() - state = Mock(spec=NativeStatePort) + state, calls = stored_context_boundary(monkeypatch) ref = NativeTestContextRef.model_construct(**{**REF_DATA, "policy_version": "unknown"}) with pytest.raises(error, match="^native_context_version$"): resolve(required=True, ref=ref, state=state) - state.load_context.assert_not_called() + assert calls == [] @pytest.mark.parametrize("reason", ["native_owner_mismatch", "native_context_mismatch", "native_access_denied"]) -def test_owner_rejection_is_sanitized(reason): +def test_owner_rejection_is_sanitized(reason, monkeypatch): error, resolve = binding() - state = Mock(spec=NativeStatePort) - state.load_context.side_effect = BuilderExecutionPolicyError(reason) + state, _calls = stored_context_boundary(monkeypatch, failure=BuilderExecutionPolicyError(reason)) with pytest.raises(error, match="^native_context_unavailable$"): resolve(required=True, ref=NativeTestContextRef(**REF_DATA), state=state) -def test_loaded_ref_must_exactly_match_requested_ref(): +def test_loaded_ref_must_exactly_match_requested_ref(monkeypatch): error, resolve = binding() - state = Mock(spec=NativeStatePort) - state.load_context.return_value = ResolvedNativeTestContext(**CONTEXT).model_copy( + changed_context = ResolvedNativeTestContext(**CONTEXT).model_copy( update={"ref": NativeTestContextRef(**{**REF_DATA, "context_digest": "b" * 64})} ) + state, _calls = stored_context_boundary(monkeypatch, context=changed_context) with pytest.raises(error, match="^native_context_mismatch$"): resolve(required=True, ref=NativeTestContextRef(**REF_DATA), state=state) -def test_reload_preserves_typed_inputs_and_isolates_mutable_snapshot(): +def test_reload_preserves_typed_inputs_and_isolates_mutable_snapshot(monkeypatch): _, resolve = binding() context = ResolvedNativeTestContext( **{**CONTEXT, "inputs": {"bool": True, "array": [1, None], "file": {"ref": "f"}}} ) - state = Mock(spec=NativeStatePort) - state.load_context.return_value = context + state, _calls = stored_context_boundary(monkeypatch, context=context) actual = resolve(required=True, ref=context.ref, state=state) assert actual.inputs == context.inputs actual.inputs["array"].append(2) @@ -97,7 +118,10 @@ def compile_owned(owned): from services.dify_builder.native_testing.context_binding import NativeAdmissionCompiler, NativeRuntimeProfile factory, actor, app, workflow, sid, tid = owned - metadata = Mock(return_value=()) + + def metadata(**_kwargs): + return () + compiler = NativeAdmissionCompiler(session_factory=factory, resources=metadata) profile = NativeRuntimeProfile( profile_id="qualification-1", implementation_revision="native-v1", limits={"calls": 10} @@ -264,10 +288,10 @@ def _apply_confirmed_agent_and_verify_binding(agent_case, sqlite_session_factory from services.workflow_service import WorkflowService monkeypatch.setattr(dify_port, "WorkflowService", lambda: WorkflowService(session_maker=sqlite_session_factory)) - monkeypatch.setattr(dify_port, "notify_workflow_draft_changed", Mock()) + monkeypatch.setattr(dify_port, "notify_workflow_draft_changed", lambda *_args, **_kwargs: None) from events.app_event import app_draft_workflow_was_synced - monkeypatch.setattr(app_draft_workflow_was_synced, "send", Mock()) + monkeypatch.setattr(app_draft_workflow_was_synced, "send", lambda *_args, **_kwargs: None) port = dify_port.WorkflowServiceDifyPort() _, revision = port.read_graph(case.workflow.app_id, case.actor) result = port.apply_repair( @@ -311,8 +335,6 @@ def test_real_apply_persists_confirmed_agent_graph_and_binding_together( def test_agent_apply_undo_refusal_preserves_bindings_and_allows_next_apply( agent_case, sqlite_session_factory, monkeypatch ): - from types import SimpleNamespace - from sqlalchemy import select from core.dify_builder.models import Action, DifyBuilderContext, MutationIntent, Turn @@ -327,16 +349,37 @@ def test_agent_apply_undo_refusal_preserves_bindings_and_allows_next_apply( port = dify_port.WorkflowServiceDifyPort() current, revision = port.read_graph(agent_case.workflow.app_id, agent_case.actor) fc = DifyBuilderContext(checkpoint_id="before-agent", last_snapshot_hash=revision) - repo = Mock() - repo.get_checkpoint.return_value = (None, SimpleNamespace(graph=before)) - env = SimpleNamespace(dify=port, repo=repo, emit_canvas=Mock()) - session = SimpleNamespace(app_id=agent_case.workflow.app_id, id="builder-session") + from core.dify_builder.models import Checkpoint, EntryMode, Session, Snapshot + from core.dify_builder.state import PcState + from services.dify_builder.agent.llm_agent import LlmBuilderAgent + from services.dify_builder.application.runner import Env + from services.dify_builder.repository import SqlDifyBuilderRepository + + repo = SqlDifyBuilderRepository(sqlite_session_factory) + monkeypatch.setattr( + repo, "get_checkpoint", lambda _identity: (Checkpoint(state=PcState.BUILD_EXECUTION), Snapshot(graph=before)) + ) + emitted, invalidated = [], [] + monkeypatch.setattr(repo, "invalidate_conversation_items", lambda *args: invalidated.append(args)) + env = Env( + dify=port, + repo=repo, + agent=LlmBuilderAgent(agent_case.actor.tenant_id), + now=datetime.now, + emit_canvas=emitted.append, + ) + session = Session( + app_id=agent_case.workflow.app_id, + id="builder-session", + entry_mode=EntryMode.BUILD, + current_state=PcState.BUILD_EXECUTION, + ) with pytest.raises(BuilderExecutionPolicyError, match="native_restore_unavailable$"): handle_execution(env, Turn(action=Action(kind="undo"), actor=agent_case.actor), session, fc) assert fc.checkpoint_id == "before-agent" assert fc.last_snapshot_hash == revision - env.emit_canvas.assert_not_called() - repo.invalidate_conversation_items.assert_not_called() + assert emitted == [] + assert invalidated == [] assert port.read_graph(session.app_id, agent_case.actor) == (current, revision) selection = _selection(agent_case) @@ -382,7 +425,12 @@ def test_restore_refuses_native_agent_state_before_graph_sync( ) sql.commit() current, revision = port.read_graph(agent_case.workflow.app_id, agent_case.actor) - sync = Mock(side_effect=AssertionError("legacy graph sync")) + sync_calls = [] + + def sync(*args, **kwargs): + sync_calls.append((args, kwargs)) + raise AssertionError("legacy graph sync") + monkeypatch.setattr(dify_port, "_sync_graph_only", sync) with pytest.raises(BuilderExecutionPolicyError, match="native_restore_unavailable$"): port.restore_graph( @@ -391,7 +439,7 @@ def test_restore_refuses_native_agent_state_before_graph_sync( target if agent_state == "target_only" else before, expected_revision=revision, ) - sync.assert_not_called() + assert sync_calls == [] assert port.read_graph(agent_case.workflow.app_id, agent_case.actor) == (current, revision) @@ -574,8 +622,6 @@ def test_unrelated_end_edit_retains_owned_agent_without_new_resource_selection( def test_unselected_agent_retention_refuses_changed_job_copy_pin_publication_or_access( agent_case, sqlite_session_factory, monkeypatch, mutation ): - from types import SimpleNamespace - from sqlalchemy import select from core.dify_builder.models import MutationIntent @@ -603,7 +649,7 @@ def test_unselected_agent_retention_refuses_changed_job_copy_pin_publication_or_ monkeypatch.setattr( access_service, "resolve_current_agent_access_filter", - lambda **_kwargs: SimpleNamespace(accessible_agent_ids=frozenset()), + lambda **_kwargs: access_service.AgentAccessFilter(accessible_agent_ids=set()), ) elif mutation in {"binding-pin", "published-pointer"}: with sqlite_session_factory() as sql: @@ -693,7 +739,7 @@ def test_final_retained_binding_check_locks_and_refreshes_rows_before_sync( statements = [] def capture(state): - descriptions = getattr(state.statement, "column_descriptions", []) + descriptions = state.statement.column_descriptions if descriptions and descriptions[0].get("entity") is WorkflowAgentNodeBinding: statements.append(state.statement) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_claims.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_claims.py index e96d233010d..62324bb11b2 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_claims.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_claims.py @@ -34,7 +34,7 @@ def test_missing_dispatch_evidence_stops_before_authority_lookup(field): ), } args[field] = None - claim = getattr(guard, "claim_dispatch", None) + claim = guard.claim_dispatch assert callable(claim), "durable physical dispatch claim protocol is unavailable" with pytest.raises(BuilderExecutionPolicyError, match="invalid_dispatch_permit"): claim(**args) @@ -65,7 +65,7 @@ def test_in_process_dto_bypass_is_revalidated_before_authority_lookup(field, cha ), } args[field] = args[field].model_copy(update=change) - claim = getattr(guard, "claim_dispatch", None) + claim = guard.claim_dispatch assert callable(claim), "durable physical dispatch claim protocol is unavailable" with pytest.raises(BuilderExecutionPolicyError, match="invalid_dispatch_permit"): claim(**args) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_fence.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_fence.py index 791248e76a0..27e3b903577 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_fence.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_fence.py @@ -1,13 +1,20 @@ """Deterministic SQL-boundary doubles; real locking/storage evidence is normal CI only.""" from collections import UserList +from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from types import SimpleNamespace import pytest +from sqlalchemy.orm import sessionmaker +from core.dify_builder.models import Actor from models.dify_builder import DifyBuilderExecutionRequest from models.dify_builder_native_tests import ( + NativeContinuation, + NativeDispatch, + NativeEffectAttempt, + NativeEffectGrant, + NativeEffectOperation, NativeInvocationState, NativePendingAction, NativeTestInvocation, @@ -15,11 +22,57 @@ from models.dify_builder_native_tests import ( ) from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import NativeTestContextRef +from services.dify_builder.native_testing.http_binding import NativeHttpBindingResolver +from services.dify_builder.native_testing.lifecycle import NativePendingResolution +from services.dify_builder.native_testing.private_reads import PrivateReadOwner +from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader +from services.dify_builder.native_testing.state_store import NativeExecutionStateStore +from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import context as sample_context NOW = datetime(2026, 10, 15, tzinfo=UTC) REF = NativeTestContextRef(request_id="request", invocation_id="root", context_digest="a" * 64) +def fence_owner( + *, + ref: NativeTestContextRef = REF, + tenant_id: str = "tenant", + actor_id: str = "actor", + app_id: str = "app", + generation: int = 3, + task_approved: bool = False, + expires_at: datetime = NOW + timedelta(days=1), +) -> OwnedNativeTest: + """Owned invocation DTO for the isolated dispatch fence boundary.""" + return OwnedNativeTest( + ref=ref, + tenant_id=tenant_id, + actor_id=actor_id, + app_id=app_id, + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=generation, + task_approved=task_approved, + expires_at=expires_at, + state="running", + ) + + +@dataclass +class SavedPendingResolution: + resolution: NativePendingResolution + + def resolve(self, **_kwargs): + return self.resolution + + +class ForbiddenChildResolution: + def resolve_child(self, **_kwargs): + pytest.fail("existing child must not resolve/create new work") + + class Rows(UserList): def all(self): return list(self) @@ -32,12 +85,18 @@ class SQLDouble: self.rows = { NativeInvocationState: list(states), NativeTestInvocation: [ - SimpleNamespace(tenant_id="tenant", request_id="request", invocation_id=s.invocation_id, active=True) + NativeTestInvocation( + tenant_id="tenant", request_id="request", invocation_id=s.invocation_id, active=True, bindings={} + ) for s in states ], NativePendingAction: list(pending), DifyBuilderExecutionRequest: [ - SimpleNamespace( + DifyBuilderExecutionRequest( + actor_id="actor", + session_id="session", + test_input_id="input", + context={}, id="request", tenant_id="tenant", context_digest=REF.context_digest, @@ -46,8 +105,19 @@ class SQLDouble: ) ], NativeTestScope: [ - SimpleNamespace( - tenant_id="tenant", request_id="request", cancelled=False, expires_at=NOW + timedelta(days=1) + NativeTestScope( + tenant_id="tenant", + request_id="request", + cancelled=False, + expires_at=NOW + timedelta(days=1), + generation=3, + task_approved=False, + max_calls=16, + max_dispatches=16, + max_cost_units=16, + budget_limits={}, + budget_spent={}, + profile={}, ) ], } @@ -67,8 +137,10 @@ class SQLDouble: def state(identity="root", parent=None, **changes): - return SimpleNamespace( + return NativeInvocationState( **{ + "context_object_id": "context", + "resolution": {}, "tenant_id": "tenant", "request_id": "request", "invocation_id": identity, @@ -86,8 +158,12 @@ def state(identity="root", parent=None, **changes): def pending(identity="pending", **changes): - return SimpleNamespace( + return NativePendingAction( **{ + "reference_digest": "c" * 64, + "reference": "reference", + "owner_ref": "app", + "owner_version": "1", "id": identity, "tenant_id": "tenant", "request_id": "request", @@ -95,7 +171,7 @@ def pending(identity="pending", **changes): "generation": 3, "category": "approval", **changes, - } + }, ) @@ -183,7 +259,7 @@ def test_incomplete_or_revoked_lineage_never_authorizes_dispatch(fault): def test_existing_c02_coalesces_fenced_sibling_before_any_operation_write(monkeypatch): - from models.dify_builder_native_tests import NativeEffectGrant, NativeEffectOperation + from models.dify_builder_native_tests import NativeEffectOperation from runtime_contracts.builder.native.contracts import ApprovalWait from services.dify_builder.native_testing.authorization import EffectAuthorizationService from tests.unit_tests.services.dify_builder.native_testing.test_authorization import binding, intent @@ -195,9 +271,7 @@ def test_existing_c02_coalesces_fenced_sibling_before_any_operation_write(monkey session.add = writes.append session.flush = lambda: None effect = intent().model_copy(update={"key": intent().key.model_copy(update={"invocation_id": "root"})}) - owned = SimpleNamespace( - ref=REF, tenant_id="tenant", actor_id="actor", app_id="app", generation=3, task_approved=False - ) + owned = fence_owner(ref=REF, tenant_id="tenant", actor_id="actor", app_id="app", generation=3, task_approved=False) service = EffectAuthorizationService(None, clock=lambda: NOW) # This test isolates the ordered admission guard after current binding validation. monkeypatch.setattr(service, "_binding", lambda *_args: binding(effect)) @@ -206,27 +280,51 @@ def test_existing_c02_coalesces_fenced_sibling_before_any_operation_write(monkey assert writes == [] -def test_pause_cannot_discard_a_late_running_fence(): +def test_pause_cannot_discard_a_late_running_fence(monkeypatch): from contextlib import contextmanager - from models.dify_builder_native_tests import NativeContinuation from runtime_contracts.builder.native.contracts import CheckpointRef from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService selected = state(pending_ids=["late"], checkpoint_id=None, wait_reason="approval") session = SQLDouble([selected], [pending()]) - session.rows[NativeContinuation] = [SimpleNamespace(checkpoint_id="checkpoint", generation=3)] - owned = SimpleNamespace(tenant_id="tenant", ref=REF, expires_at=NOW + timedelta(days=1)) + session.rows[NativeContinuation] = [ + NativeContinuation( + checkpoint_id="checkpoint", + generation=3, + tenant_id="tenant", + request_id="request", + invocation_id="root", + payload_digest="b" * 64, + payload_ref="private-checkpoint", + ) + ] + owned = fence_owner(tenant_id="tenant", ref=REF, expires_at=NOW + timedelta(days=1)) @contextmanager def locked(**_kwargs): yield session, owned, session.rows[NativeTestScope][0] - service = NativeTestLifecycleService( - SimpleNamespace(locked=locked), - state_store=SimpleNamespace(read_checkpoint=lambda **_kwargs: SimpleNamespace(generation=3, child_refs=())), - clock=lambda: NOW, + from runtime_contracts.builder.native.runtime_contracts import NativeCheckpointPayload + + loader = OwnedNativeTestLoader( + sessionmaker(), actor=Actor(tenant_id="tenant", account_id="actor"), resolver=object() ) + monkeypatch.setattr(loader, "locked", locked) + store = NativeExecutionStateStore(loader) + payload = NativeCheckpointPayload( + schema_version="1", + native_run_id="run", + generation=3, + generate_entity_json="{}", + graph_runtime_state_json="{}", + response_stream_filter_json="{}", + node_journals=(), + child_refs=(), + input_result_refs=(), + ) + monkeypatch.setattr(store, "read_checkpoint", lambda **_kwargs: payload) + service = NativeTestLifecycleService(loader, state_store=store, clock=lambda: NOW) with pytest.raises(BuilderExecutionPolicyError, match="native_pending_mismatch"): service.pause( ref=REF, @@ -246,8 +344,21 @@ def test_approval_pending_uses_actual_local_generation_after_shared_scope_advanc writes = [] session.add = writes.append session.flush = lambda: setattr(writes[-1], "id", "pending") - owned = SimpleNamespace(ref=REF, tenant_id="tenant", generation=9, app_id="app") - EffectAuthorizationService._pending(session, owned, SimpleNamespace(id="operation", scope_digest="b" * 64)) + owned = fence_owner(ref=REF, tenant_id="tenant", generation=9, app_id="app") + EffectAuthorizationService._pending( + session, + owned, + NativeEffectOperation( + id="operation", + scope_digest="b" * 64, + tenant_id="tenant", + request_id="request", + invocation_id="root", + operation_digest="c" * 64, + intent_digest="d" * 64, + state="pending", + ), + ) assert writes[0].generation == 3 @@ -260,18 +371,23 @@ def test_native_pending_owner_uses_local_generation_after_shared_scope_advance(m row = pending(category=category) session = SQLDouble([state()], [row]) - owned = SimpleNamespace(ref=REF, tenant_id="tenant", generation=9) + owned = fence_owner(ref=REF, tenant_id="tenant", generation=9) @contextmanager def locked(**_kwargs): yield session, owned, session.rows[NativeTestScope][0] - resolver = SimpleNamespace( - resolve_pending=lambda **_kw: PendingOwner( - category=category, owner_ref="app", version="1", reference="reference" - ) + resolver = NativeHttpBindingResolver() + monkeypatch.setattr( + resolver, + "resolve_pending", + lambda **_kw: PendingOwner(category=category, owner_ref="app", version="1", reference="reference"), ) - loader = SimpleNamespace(load=lambda **_kw: owned, locked=locked, resolver=resolver) + loader = OwnedNativeTestLoader( + sessionmaker(), actor=Actor(tenant_id=owned.tenant_id, account_id=owned.actor_id), resolver=resolver + ) + monkeypatch.setattr(loader, "load", lambda **_kw: owned) + monkeypatch.setattr(loader, "locked", locked) monkeypatch.setattr(pending_actions, "utc_now", lambda: NOW, raising=False) service = pending_actions.NativePendingActionService(loader) result = service._wait(REF, category, "reference", 3) @@ -289,7 +405,7 @@ def test_fence_pending_acknowledges_after_commit_without_changing_running_state( selected = state(context_object_id="context", checkpoint_id=None, wait_reason=None) row = pending(category=category) session = SQLDouble([selected], [row]) - owned = SimpleNamespace(ref=REF, tenant_id="tenant", expires_at=NOW + timedelta(days=1)) + owned = fence_owner(ref=REF, tenant_id="tenant", expires_at=NOW + timedelta(days=1)) committed = [] @contextmanager @@ -297,15 +413,31 @@ def test_fence_pending_acknowledges_after_commit_without_changing_running_state( yield session, owned, session.rows[NativeTestScope][0] committed.append(True) - authority = SimpleNamespace( - app_id="app", workflow_id="workflow", generation=3, context_object_id="context", graph_revision="revision" + authority = PrivateReadOwner( + tenant_id="tenant", + actor_id="actor", + parent_invocation_id=None, + root_revision_matches=True, + input_revision=None, + expires_at=NOW + timedelta(days=1), + cancelled=False, + active=True, + request_state="running", + revision_context=None, + app_id="app", + workflow_id="workflow", + generation=3, + context_object_id="context", + graph_revision="revision", ) monkeypatch.setattr(lifecycle, "validate_private_read", lambda *_args: authority, raising=False) monkeypatch.setattr(lifecycle, "validate_invocation_workflow", lambda *_args, **_kw: object(), raising=False) monkeypatch.setattr(lifecycle, "native_workflow_revision", lambda _workflow: "revision", raising=False) - service = lifecycle.NativeTestLifecycleService( - SimpleNamespace(locked=locked), state_store=object(), clock=lambda: NOW + loader = OwnedNativeTestLoader( + sessionmaker(), actor=Actor(tenant_id="tenant", account_id="actor"), resolver=object() ) + monkeypatch.setattr(loader, "locked", locked) + service = lifecycle.NativeTestLifecycleService(loader, state_store=object(), clock=lambda: NOW) assert service.fence_pending(ref=REF, pending_operation_ids=(row.id,), expected_generation=3) is None assert committed == [True] assert (selected.state, selected.checkpoint_id, selected.wait_reason, selected.pending_ids) == ( @@ -339,19 +471,24 @@ def test_child_creation_checks_fence_before_protected_input_or_resolver_work(mon yield session touched = [] - parent = SimpleNamespace(tenant_id="tenant", ref=REF) - loader = SimpleNamespace(load=lambda **_kw: parent, session_factory=factory) - store = SimpleNamespace( - loader=loader, - load_context=lambda **_kw: touched.append("context") or parent, - load_result=lambda **_kw: touched.append("input") or {}, + parent = fence_owner(tenant_id="tenant", ref=REF) + loader = OwnedNativeTestLoader( + factory, + actor=Actor(tenant_id=parent.tenant_id, account_id=parent.actor_id), + resolver=NativeHttpBindingResolver(), ) + monkeypatch.setattr(loader, "load", lambda **_kw: parent) + store = NativeExecutionStateStore(loader) + monkeypatch.setattr(store, "load_context", lambda **_kw: touched.append("context") or parent) + monkeypatch.setattr(store, "load_result", lambda **_kw: touched.append("input") or {}) def resolve_child(**_kwargs): touched.append("resolver") raise BuilderExecutionPolicyError("test_resolver_reached") - service = NativeChildInvocationService(store, resolver=SimpleNamespace(resolve_child=resolve_child)) + resolver = ForbiddenChildResolution() + monkeypatch.setattr(resolver, "resolve_child", resolve_child) + service = NativeChildInvocationService(store, resolver=resolver) monkeypatch.setattr(child_invocations, "utc_now", lambda: NOW) with pytest.raises(BuilderExecutionPolicyError, match="native_dispatch_fenced"): service.get_or_create( @@ -366,17 +503,23 @@ def test_child_creation_checks_fence_before_protected_input_or_resolver_work(mon def test_retained_acknowledged_historical_ids_do_not_coalesce_or_close_admission(): - from models.dify_builder_native_tests import NativeContinuation, NativeDispatch selected = state(pending_ids=["pending"], wait_reason=None, checkpoint_id="checkpoint") session = SQLDouble([selected], [pending(generation=2)]) session.rows[NativeContinuation] = [ - SimpleNamespace( - tenant_id="tenant", request_id="request", invocation_id="root", generation=2, checkpoint_id="checkpoint" + NativeContinuation( + payload_digest="b" * 64, + payload_ref="private-checkpoint", + tenant_id="tenant", + request_id="request", + invocation_id="root", + generation=2, + checkpoint_id="checkpoint", ) ] session.rows[NativeDispatch] = [ - SimpleNamespace( + NativeDispatch( + idempotency_digest="a" * 64, tenant_id="tenant", request_id="request", invocation_id="root", @@ -390,10 +533,9 @@ def test_retained_acknowledged_historical_ids_do_not_coalesce_or_close_admission @pytest.mark.parametrize("saved", [False, True]) -def test_generation_claim_requires_actual_saved_configuration_resolution(saved): +def test_generation_claim_requires_actual_saved_configuration_resolution(saved, monkeypatch): from contextlib import contextmanager - from models.dify_builder_native_tests import NativeContinuation, NativeDispatch from runtime_contracts.builder.native.runtime_contracts import NativeCheckpointPayload from services.dify_builder.native_testing.lifecycle import NativePendingResolution, NativeTestLifecycleService @@ -407,7 +549,8 @@ def test_generation_claim_requires_actual_saved_configuration_resolution(saved): ) row = pending(generation=3, category="configuration", owner_ref="app", reference="config") session = SQLDouble([selected], [row]) - dispatch = SimpleNamespace( + dispatch = NativeDispatch( + idempotency_digest="a" * 64, id="dispatch", tenant_id="tenant", request_id="request", @@ -419,7 +562,8 @@ def test_generation_claim_requires_actual_saved_configuration_resolution(saved): ) session.rows[NativeDispatch] = [dispatch] session.rows[NativeContinuation] = [ - SimpleNamespace( + NativeContinuation( + payload_ref="private-checkpoint", tenant_id="tenant", request_id="request", invocation_id="root", @@ -428,7 +572,7 @@ def test_generation_claim_requires_actual_saved_configuration_resolution(saved): payload_digest="b" * 64, ) ] - owned = SimpleNamespace(ref=REF, tenant_id="tenant", expires_at=NOW + timedelta(days=1)) + owned = fence_owner(ref=REF, tenant_id="tenant", expires_at=NOW + timedelta(days=1)) @contextmanager def factory(): @@ -438,12 +582,12 @@ def test_generation_claim_requires_actual_saved_configuration_resolution(saved): def locked(**_kwargs): yield session, owned, session.rows[NativeTestScope][0] - loader = SimpleNamespace( - session_factory=factory, - _load=lambda *_args, **_kw: (owned, session.rows[NativeTestScope][0]), - load=lambda **_kw: owned, - locked=locked, + loader = OwnedNativeTestLoader( + factory, actor=Actor(tenant_id=owned.tenant_id, account_id=owned.actor_id), resolver=NativeHttpBindingResolver() ) + monkeypatch.setattr(loader, "_load", lambda *_args, **_kw: (owned, session.rows[NativeTestScope][0])) + monkeypatch.setattr(loader, "load", lambda **_kw: owned) + monkeypatch.setattr(loader, "locked", locked) payload = NativeCheckpointPayload( schema_version="1", native_run_id="native-run", @@ -455,10 +599,10 @@ def test_generation_claim_requires_actual_saved_configuration_resolution(saved): child_refs=(), input_result_refs=(), ) - store = SimpleNamespace( - load_context=lambda **_kw: SimpleNamespace(native_run_id="native-run", tenant_id="tenant"), - read_checkpoint=lambda **_kw: payload, - ) + store = NativeExecutionStateStore(loader) + context = sample_context().model_copy(update={"ref": REF, "native_run_id": "native-run", "tenant_id": "tenant"}) + monkeypatch.setattr(store, "load_context", lambda **_kw: context) + monkeypatch.setattr(store, "read_checkpoint", lambda **_kw: payload) resolution = NativePendingResolution( category="configuration", invocation_id="root", @@ -470,7 +614,7 @@ def test_generation_claim_requires_actual_saved_configuration_resolution(saved): answer_result_ref=None, ) service = NativeTestLifecycleService( - loader, state_store=store, resolution=SimpleNamespace(resolve=lambda **_kw: resolution), clock=lambda: NOW + loader, state_store=store, resolution=SavedPendingResolution(resolution), clock=lambda: NOW ) if saved: selected.resolution = [resolution.model_dump(mode="json")] @@ -492,18 +636,25 @@ def test_coalesced_native_wait_still_validates_requested_pending_owner(monkeypat row = pending(category="configuration") session = SQLDouble([state(pending_ids=[row.id], wait_reason="configuration")], [row]) - owned = SimpleNamespace(ref=REF, tenant_id="tenant", generation=9) + owned = fence_owner(ref=REF, tenant_id="tenant", generation=9) @contextmanager def locked(**_kwargs): yield session, owned, session.rows[NativeTestScope][0] - resolver = SimpleNamespace( - resolve_pending=lambda **_kw: PendingOwner( + resolver = NativeHttpBindingResolver() + monkeypatch.setattr( + resolver, + "resolve_pending", + lambda **_kw: PendingOwner( category="configuration", owner_ref="app", version="1", reference="changed-reference" - ) + ), ) - loader = SimpleNamespace(load=lambda **_kw: owned, locked=locked, resolver=resolver) + loader = OwnedNativeTestLoader( + sessionmaker(), actor=Actor(tenant_id=owned.tenant_id, account_id=owned.actor_id), resolver=resolver + ) + monkeypatch.setattr(loader, "load", lambda **_kw: owned) + monkeypatch.setattr(loader, "locked", locked) monkeypatch.setattr(pending_actions, "utc_now", lambda: NOW) with pytest.raises(BuilderExecutionPolicyError, match="pending_owner_mismatch"): pending_actions.NativePendingActionService(loader)._wait(REF, "configuration", "reference", 3) @@ -570,8 +721,11 @@ def test_existing_nested_child_lookup_requires_actual_parent_private_authority(m yield session # Root request loader remains authorized in every case, including local B revocation. - owned = SimpleNamespace(tenant_id="tenant", app_id="app", ref=parent_ref) - loader = SimpleNamespace(load=lambda **_kw: owned, session_factory=factory) + owned = fence_owner(tenant_id="tenant", app_id="app", ref=parent_ref) + loader = OwnedNativeTestLoader( + factory, actor=Actor(tenant_id=owned.tenant_id, account_id=owned.actor_id), resolver=NativeHttpBindingResolver() + ) + monkeypatch.setattr(loader, "load", lambda **_kw: owned) def load_context(*, ref): assert ref == parent_ref @@ -579,16 +733,16 @@ def test_existing_nested_child_lookup_requires_actual_parent_private_authority(m raise BuilderExecutionPolicyError( "execution_permission_denied" if fault == "permission" else "native_revision_drift" ) - return SimpleNamespace(ref=ref, app_id="app-B") + return fence_owner(ref=ref, app_id="app-B") def load_result(*, ref, result_ref): assert (ref, result_ref) == (parent_ref, "protected-input-B") return {"nested": "corrupt"} if fault == "input" else inputs - store = SimpleNamespace(loader=loader, load_context=load_context, load_result=load_result) - resolver = SimpleNamespace( - resolve_child=lambda **_kw: pytest.fail("existing child must not resolve/create new work") - ) + store = NativeExecutionStateStore(loader) + monkeypatch.setattr(store, "load_context", load_context) + monkeypatch.setattr(store, "load_result", load_result) + resolver = ForbiddenChildResolution() service = NativeChildInvocationService(store, resolver=resolver) monkeypatch.setattr(child_invocations, "utc_now", lambda: NOW) args = { @@ -614,18 +768,38 @@ def test_existing_nested_child_lookup_requires_actual_parent_private_authority(m @pytest.mark.parametrize("has_operation", [False, True]) @pytest.mark.parametrize("decision", ["denied", "approved"]) def test_waiting_immutable_denial_is_negative_evidence_without_fresh_admission(monkeypatch, has_operation, decision): - from models.dify_builder_native_tests import NativeEffectGrant, NativeEffectOperation + from models.dify_builder_native_tests import NativeEffectOperation from runtime_contracts.builder.execution import canonical_digest from runtime_contracts.builder.native.contracts import PolicyStop from services.dify_builder.native_testing.authorization import EffectAuthorizationService from tests.unit_tests.services.dify_builder.native_testing.test_authorization import binding, intent effect = intent().model_copy(update={"key": intent().key.model_copy(update={"invocation_id": "root"})}) - operation = SimpleNamespace(intent_digest=canonical_digest(effect.model_dump(mode="json")), state="pending") + operation = NativeEffectOperation( + intent_digest=canonical_digest(effect.model_dump(mode="json")), + state="pending", + tenant_id="tenant", + request_id="request", + invocation_id="root", + operation_digest="a" * 64, + scope_digest="b" * 64, + ) session = SQLDouble([state(state="waiting", pending_ids=["pending"], checkpoint_id="checkpoint")], [pending()]) session.rows.update( { - NativeEffectGrant: [SimpleNamespace(decision=decision)], + NativeEffectGrant: [ + NativeEffectGrant( + decision=decision, + tenant_id="tenant", + request_id="request", + actor_id="actor", + scope_digest="b" * 64, + expires_at=NOW + timedelta(hours=1), + max_calls=16, + max_dispatches=16, + max_cost_units=16, + ) + ], NativeEffectOperation: [operation] if has_operation else [], } ) @@ -634,7 +808,7 @@ def test_waiting_immutable_denial_is_negative_evidence_without_fresh_admission(m session.flush = lambda: None scope = session.rows[NativeTestScope][0] scope.spent_calls = scope.spent_dispatches = scope.spent_cost_units = 0 - owned = SimpleNamespace(ref=REF, tenant_id="tenant", actor_id="actor", app_id="app", task_approved=False) + owned = fence_owner(ref=REF, tenant_id="tenant", actor_id="actor", app_id="app", task_approved=False) service = EffectAuthorizationService(None, clock=lambda: NOW) monkeypatch.setattr(service, "_binding", lambda *_: binding(effect)) if decision == "denied": @@ -659,7 +833,10 @@ def test_physical_claim_validates_permit_then_preserves_ancestor_refusal(monkeyp child_ref = REF.model_copy(update={"invocation_id": "child"}) effect = intent().model_copy(update={"key": intent().key.model_copy(update={"invocation_id": "child"})}) trusted = binding(effect) - operation = SimpleNamespace( + operation = NativeEffectOperation( + tenant_id="tenant", + request_id="request", + invocation_id="child", operation_digest=operation_digest(effect.key), intent_digest="wrong" if fault == "intent" else canonical_digest(effect.model_dump(mode="json")), scope_digest=_scope_digest(effect, trusted), @@ -668,7 +845,22 @@ def test_physical_claim_validates_permit_then_preserves_ancestor_refusal(monkeyp session = SQLDouble([state(state=ancestor_state), state("child", "root")]) session.rows[NativeEffectOperation] = [operation] scope = session.rows[NativeTestScope][0] - attempt = SimpleNamespace(expires_at=NOW + timedelta(hours=1)) + attempt = NativeEffectAttempt( + expires_at=NOW + timedelta(hours=1), + tenant_id="tenant", + request_id="request", + invocation_id="child", + operation_id="operation", + grant_id="grant", + dispatch_index=0, + permit_digest="a" * 64, + cost_units=1, + budget_reservation_ref="budget", + execution_id="execution", + invocation_digest="b" * 64, + budget={}, + profile={}, + ) permit = DispatchPermit( permit_id="permit", attempt_id="attempt", operation_id="operation", expires_at=attempt.expires_at.isoformat() ) @@ -685,7 +877,7 @@ def test_physical_claim_validates_permit_then_preserves_ancestor_refusal(monkeyp with pytest.raises(BuilderExecutionPolicyError, match=expected): service._validate_dispatch_locked( session=session, - owned=SimpleNamespace(ref=child_ref, tenant_id="tenant"), + owned=fence_owner(ref=child_ref, tenant_id="tenant"), scope=scope, intent=effect, permit=permit, @@ -693,7 +885,7 @@ def test_physical_claim_validates_permit_then_preserves_ancestor_refusal(monkeyp @pytest.mark.parametrize("category", ["configuration", "interaction"]) -def test_terminal_child_settlement_without_resolution_owner_commits_and_keeps_parent_waiting(category): +def test_terminal_child_settlement_without_resolution_owner_commits_and_keeps_parent_waiting(category, monkeypatch): from contextlib import contextmanager from runtime_contracts.builder.native.runtime_contracts import NativeWorkflowOutcome @@ -729,7 +921,7 @@ def test_terminal_child_settlement_without_resolution_owner_commits_and_keeps_pa session = SettlementSQL([root, child], [pending(invocation_id="child", category=category)]) writes, committed = [], [] session.add = writes.append - owned = SimpleNamespace(ref=child_ref, tenant_id="tenant") + owned = fence_owner(ref=child_ref, tenant_id="tenant") scope = session.rows[NativeTestScope][0] scope.generation = 3 @@ -742,13 +934,19 @@ def test_terminal_child_settlement_without_resolution_owner_commits_and_keeps_pa current = next(row for row in session.rows[NativeTestInvocation] if row.invocation_id == ref.invocation_id) if not current.active: raise BuilderExecutionPolicyError("native_invocation_mismatch") - return SimpleNamespace(ref=ref, tenant_id="tenant"), scope + return fence_owner(ref=ref, tenant_id="tenant"), scope - store = SimpleNamespace( - loader=SimpleNamespace(locked=locked, _load=load), - load_recovery_context=lambda **_: SimpleNamespace(native_run_id="child-run"), + loader = OwnedNativeTestLoader( + sessionmaker(), + actor=Actor(tenant_id=owned.tenant_id, account_id=owned.actor_id), + resolver=NativeHttpBindingResolver(), ) - service = NativeChildInvocationService(store, resolver=SimpleNamespace()) + monkeypatch.setattr(loader, "locked", locked) + monkeypatch.setattr(loader, "_load", load) + store = NativeExecutionStateStore(loader) + context = sample_context().model_copy(update={"ref": child_ref, "native_run_id": "child-run"}) + monkeypatch.setattr(store, "load_recovery_context", lambda **_: context) + service = NativeChildInvocationService(store, resolver=ForbiddenChildResolution()) outcome = NativeWorkflowOutcome( status="completed", ref=child_ref, diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_capacity.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_capacity.py index 7a85ed44710..02e0bb5f297 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_capacity.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_capacity.py @@ -2,14 +2,23 @@ from collections import UserList from contextlib import contextmanager +from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from types import SimpleNamespace import pytest from sqlalchemy.sql import operators -from sqlalchemy.sql.elements import BinaryExpression, BooleanClauseList +from sqlalchemy.sql.elements import ( + BinaryExpression, + BindParameter, + BooleanClauseList, + ColumnClause, + False_, + Null, + True_, +) -from models.dify_builder import DifyBuilderExecutionRequest, DifyBuilderTestInput +from core.dify_builder.models import Actor +from models.dify_builder import DifyBuilderExecutionRequest, DifyBuilderSession, DifyBuilderTestInput from models.dify_builder_native_tests import ( NativeContinuation, NativeDispatch, @@ -19,6 +28,7 @@ from models.dify_builder_native_tests import ( NativeEffectOutcome, NativeInvocationState, NativePayloadObject, + NativePendingAction, NativeTestInvocation, NativeTestScope, ) @@ -28,7 +38,12 @@ from runtime_contracts.builder.execution import BuilderExecutionPolicyError, can from runtime_contracts.builder.native.contracts import NativeTestContextRef from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService -from services.dify_builder.native_testing.repository import native_workflow_revision +from services.dify_builder.native_testing.repository import ( + OwnedNativeTest, + OwnedNativeTestLoader, + native_workflow_revision, +) +from services.dify_builder.native_testing.state_store import NativeExecutionStateStore NOW = datetime(2026, 10, 16, tzinfo=UTC) @@ -53,13 +68,104 @@ class SQL: return any(results) if expression.operator is operators.or_ else all(results) if not isinstance(expression, BinaryExpression): return True - field = getattr(expression.left, "key", None) - if field is None or not hasattr(row, field): + field = expression.left.key if isinstance(expression.left, ColumnClause) else None + if field is None: + return True + if isinstance(expression.right, BindParameter): + value = expression.right.value + elif isinstance(expression.right, (True_, False_, Null)): + value = {True_: True, False_: False, Null: None}[type(expression.right)] + else: + raise AssertionError("unsupported SQL predicate operand") + # This unit boundary only evaluates the native ownership predicates + # represented by these explicit records; real SQL is tested separately. + try: + if field == "account_id": + actual = row.account_id + elif field == "context_digest": + actual = row.context_digest + elif field == "type": + actual = row.type + elif field == "value_digest": + actual = row.value_digest + elif field == "id": + actual = row.id + elif field == "tenant_id": + actual = row.tenant_id + elif field == "actor_id": + actual = row.actor_id + elif field == "request_id": + actual = row.request_id + elif field == "invocation_id": + actual = row.invocation_id + elif field == "parent_invocation_id": + actual = row.parent_invocation_id + elif field == "app_id": + actual = row.app_id + elif field == "workflow_id": + actual = row.workflow_id + elif field == "session_id": + actual = row.session_id + elif field == "state": + actual = row.state + elif field == "status": + actual = row.status + elif field == "generation": + actual = row.generation + elif field == "active": + actual = row.active + elif field == "cancelled": + actual = row.cancelled + elif field == "logical_key": + actual = row.logical_key + elif field == "kind": + actual = row.kind + elif field == "scope_digest": + actual = row.scope_digest + elif field == "operation_id": + actual = row.operation_id + elif field == "attempt_id": + actual = row.attempt_id + elif field == "cleanup_complete": + actual = row.cleanup_complete + elif field == "claim_expires_at": + actual = row.claim_expires_at + elif field == "claimed_at": + actual = row.claimed_at + elif field == "expires_at": + actual = row.expires_at + elif field == "source_generation": + actual = row.source_generation + elif field == "context_object_id": + actual = row.context_object_id + elif field == "checkpoint_id": + actual = row.checkpoint_id + elif field == "payload_digest": + actual = row.payload_digest + elif field == "test_input_id": + actual = row.test_input_id + elif field == "owner_account_id": + actual = row.owner_account_id + elif field == "version": + actual = row.version + elif field == "cancellation_key": + actual = row.cancellation_key + elif field == "created_by": + actual = row.created_by + elif field == "created_by_role": + actual = row.created_by_role + elif field == "workflow_run_id": + actual = row.workflow_run_id + elif field == "node_id": + actual = row.node_id + elif field == "node_execution_id": + actual = row.node_execution_id + elif field == "triggered_from": + actual = row.triggered_from + else: + raise AssertionError(f"unsupported SQL predicate column: {field}") + except AttributeError: return True - value = getattr(expression.right, "value", None) - if not hasattr(expression.right, "value"): - value = {"true": True, "false": False, "NULL": None}.get(str(expression.right)) - actual = getattr(row, field) if expression.operator in {operators.eq, operators.is_}: return actual == value if expression.operator in {operators.ne, operators.is_not}: @@ -72,7 +178,13 @@ class SQL: rows = [row for row in rows if all(matches(clause, row) for clause in query._where_criteria)] return Rows( - getattr(r, description["name"]) if description["name"] in {"invocation_id", "id", "request_id"} else r + r.invocation_id + if description["name"] == "invocation_id" + else r.id + if description["name"] == "id" + else r.request_id + if description["name"] == "request_id" + else r for r in rows ) @@ -97,15 +209,32 @@ class SQL: row.id = "dispatch-" + str(i) +@dataclass +class HostCapacityCase: + lifecycle: NativeTestLifecycleService + loader: OwnedNativeTestLoader + session: SQL + state: NativeInvocationState + scope: NativeTestScope + request: DifyBuilderExecutionRequest + context: ResolvedNativeTestContext + ref: NativeTestContextRef + events: list[str] + owned: OwnedNativeTest + + @pytest.fixture -def case(): +def case(monkeypatch): events = [] - workflow = SimpleNamespace( + workflow = Workflow( + type="workflow", + version="draft", + created_by="actor", id="workflow", app_id="app", tenant_id="tenant", graph="{}", - serialized_features="{}", + _features="{}", _environment_variables="[]", _conversation_variables="[]", ) @@ -114,7 +243,7 @@ def case(): ref = NativeTestContextRef( request_id="request", invocation_id="root", context_digest=canonical_digest(context_body) ) - state = SimpleNamespace( + state = NativeInvocationState( id="state", tenant_id="tenant", request_id="request", @@ -133,7 +262,8 @@ def case(): cancellation_key=None, cleanup_complete=False, ) - request = SimpleNamespace( + request = DifyBuilderExecutionRequest( + session_id="session", id="request", tenant_id="tenant", actor_id="actor", @@ -144,7 +274,13 @@ def case(): context_digest=ref.context_digest, state="running", ) - scope = SimpleNamespace( + scope = NativeTestScope( + task_approved=False, + max_calls=16, + max_dispatches=16, + max_cost_units=16, + budget_limits={}, + profile={}, request_id="request", tenant_id="tenant", cancelled=False, @@ -180,12 +316,17 @@ def case(): DifyBuilderExecutionRequest: [request], NativeTestScope: [scope], NativeTestInvocation: [ - SimpleNamespace( - id="invocation", tenant_id="tenant", request_id="request", invocation_id="root", active=True + NativeTestInvocation( + id="invocation", + tenant_id="tenant", + request_id="request", + invocation_id="root", + active=True, + bindings={}, ) ], NativeDispatch: [ - SimpleNamespace( + NativeDispatch( id="dispatch", tenant_id="tenant", request_id="request", @@ -203,7 +344,10 @@ def case(): NativeEffectDispatchClaim: [], NativeEffectOutcome: [], NativePayloadObject: [ - SimpleNamespace( + NativePayloadObject( + logical_key="context", + storage_key="private-context", + payload_digest="e" * 64, id="context", tenant_id="tenant", request_id="request", @@ -212,12 +356,21 @@ def case(): value_digest=canonical_digest(context.model_dump(mode="json")), ) ], - DifyBuilderTestInput: [SimpleNamespace(id="input", inputs={"x": True})], + DifyBuilderTestInput: [ + DifyBuilderTestInput( + id="input", inputs={"x": True}, session_id="session", source="mock", start_schema_hash="" + ) + ], Workflow: [workflow], - App: [SimpleNamespace(id="app", tenant_id="tenant", status="normal")], + App: [App(id="app", tenant_id="tenant", status="normal")], } session = SQL(rows, events) - owned = SimpleNamespace( + owned = OwnedNativeTest( + session_id="session", + bindings={}, + generation=0, + task_approved=False, + revision_context=None, ref=ref, tenant_id="tenant", actor_id="actor", @@ -252,22 +405,19 @@ def case(): yield session, owned, scope events.append("commit") - loader = SimpleNamespace( - session_factory=factory, - _load=load, - load=lambda **_: owned, - locked=locked, - actor=SimpleNamespace(tenant_id="tenant", account_id="actor"), - ) + loader = OwnedNativeTestLoader(factory, actor=Actor(tenant_id="tenant", account_id="actor"), resolver=object()) + monkeypatch.setattr(loader, "_load", load) + monkeypatch.setattr(loader, "load", lambda **_: owned) + monkeypatch.setattr(loader, "locked", locked) def load_context(**_): events.append("private") return context - lifecycle = NativeTestLifecycleService( - loader, state_store=SimpleNamespace(load_context=load_context), clock=lambda: NOW - ) - return SimpleNamespace( + store = NativeExecutionStateStore(loader) + monkeypatch.setattr(store, "load_context", load_context) + lifecycle = NativeTestLifecycleService(loader, state_store=store, clock=lambda: NOW) + return HostCapacityCase( lifecycle=lifecycle, loader=loader, session=session, @@ -295,7 +445,14 @@ def test_initial_generation_claim_uses_acknowledged_context_without_fake_checkpo ["prepare_dispatch_claim", "consume_dispatch_claim", "enqueue_initial_dispatch", "finalize_dispatch_locked"], ) def test_transaction_local_c03_ports_are_explicit(method): - assert callable(getattr(NativeTestLifecycleService, method, None)) + assert callable( + { + "prepare_dispatch_claim": NativeTestLifecycleService.prepare_dispatch_claim, + "consume_dispatch_claim": NativeTestLifecycleService.consume_dispatch_claim, + "enqueue_initial_dispatch": NativeTestLifecycleService.enqueue_initial_dispatch, + "finalize_dispatch_locked": NativeTestLifecycleService.finalize_dispatch_locked, + }[method] + ) def test_capacity_requires_actual_cleanup_verifier(case): @@ -334,7 +491,8 @@ def test_unclean_existing_root_holds_capacity_across_sessions_and_terminal_statu from services.dify_builder.native_testing.host_capacity import NativeHostCapacity owner = NativeHostCapacity(case.lifecycle, cleanup_verifier=OnceVerifier(case.events)) - q = SimpleNamespace( + q = DifyBuilderExecutionRequest( + test_input_id="other-input", id="other-request", tenant_id="tenant", app_id="app", @@ -345,7 +503,11 @@ def test_unclean_existing_root_holds_capacity_across_sessions_and_terminal_statu context_digest=case.request.context_digest, state=request_status, ) - r = SimpleNamespace( + r = NativeInvocationState( + context_object_id="other-context", + generation=0, + pending_ids=[], + resolution=[], request_id=q.id, tenant_id="tenant", invocation_id="other-root", @@ -355,11 +517,52 @@ def test_unclean_existing_root_holds_capacity_across_sessions_and_terminal_statu cleanup_complete=False, state="cancelled", ) - d = SimpleNamespace(state=status, request_id=q.id, tenant_id=q.tenant_id, invocation_id=r.invocation_id) - b = SimpleNamespace(tenant_id="tenant", app_id="app", owner_account_id=q.actor_id) - invocation = SimpleNamespace(request_id=q.id, tenant_id=q.tenant_id, invocation_id=r.invocation_id) - scope = SimpleNamespace(request_id=q.id, tenant_id=q.tenant_id) - case.session.occupancy = [(q, d, r, invocation, scope, b, SimpleNamespace(session_id=q.session_id))] + d = NativeDispatch( + state=status, + request_id=q.id, + tenant_id=q.tenant_id, + invocation_id=r.invocation_id, + generation=0, + idempotency_digest="a" * 64, + ) + b = DifyBuilderSession( + id=q.session_id, + tenant_id="tenant", + app_id="app", + owner_account_id=q.actor_id, + entry_mode="new", + current_state="build.review", + ) + invocation = NativeTestInvocation( + request_id=q.id, tenant_id=q.tenant_id, invocation_id=r.invocation_id, active=True, bindings={} + ) + scope = NativeTestScope( + request_id=q.id, + tenant_id=q.tenant_id, + generation=0, + task_approved=False, + cancelled=False, + expires_at=NOW + timedelta(days=1), + max_calls=16, + max_dispatches=16, + max_cost_units=16, + budget_limits={}, + budget_spent={}, + profile={}, + ) + case.session.occupancy = [ + ( + q, + d, + r, + invocation, + scope, + b, + DifyBuilderTestInput( + id="other-input", session_id=q.session_id, source="mock", inputs={}, start_schema_hash="" + ), + ) + ] with pytest.raises(BuilderExecutionPolicyError, match="native_capacity_busy"): owner.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") assert case.session.rows[NativeDispatch][0].state == "queued" @@ -390,7 +593,7 @@ def test_composed_recovery_refuses_unclean_history_before_requeue(case): NativeHostCapacity(case.lifecycle, cleanup_verifier=OnceVerifier(case.events)) case.state.state = "unknown" - case.owned.state = "running" + assert case.owned.state == "running" with pytest.raises(BuilderExecutionPolicyError, match="native_cleanup_unproven"): case.lifecycle._check_recovery_authority(case.session, case.ref, case.owned, case.scope) @@ -402,7 +605,13 @@ def test_runtime_cancel_ids_do_not_release_composed_capacity_without_verifier(ca verifier.current = False owner = NativeHostCapacity(case.lifecycle, cleanup_verifier=verifier) assert owner.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") - case.lifecycle.runtime = SimpleNamespace(cancel=lambda **_: ("root",)) + + class UnprovenCancelRuntime: + def cancel(self, *, refs): + assert refs + return ("root",) + + case.lifecycle.runtime = UnprovenCancelRuntime() result = case.lifecycle.cancel(actor=case.loader.actor, request_id="request", idempotency_key="cancel") assert result.status == "cancelling" assert not case.state.cleanup_complete @@ -411,7 +620,8 @@ def test_runtime_cancel_ids_do_not_release_composed_capacity_without_verifier(ca def test_transaction_local_finalize_cannot_mutate_another_requests_claim(case): from runtime_contracts.builder.native.runtime_contracts import NativeWorkflowOutcome - foreign = SimpleNamespace( + foreign = NativeDispatch( + idempotency_digest="a" * 64, id="foreign-dispatch", tenant_id="tenant", request_id="foreign", @@ -456,7 +666,6 @@ def test_initial_enqueue_is_real_acknowledged_idempotent_outbox_not_a_checkpoint @pytest.mark.parametrize("fault", ["wrong_initial_key", "already_clean", "unrepresented_form"]) def test_initial_claim_cannot_borrow_resume_or_prior_pending_authority(case, fault): - from models.dify_builder_native_tests import NativePendingAction if fault == "wrong_initial_key": case.session.rows[NativeDispatch][0].idempotency_digest = "foreign" @@ -464,7 +673,18 @@ def test_initial_claim_cannot_borrow_resume_or_prior_pending_authority(case, fau case.state.cleanup_complete = True else: case.session.rows[NativePendingAction] = [ - SimpleNamespace(id="pending", request_id="request", tenant_id="tenant") + NativePendingAction( + id="pending", + request_id="request", + tenant_id="tenant", + invocation_id="root", + generation=0, + category="approval", + reference_digest="a" * 64, + reference="reference", + owner_ref="app", + owner_version="1", + ) ] with pytest.raises(BuilderExecutionPolicyError, match="native_initial_conflict"): case.lifecycle.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") @@ -483,7 +703,23 @@ def test_initial_enqueue_rechecks_actual_context_and_sql_inputs_before_outbox(ca case.session.rows[Workflow][0].graph = '{"changed":true}' else: case.session.rows[NativeEffectAttempt] = [ - SimpleNamespace(id="attempt", tenant_id="tenant", request_id="request") + NativeEffectAttempt( + id="attempt", + tenant_id="tenant", + request_id="request", + invocation_id="root", + operation_id="operation", + grant_id="grant", + dispatch_index=0, + permit_digest="a" * 64, + expires_at=NOW + timedelta(days=1), + cost_units=1, + budget_reservation_ref="budget", + execution_id="execution", + invocation_digest="b" * 64, + budget={}, + profile={}, + ) ] with pytest.raises(BuilderExecutionPolicyError): case.lifecycle.enqueue_initial_dispatch(ref=case.ref) @@ -493,7 +729,11 @@ def test_initial_enqueue_rechecks_actual_context_and_sql_inputs_before_outbox(ca def test_boolean_cleanup_result_is_not_actual_trusted_none_success(case): from services.dify_builder.native_testing.host_capacity import NativeHostCapacity - owner = NativeHostCapacity(case.lifecycle, cleanup_verifier=SimpleNamespace(consume_cleanup=lambda **_: True)) + class BooleanCleanupVerifier: + def consume_cleanup(self, **_kwargs): + return True + + owner = NativeHostCapacity(case.lifecycle, cleanup_verifier=BooleanCleanupVerifier()) assert owner.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") with pytest.raises(BuilderExecutionPolicyError, match="native_cleanup_unproven"): owner.record_cleanup(ref=case.ref, generation=0, dispatch_id="dispatch") @@ -551,8 +791,16 @@ def test_transaction_local_outcome_cannot_attach_a_foreign_private_result(case): dispatch.state = "claimed" case.state.state = "running" case.session.rows[NativePayloadObject].append( - SimpleNamespace( - id="foreign-result", tenant_id="tenant", request_id="foreign", invocation_id="root", kind="result" + NativePayloadObject( + logical_key="result", + storage_key="foreign-storage", + payload_digest="a" * 64, + value_digest="b" * 64, + id="foreign-result", + tenant_id="tenant", + request_id="foreign", + invocation_id="root", + kind="result", ) ) outcome = NativeWorkflowOutcome( @@ -636,7 +884,17 @@ def test_preclaim_cancel_refuses_uncertain_history_without_calling_physical_veri elif fault == "missing_context": case.session.rows[NativePayloadObject] = [] else: - case.session.rows[NativeEffectDispatchClaim] = [SimpleNamespace(id="effect", request_id="request")] + case.session.rows[NativeEffectDispatchClaim] = [ + NativeEffectDispatchClaim( + id="effect", + request_id="request", + tenant_id="tenant", + invocation_id="root", + operation_id="operation", + attempt_id="attempt", + claimed_at=NOW, + ) + ] result = case.lifecycle.cancel(actor=case.loader.actor, request_id="request", idempotency_key="cancel") assert result.status == "cancelling" assert not case.state.cleanup_complete diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_generation_wiring.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_generation_wiring.py index d235d0b083e..7cf979191f8 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_generation_wiring.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_generation_wiring.py @@ -11,11 +11,11 @@ import json from contextlib import nullcontext from threading import Event, current_thread from time import monotonic -from types import SimpleNamespace from typing import cast, override import pytest from flask import Flask +from sqlalchemy.orm import Session, sessionmaker from typing_extensions import TypedDict from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity @@ -94,14 +94,14 @@ def generate_fixture(case: LaunchCase, monkeypatch: pytest.MonkeyPatch): generator = WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run) monkeypatch.setattr(generator, "_bind_file_access_scope", lambda **_kwargs: nullcontext()) - monkeypatch.setattr(module, "db", SimpleNamespace(session=SimpleNamespace(close=lambda: None))) + monkeypatch.setattr(module.db.session, "close", lambda: None) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *_args, **_kwargs: None) monkeypatch.setattr(generator, "_handle_response", lambda **_kwargs: iter_response()) monkeypatch.setattr(module.WorkflowAppGenerateResponseConverter, "convert", lambda **kwargs: kwargs["response"]) arguments: _GenerateArguments = { - "app_model": cast(App, SimpleNamespace(mode="workflow", tenant_id=case.state.context.tenant_id)), - "workflow": cast(Workflow, SimpleNamespace(features_dict={})), - "user": cast(Account, SimpleNamespace()), + "app_model": App(mode="workflow", tenant_id=case.state.context.tenant_id), + "workflow": Workflow(features="{}"), + "user": Account(name="Native actor", email="native@example.test"), "application_generate_entity": entity, "invoke_from": InvokeFrom.DEBUGGER, "workflow_execution_repository": workflow_repo, @@ -181,10 +181,10 @@ def test_owned_worker_preserves_bound_entity_and_reports_finally_on_actual_threa ) bind_component_observation(case, monkeypatch) - workflow = SimpleNamespace(graph_dict=case.state.context.graph) - monkeypatch.setattr( - module.session_factory, "create_session", lambda: nullcontext(SimpleNamespace(scalar=lambda _query: workflow)) - ) + workflow = Workflow(graph=json.dumps(case.state.context.graph)) + session = Session() + monkeypatch.setattr(session, "scalar", lambda _query: workflow) + monkeypatch.setattr(module.session_factory, "create_session", lambda: session) seen = [] class RunnerPort: @@ -242,7 +242,6 @@ def test_owned_worker_preserves_bound_entity_and_reports_finally_on_actual_threa def test_entry_installs_actual_host_layer_first_and_disables_raw_observers(monkeypatch: pytest.MonkeyPatch) -> None: import services.workflow_runtime.entry as module - from configs import dify_config case = LaunchCase() entity, queue, response_filter, workflow_repo, node_repo = generation_objects(case, monkeypatch) @@ -271,8 +270,9 @@ def test_entry_installs_actual_host_layer_first_and_disables_raw_observers(monke ) graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="start") commands = case.host.wrap_commands(channel=InMemoryChannel()) - monkeypatch.setattr(dify_config, "DEBUG", True) - monkeypatch.setattr(dify_config, "ENABLE_OTEL", True) + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, DEBUG=True, ENABLE_OTEL=True) monkeypatch.setattr(module, "ObservabilityLayer", lambda: pytest.fail("raw OTel observer")) entry = WorkflowEntry( graph_config=config, @@ -585,8 +585,6 @@ def test_actual_runner_passes_same_host_filter_runtime_and_wrapped_combined_chan monkeypatch.setattr(module, "CombinedCommandChannel", build_channel) monkeypatch.setattr(module, "attach_stop_aware_ready_queue", lambda *_args, **_kwargs: None) - from unittest.mock import Mock - from services import workflow_run_agg as aggregate_module class PersistencePort(LayerPort): @@ -604,7 +602,11 @@ def test_actual_runner_passes_same_host_filter_runtime_and_wrapped_combined_chan monkeypatch.setattr( aggregate_module, "build_workflow_agent_workspace_retirement_layer", lambda **_kwargs: LayerPort() ) - monkeypatch.setattr(aggregate_module.WorkflowRunAgg, "_api_run_repository", lambda _self: Mock()) + from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + + api_repository = DifyAPISQLAlchemyWorkflowRunRepository(sessionmaker()) + monkeypatch.setattr(api_repository, "bind_workflow_task", lambda **_kwargs: None) + monkeypatch.setattr(aggregate_module.WorkflowRunAgg, "_api_run_repository", lambda _self: api_repository) monkeypatch.setattr(aggregate_module.WorkflowRunAgg, "_refresh_form_completions", lambda _self: None) runner = module.WorkflowAppRunner( application_generate_entity=entity, diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_http_adapter.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_http_adapter.py index d9699c4f03c..588a60abfdd 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_http_adapter.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_http_adapter.py @@ -36,6 +36,7 @@ from services.dify_builder.native_testing.call_adapter import NativeCallAdapter from services.dify_builder.native_testing.control import NativeApprovalWait, NativePolicyStop from services.dify_builder.native_testing.operation_journal import NodeOperationJournal from tests.unit_tests.config_override import apply_config_overrides +from tests.unit_tests.logging_helpers import protect_existing_log_routes from tests.unit_tests.services.dify_builder.native_testing.test_call_adapter import ( INPUT, PATH, @@ -53,7 +54,10 @@ def protected_application_logs(monkeypatch): test-only initialization uses the exact application filter, so readiness still inspects actual handlers and never a mocked readiness assertion. """ + from libs.sensitive_http_telemetry import _HTTP_LOGGERS + root = logging.getLogger() + protect_existing_log_routes(monkeypatch, emitters=_HTTP_LOGGERS) old_handlers, old_level = root.handlers[:], root.level old_filters = {handler: handler.filters[:] for handler in old_handlers} for handler in old_handlers: @@ -151,7 +155,14 @@ class Harness: "follow_redirects": True, } args.update(kwargs) - return getattr(self.client, method)(url, max_retries=0, **args) + return { + "get": self.client.get, + "head": self.client.head, + "post": self.client.post, + "put": self.client.put, + "patch": self.client.patch, + "delete": self.client.delete, + }[method](url, max_retries=0, **args) def replay(self): self.cursor = self.journal.open(ref=REF, occurrence_path=PATH) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_http_binding.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_http_binding.py index cc9adfad557..496da855800 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_http_binding.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_http_binding.py @@ -3,11 +3,14 @@ import importlib import importlib.util from collections.abc import Callable -from dataclasses import replace -from types import SimpleNamespace +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime +from types import ModuleType from typing import cast +import httpx import pytest +from sqlalchemy.orm import sessionmaker from core.dify_builder.models import Actor from graphon.nodes.http_request import HttpRequestNode @@ -17,10 +20,43 @@ from runtime_contracts.builder.execution import BuilderExecutionPolicyError, can from runtime_contracts.builder.native.contracts import NativeTestContextRef from services.dify_builder.native_testing.call_adapter import NativeCallAdapter from services.dify_builder.native_testing.http_adapter import NativeHttpIntentPort -from services.dify_builder.native_testing.runtime_bridge import NativeRuntimeBridge +from services.dify_builder.native_testing.http_binding import ( + DeferredNativeHttpClient, + NativeHttpBindingProducer, + NativeHttpBindingResolver, +) +from services.dify_builder.native_testing.operation_journal import NodeOperationJournal +from services.dify_builder.native_testing.private_reads import PrivateReadOwner +from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader +from services.dify_builder.native_testing.runtime_bridge import NativeNodePreparation, NativeRuntimeBridge +from tests.unit_tests.services.dify_builder.native_testing.test_call_adapter import Authorization, State from tests.unit_tests.services.dify_builder.native_testing.test_http_adapter import ( protected_application_logs as protected_application_logs, # noqa: PLC0414 - shared pytest fixture ) +from tests.unit_tests.services.dify_builder.native_testing.test_runtime_bridge import SyntheticSink + + +@dataclass +class ConcreteHttpCase: + m: ModuleType + producer: NativeHttpBindingProducer + state: State + auth: Authorization + journal: NodeOperationJournal + association: NativeNodePreparation + node: HttpRequestNode + events: list + uuid: str + raw: dict + owned: OwnedNativeTest + reply: Callable[[httpx.Request], httpx.Response] | None = None + prepared: list = field(default_factory=list) + sent: list[httpx.Request] = field(default_factory=list) + client: DeferredNativeHttpClient | None = None + + +class NonCallableHttpCalls: + call: None = None def owner(): @@ -167,10 +203,12 @@ def concrete_case(monkeypatch): state.store_node_inputs = store_inputs runtime = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) - bridge = SimpleNamespace( - _boundary_owner=BoundaryOwnerState(ref=REF), - _control_sink=SimpleNamespace(record_control=lambda **_kw: None), - resolve=lambda ref: state.load_context(ref=ref), + bridge = NativeRuntimeBridge( + state=state, + collaborators=lambda **_kwargs: pytest.fail("producer test constructed runtime collaborators"), + boundary_owner=BoundaryOwnerState(ref=REF), + control_sink=SyntheticSink(), + node_arguments=lambda **_kwargs: pytest.fail("producer test requested runtime arguments"), ) association = NativeNodePreparation( bridge=cast(NativeRuntimeBridge, bridge), @@ -193,10 +231,46 @@ def concrete_case(monkeypatch): file_reference_factory=m.RefusingNativeHttpFiles(), ) association.attach(node) - loader = SimpleNamespace( - actor=Actor(tenant_id="tenant", account_id="actor"), load=lambda **_kw: events.append(("load",)) + loader = OwnedNativeTestLoader( + sessionmaker(), actor=Actor(tenant_id="tenant", account_id="actor"), resolver=NativeHttpBindingResolver() ) - monkeypatch.setattr(m, "validate_private_read", lambda *_a: SimpleNamespace(generation=0)) + owned = OwnedNativeTest( + ref=REF, + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=0, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", + ) + + def load(**_kwargs): + events.append(("load",)) + return owned + + monkeypatch.setattr(loader, "load", load) + authority = PrivateReadOwner( + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + parent_invocation_id=None, + context_object_id="context", + generation=0, + graph_revision="a" * 64, + root_revision_matches=True, + input_revision=None, + expires_at=owned.expires_at, + cancelled=False, + active=True, + request_state="running", + ) + monkeypatch.setattr(m, "validate_private_read", lambda *_args: authority) journal = NodeOperationJournal(state=state, authorization=auth) producer = m.NativeHttpBindingProducer(association=association, loader=loader, state_store=state, journal=journal) @@ -205,7 +279,8 @@ def concrete_case(monkeypatch): assert any(event[0] == "input_ack" for event in events) monkeypatch.setattr(producer, "_install", install) - return SimpleNamespace( + return ConcreteHttpCase( + owned=owned, m=m, producer=producer, state=state, @@ -279,6 +354,7 @@ def physical_http(concrete_case, monkeypatch): def transport(request): sent.append(request) + assert c.reply is not None return c.reply(request) lower = httpx.Client(transport=httpx.MockTransport(transport)) @@ -379,7 +455,7 @@ def test_current_classification_does_not_echo_stored_flags(concrete_case, monkey stored = frozen.model_copy( update={"isolated": True, "cost_units": 0, "destructive": True, "approval_scope": "batch"} ) - owned = SimpleNamespace(ref=c.association.ref, bindings={operation_digest(key): stored.model_dump(mode="json")}) + owned = replace(c.owned, ref=c.association.ref, bindings={operation_digest(key): stored.model_dump(mode="json")}) monkeypatch.setattr(c.m, "_current_metadata", lambda **_kw: (c.raw, desc, "c" * 64)) current = c.m.NativeHttpBindingResolver().resolve_binding(session=object(), owned=owned, key=key) assert current == frozen @@ -393,7 +469,7 @@ def test_actual_wait_exports_one_call_proposal_from_scope_expiry(physical_http): from services.dify_builder.native_testing.control import NativeApprovalWait c = physical_http - c.producer.loader.load = lambda **_kw: SimpleNamespace(expires_at=datetime(2030, 1, 1, tzinfo=UTC)) + c.producer.loader.load = lambda **_kw: replace(c.owned, expires_at=datetime(2030, 1, 1, tzinfo=UTC)) c.auth.decisions[0] = ApprovalWait(pending_operation_id="pending") with pytest.raises(NativeApprovalWait): invoke_native_executor(c) @@ -702,7 +778,7 @@ def test_host_seams_observer_exception_refuses_before_scalar_authority(physical_ assert c.sent == [] -@pytest.mark.parametrize("malformed", [None, object(), SimpleNamespace(call=None)], ids=["none", "object", "no-call"]) +@pytest.mark.parametrize("malformed", [None, object(), NonCallableHttpCalls()], ids=["none", "object", "no-call"]) def test_host_seams_invalid_factory_owner_has_no_fallback(physical_http, malformed): from services.dify_builder.native_testing.control import NativePolicyStop diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_lifecycle.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_lifecycle.py index 272efc7d935..4addc977f42 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_lifecycle.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_lifecycle.py @@ -91,12 +91,21 @@ def test_payload_codec_snapshots_caller_mutation(): def test_controlling_proof_keeps_independently_unknown_and_completed_branches_inactive(): - from types import SimpleNamespace - + from models.dify_builder_native_tests import NativeInvocationState from services.dify_builder.native_testing.transitions import recoverable_invocation_ids def state(identity, parent, status): - return SimpleNamespace(invocation_id=identity, parent_invocation_id=parent, state=status) + return NativeInvocationState( + tenant_id="tenant", + request_id="request", + invocation_id=identity, + parent_invocation_id=parent, + state=status, + context_object_id="context", + generation=0, + pending_ids=[], + resolution={}, + ) # Descendants precede parents deliberately; topology, not row order, decides. states = [ 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 e5f735aecaa..e6f470108c6 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 @@ -1,18 +1,115 @@ """Worker orchestration uses only current protected state and a native owner.""" -from typing import cast, override -from unittest.mock import Mock +from dataclasses import dataclass import pytest +from redis import Redis +from sqlalchemy import Engine +from sqlalchemy.orm import sessionmaker +from core.dify_builder.models import Actor +from graphon.runtime import RuntimeState, VariablePool +from graphon.variable_loader import DUMMY_VARIABLE_LOADER +from models.account import Account +from models.model import App +from models.workflow import Workflow from runtime_contracts.builder.native.runtime_contracts import NativeWorkflowOutcome, ResolvedNativeTestContext +from services.dify_builder.native_testing.api_service import NativeTestApiService +from services.dify_builder.native_testing.authorization import EffectAuthorizationService from services.dify_builder.native_testing.context_binding import NativeContextUnavailable +from services.dify_builder.native_testing.native_execution import DifyNativeWorkflowOwner +from services.dify_builder.native_testing.runtime_bridge import NativePreparedInputs from tests.unit_tests.services.dify_builder.native_testing.test_runtime_contracts import CONTEXT from tests.unit_tests.services.dify_builder.test_execution_policy_service import owned __all__ = ["owned"] +@dataclass +class _NativeDatabase: + engine: Engine + + +@dataclass +class _HostObservationCase: + host: object + + +class _ObservedSessionFactory(sessionmaker): + """Real Session creation with an entry witness for refusal-before-SQL checks.""" + + def __init__(self): + super().__init__() + self.opens = 0 + + def __call__(self, **kwargs): + self.opens += 1 + return super().__call__(**kwargs) + + +class _StreamResultPort: + """The stream boundary closes the actual reader and returns its typed outcome.""" + + def consume(self, *, context, stream, on_event): + assert callable(on_event) + try: + list(stream) + finally: + stream.finish_reader() + return _outcome(context) + + def resume(self, *, context, generation): + assert generation == context.generation + return _outcome(context) + + +def _outcome(context): + return NativeWorkflowOutcome( + status="completed", + ref=context.ref, + native_run_id=context.native_run_id, + checkpoint_ref=None, + pending_operation_ids=(), + reason_code=None, + wait_reason=None, + native_status="failed", + output_result_ref=None, + ) + + +def _api(): + return NativeTestApiService( + session_factory=sessionmaker(), + actor=Actor(account_id="actor-1", tenant_id="tenant-1"), + resolver=None, + admission=None, + resolution=None, + runtime=None, + disclosure=None, + ) + + +class _InitialInputPreparation: + def prepare(self, *, context): + return NativePreparedInputs(inputs=context.inputs, files=(), environment_variables=()) + + +def _unexpected_collaborators(**_kwargs): + raise AssertionError("the service boundary must not resolve a node") + + +def _node_arguments(**_kwargs): + return {} + + +def _on_event(_event): + raise AssertionError("this boundary must not emit an event") + + +def _account(): + return Account(name="Owner", email="owner@example.invalid") + + @pytest.mark.parametrize("failed_owner", ["source", "conversion"]) def test_native_stream_failed_close_never_acknowledges_or_retries(failed_owner, monkeypatch): from services.dify_builder.native_testing.native_execution import NativeGeneratorStream @@ -71,7 +168,7 @@ def test_native_stream_successful_close_is_acknowledged_once(): next(stream) -def native_host_fixture(context): +def native_host_fixture(context, mocker): """Actual host/bridge with labeled protected-state and C02/C03 unit ports. Local registry activation is test setup, not a durable SQL claim or launch @@ -83,27 +180,38 @@ def native_host_fixture(context): from services.dify_builder.native_testing.operation_journal import NodeOperationJournal from services.dify_builder.native_testing.runtime_bridge import NativePreparedInputs, NativeRuntimeBridge - state = Mock(load_context=Mock(return_value=context)) - authorization = Mock() + api = _api() + state = api.store + mocker.patch.object(state, "load_context", return_value=context) + authorization = EffectAuthorizationService(api.loader) lifetime = NativeHostLifetime(context=context, dispatch_id="gateway-unit-dispatch") registry = NativeHostLifetimeRegistry() registry.activate(lifetime=lifetime) + from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import ( + DurablePorts, + NoCheckpointLifecycle, + ) + + durable = DurablePorts() host = NativeHostLaunch( context=context, dispatch_id=lifetime.dispatch_id, lifetime=lifetime, journal=NodeOperationJournal(state=state, authorization=authorization), - checkpoints=NativeCheckpointBridge(state=state, lifecycle=Mock()), - fence_pending=Mock(), - stop_sealer=Mock(), + checkpoints=NativeCheckpointBridge(state=state, lifecycle=NoCheckpointLifecycle()), + fence_pending=durable.fence_pending, + stop_sealer=durable, authorization=authorization, http_baseline=(0.02, 0.02, 0.02), ) - prepare = Mock(return_value=NativePreparedInputs(inputs=context.inputs, files=(), environment_variables=())) + inputs = _InitialInputPreparation() + prepare = mocker.patch.object( + inputs, "prepare", return_value=NativePreparedInputs(inputs=context.inputs, files=(), environment_variables=()) + ) runtime = NativeRuntimeBridge( state=state, - collaborators=Mock(), - node_arguments=Mock(), + collaborators=_unexpected_collaborators, + node_arguments=_node_arguments, prepare_inputs=prepare, boundary_owner=host.boundary_owner, control_sink=host.control_sink, @@ -111,29 +219,25 @@ def native_host_fixture(context): return host, runtime, state, prepare -def service_fixture(): +def service_fixture(mocker): from services.dify_builder.native_testing.native_execution import NativeWorkflowExecutionService context = ResolvedNativeTestContext.model_validate(CONTEXT) - outcome = NativeWorkflowOutcome( - status="completed", - ref=context.ref, - native_run_id=context.native_run_id, - checkpoint_ref=None, - pending_operation_ids=(), - reason_code=None, - wait_reason=None, - native_status="failed", - output_result_ref=None, + host, bridge, state, _ = native_host_fixture(context, mocker) + owner = DifyNativeWorkflowOwner( + session_factory=sessionmaker(), + runtime=bridge, + stream_owner=_StreamResultPort(), + native_host=host, ) - state = Mock(load_context=Mock(return_value=context)) - owner = Mock(run=Mock(return_value=outcome), resume=Mock(return_value=outcome)) + mocker.patch.object(owner, "run", return_value=_outcome(context)) + mocker.patch.object(owner, "resume", return_value=_outcome(context)) return context, state, owner, NativeWorkflowExecutionService(state=state, native_owner=owner) -def test_run_uses_reloaded_typed_snapshot_and_exact_native_run_id(): - context, state, owner, service = service_fixture() - callback = Mock() +def test_run_uses_reloaded_typed_snapshot_and_exact_native_run_id(mocker): + context, state, owner, service = service_fixture(mocker) + callback = _on_event result = service.run(ref=context.ref, on_event=callback) assert result.native_status == "failed" assert owner.run.call_args.kwargs["context"] == context @@ -141,15 +245,15 @@ def test_run_uses_reloaded_typed_snapshot_and_exact_native_run_id(): state.load_context.assert_called_once_with(ref=context.ref) -def test_resume_checks_generation_before_native_owner(): - context, _, owner, service = service_fixture() +def test_resume_checks_generation_before_native_owner(mocker): + context, _, owner, service = service_fixture(mocker) with pytest.raises(NativeContextUnavailable, match="native_generation_conflict"): service.resume_worker(ref=context.ref, generation=1) owner.resume.assert_not_called() -def test_resume_reloads_state_each_time(): - context, state, owner, service = service_fixture() +def test_resume_reloads_state_each_time(mocker): + context, state, owner, service = service_fixture(mocker) service.resume_worker(ref=context.ref, generation=0) state.load_context.side_effect = ValueError("private credential value") with pytest.raises(NativeContextUnavailable, match="^native_context_unavailable$"): @@ -157,25 +261,26 @@ def test_resume_reloads_state_each_time(): assert owner.resume.call_count == 1 -def test_owner_cannot_replace_reserved_run_identity(): - context, _, owner, service = service_fixture() +def test_owner_cannot_replace_reserved_run_identity(mocker): + context, _, owner, service = service_fixture(mocker) owner.run.return_value = owner.run.return_value.model_copy(update={"native_run_id": "other-run"}) with pytest.raises(NativeContextUnavailable, match="native_run_identity_mismatch"): - service.run(ref=context.ref, on_event=Mock()) + service.run(ref=context.ref, on_event=_on_event) -def test_generator_refuses_missing_native_scope_before_file_or_config_work(monkeypatch): +def test_generator_refuses_missing_native_scope_before_file_or_config_work(mocker): from services.workflow_run_agg import WorkflowRunAgg from services.workflow_runtime import workflow_generator as app_generator generator = app_generator.WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run) - effect = Mock(side_effect=AssertionError("eager file I/O")) - monkeypatch.setattr(app_generator.file_factory, "build_from_mappings", effect) + effect = mocker.patch.object( + app_generator.file_factory, "build_from_mappings", side_effect=AssertionError("eager file I/O") + ) with pytest.raises(NativeContextUnavailable, match="native_context_missing"): generator.generate( - app_model=Mock(), - workflow=Mock(), - user=Mock(), + app_model=App(id="app", tenant_id="tenant", mode="workflow"), + workflow=Workflow(), + user=_account(), args={}, invoke_from=app_generator.InvokeFrom.DEBUGGER, streaming=True, @@ -185,10 +290,10 @@ def test_generator_refuses_missing_native_scope_before_file_or_config_work(monke effect.assert_not_called() -def test_worker_rejects_marker_before_payload_logger_or_ordinary_runner(monkeypatch): +def test_worker_rejects_marker_before_payload_logger_or_ordinary_runner(mocker): from tasks.app_generate import workflow_execute_task as module - context, _, _, _ = service_fixture() + context, _, _, _ = service_fixture(mocker) params = module.AppExecutionParams( app_id="app", workflow_id="workflow", @@ -199,18 +304,16 @@ def test_worker_rejects_marker_before_payload_logger_or_ordinary_runner(monkeypa native_test_required=True, native_test_ref=context.ref, ) - ordinary = Mock(side_effect=AssertionError("ordinary worker")) - logger = Mock() - monkeypatch.setattr(module, "_AppRunner", ordinary) - monkeypatch.setattr(module, "logger", logger) - monkeypatch.setattr(module, "db", Mock(engine=object())) + ordinary = mocker.patch.object(module._AppRunner, "run", side_effect=AssertionError("ordinary worker")) + logger = module.logger + mocker.patch.object(logger, "info") with pytest.raises(NativeContextUnavailable, match="native_runtime_unavailable"): module.workflow_based_app_execution_task(params.model_dump_json()) ordinary.assert_not_called() logger.info.assert_not_called() -def test_native_owner_calls_existing_generator_with_reserved_id_and_server_actor(owned, monkeypatch): +def test_native_owner_calls_existing_generator_with_reserved_id_and_server_actor(mocker, owned): from services.dify_builder.native_testing.native_execution import DifyNativeWorkflowOwner from services.dify_builder.native_testing.repository import native_workflow_revision from services.workflow_runtime import workflow_generator as app_generator @@ -228,23 +331,14 @@ def test_native_owner_calls_existing_generator_with_reserved_id_and_server_actor } ) stream = iter([{"event": "workflow_started"}]) - generator = Mock(return_value=stream) - monkeypatch.setattr(app_generator.WorkflowAppGenerator, "generate", generator) - host, runtime, _, _ = native_host_fixture(context) - result_owner = Mock() - - def consume(**kwargs): - actual_stream = kwargs["stream"] - try: - list(actual_stream) - finally: - actual_stream.finish_reader() - - result_owner.consume.side_effect = consume + generator = mocker.patch.object(app_generator.WorkflowAppGenerator, "generate", return_value=stream) + host, runtime, _, _ = native_host_fixture(context, mocker) + result_owner = _StreamResultPort() + mocker.spy(result_owner, "consume") owner = DifyNativeWorkflowOwner( session_factory=factory, runtime=runtime, stream_owner=result_owner, native_host=host ) - callback = Mock() + callback = _on_event owner.run(context=context, on_event=callback) kwargs = generator.call_args.kwargs assert kwargs["workflow_run_id"] == context.native_run_id @@ -258,25 +352,34 @@ def test_native_owner_calls_existing_generator_with_reserved_id_and_server_actor assert host.settlement_deadline is not None -def test_ordinary_resume_cannot_rebuild_trace_for_native_marked_entity(monkeypatch): +def test_ordinary_resume_cannot_rebuild_trace_for_native_marked_entity(mocker): from services.workflow_runtime import workflow_generator as app_generator - trace = Mock(side_effect=AssertionError("eager trace credentials")) - monkeypatch.setattr(app_generator, "TraceQueueManager", trace) + trace = mocker.patch( + "core.ops.ops_trace_manager.OpsTraceManager.get_ops_trace_instance", + side_effect=AssertionError("eager trace credentials"), + ) entity = app_generator.WorkflowAppGenerateEntity.model_construct( native_test_required=True, native_test_ref=None, builder_execution=None, trace_manager=None ) + from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository + from core.repositories.sqlalchemy_workflow_node_execution_repository import ( + SQLAlchemyWorkflowNodeExecutionRepository, + ) from services.workflow_run_agg import WorkflowRunAgg + workflow_repo = SQLAlchemyWorkflowExecutionRepository(sessionmaker(), "tenant", _account(), "app", None) + node_repo = SQLAlchemyWorkflowNodeExecutionRepository(sessionmaker(), "tenant", _account(), "app", None) + def resume(): app_generator.WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run).resume( - app_model=Mock(), - workflow=Mock(), - user=Mock(), + app_model=App(id="app", tenant_id="tenant", mode="workflow"), + workflow=Workflow(), + user=_account(), application_generate_entity=entity, - graph_runtime_state=Mock(), - workflow_execution_repository=Mock(), - workflow_node_execution_repository=Mock(), + graph_runtime_state=RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0), + workflow_execution_repository=workflow_repo, + workflow_node_execution_repository=node_repo, ) with pytest.raises(NativeContextUnavailable, match="native_context_missing"): @@ -285,12 +388,11 @@ def test_ordinary_resume_cannot_rebuild_trace_for_native_marked_entity(monkeypat @pytest.mark.parametrize("ending", ["complete", "close", "error", "startup_error"]) -def test_admitted_native_gateway_retains_rate_release_until_host_cleanup(owned, monkeypatch, ending): +def test_admitted_native_gateway_retains_rate_release_until_host_cleanup(mocker, owned, monkeypatch, ending): from core.app.features.rate_limiting.rate_limit import RateLimit from services import app_generate_service as module from services.dify_builder.native_testing.repository import native_workflow_revision from services.workflow_runtime import workflow_generator as app_generator - from tests.unit_tests.services.test_app_generate_service import _DummyRateLimit factory, actor, app, workflow, _, _ = owned context = ResolvedNativeTestContext.model_validate( @@ -307,14 +409,9 @@ def test_admitted_native_gateway_retains_rate_release_until_host_cleanup(owned, exits = [] closed = [] - class TrackingLimit(_DummyRateLimit): - @override - def generate(self, generator, request_id: str): - return RateLimit.generate(cast(RateLimit, self), generator, request_id) - - @override - def exit(self, request_id: str) -> None: - exits.append(request_id) + limiter = RateLimit(app.id, 0) + mocker.patch.object(limiter, "enter", return_value="native-request") + mocker.patch.object(limiter, "exit", side_effect=exits.append) def source(): try: @@ -325,17 +422,14 @@ def test_admitted_native_gateway_retains_rate_release_until_host_cleanup(owned, finally: closed.append(True) - monkeypatch.setattr(module, "RateLimit", TrackingLimit) - monkeypatch.setattr(module.AppGenerateService, "_get_max_active_requests", lambda _: 10) - monkeypatch.setattr( + monkeypatch.setattr(module.AppGenerateService, "_get_max_active_requests", lambda _: 0) + mocker.patch.object( app_generator.WorkflowAppGenerator, "generate", - Mock( - side_effect=RuntimeError("startup") if ending == "startup_error" else None, - return_value=source(), - ), + side_effect=RuntimeError("startup") if ending == "startup_error" else None, + return_value=source(), ) - host, runtime, _, _ = native_host_fixture(context) + host, runtime, _, _ = native_host_fixture(context, mocker) with factory() as session: from models.account import Account @@ -371,11 +465,11 @@ def test_admitted_native_gateway_retains_rate_release_until_host_cleanup(owned, @pytest.mark.parametrize("refusal", ["reload", "inputs"]) -def test_in_process_worker_refusal_terminates_native_queue_without_ordinary_setup(owned, monkeypatch, refusal): - _worker_refusal_case(owned, monkeypatch, refusal=refusal, redis_failure=False) +def test_in_process_worker_refusal_terminates_native_queue_without_ordinary_setup(mocker, owned, monkeypatch, refusal): + _worker_refusal_case(owned, monkeypatch, mocker, refusal=refusal, redis_failure=False) -def _worker_refusal_case(owned, monkeypatch, *, refusal, redis_failure): +def _worker_refusal_case(owned, monkeypatch, mocker, *, refusal, redis_failure): import contextvars from flask import Flask @@ -401,8 +495,7 @@ def _worker_refusal_case(owned, monkeypatch, *, refusal, redis_failure): "graph": workflow.graph_dict, } ) - host, bridge, state, prepare = native_host_fixture(context) - from types import SimpleNamespace + host, bridge, state, prepare = native_host_fixture(context, mocker) from tests.unit_tests.services.dify_builder.native_testing.test_native_observation_wiring import ( bind_component_observation, @@ -411,16 +504,14 @@ def _worker_refusal_case(owned, monkeypatch, *, refusal, redis_failure): # Structural component guard only: this fixture supplies no persisted # HTTP profile, SQL claim, transport or production qualification proof. - guard = bind_component_observation(SimpleNamespace(host=host), monkeypatch) + guard = bind_component_observation(_HostObservationCase(host=host), monkeypatch) output = protect_logs(monkeypatch) state.load_context.reset_mock() from services.workflow_run_agg import WorkflowRunAgg generator = app_generator.WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run) - launch = Mock() - monkeypatch.setattr(generator, "_generate", launch) - monkeypatch.setattr(app_generator, "db", Mock(engine=factory.kw["bind"])) - monkeypatch.setattr(app_generator, "DifyCoreRepositoryFactory", Mock()) + launch = mocker.patch.object(generator, "_generate") + monkeypatch.setattr(app_generator, "db", _NativeDatabase(engine=factory.kw["bind"])) generator.generate( app_model=app, workflow=workflow, @@ -445,11 +536,13 @@ def _worker_refusal_case(owned, monkeypatch, *, refusal, redis_failure): else: prepare.side_effect = NativeContextUnavailable("native_input_adapter_unavailable") reason = "native_input_adapter_unavailable" - redis_port = Mock() + redis_port = Redis() + mocker.patch.object(redis_port, "execute_command", return_value=None) + deletion = mocker.spy(redis_port, "delete") if redis_failure: from redis.exceptions import RedisError - redis_port.delete.side_effect = RedisError("private-redis-delete-canary") + deletion.side_effect = RedisError("private-redis-delete-canary") monkeypatch.setattr(base_app_queue_manager, "redis_client", redis_port) queue = WorkflowAppQueueManager( entity.task_id, actor.account_id, entity.invoke_from, "workflow", native_observation=guard @@ -472,10 +565,12 @@ def _worker_refusal_case(owned, monkeypatch, *, refusal, redis_failure): node_repository=node_repo, ) monkeypatch.setattr(queue, "_is_stopped", lambda: False) - eager = Mock(side_effect=AssertionError("ordinary SQL/setup")) - runner = Mock(side_effect=AssertionError("ordinary runner")) - monkeypatch.setattr(app_generator.session_factory, "create_session", eager) - monkeypatch.setattr(app_generator, "WorkflowAppRunner", runner) + eager = mocker.patch.object( + app_generator.session_factory, "create_session", side_effect=AssertionError("ordinary SQL/setup") + ) + runner = mocker.patch.object( + app_generator.WorkflowAppRunner, "prepare", side_effect=AssertionError("ordinary runner") + ) from threading import Thread worker_arguments = { @@ -483,7 +578,7 @@ def _worker_refusal_case(owned, monkeypatch, *, refusal, redis_failure): "application_generate_entity": entity, "queue_manager": queue, "context": contextvars.copy_context(), - "variable_loader": Mock(), + "variable_loader": DUMMY_VARIABLE_LOADER, "workflow_execution_repository": workflow_repo, "workflow_node_execution_repository": node_repo, "native_runtime": bridge, @@ -534,32 +629,39 @@ def _worker_refusal_case(owned, monkeypatch, *, refusal, redis_failure): runner.assert_not_called() -def test_early_worker_revocation_redis_cleanup_cannot_log_private_payload(owned, monkeypatch): - _worker_refusal_case(owned, monkeypatch, refusal="reload", redis_failure=True) +def test_early_worker_revocation_redis_cleanup_cannot_log_private_payload(mocker, owned, monkeypatch): + _worker_refusal_case(owned, monkeypatch, mocker, refusal="reload", redis_failure=True) @pytest.mark.parametrize("invalid_host", [None, object()]) -def test_native_owner_requires_explicit_actual_host(invalid_host): +def test_native_owner_requires_explicit_actual_host(mocker, invalid_host): from services.dify_builder.native_testing.native_execution import DifyNativeWorkflowOwner with pytest.raises(NativeContextUnavailable, match="native_host_unavailable"): - DifyNativeWorkflowOwner(session_factory=Mock(), runtime=Mock(), stream_owner=Mock(), native_host=invalid_host) + DifyNativeWorkflowOwner( + session_factory=sessionmaker(), + runtime=native_host_fixture(ResolvedNativeTestContext.model_validate(CONTEXT), mocker)[1], + stream_owner=_StreamResultPort(), + native_host=invalid_host, + ) -def test_native_owner_rejects_foreign_host_before_sql_or_generator(): +def test_native_owner_rejects_foreign_host_before_sql_or_generator(mocker): from services.dify_builder.native_testing.native_execution import DifyNativeWorkflowOwner context = ResolvedNativeTestContext.model_validate(CONTEXT) - host, _, _, _ = native_host_fixture(context) - _, foreign_runtime, _, _ = native_host_fixture(context) - sessions = Mock(side_effect=AssertionError("eager SQL")) - stream_owner = Mock() + host, _, _, _ = native_host_fixture(context, mocker) + _, foreign_runtime, _, _ = native_host_fixture(context, mocker) + sessions = _ObservedSessionFactory() + stream_owner = _StreamResultPort() + mocker.spy(stream_owner, "consume") + mocker.spy(stream_owner, "resume") owner = DifyNativeWorkflowOwner( session_factory=sessions, runtime=foreign_runtime, stream_owner=stream_owner, native_host=host ) with pytest.raises(NativeContextUnavailable, match="native_host_identity_mismatch"): - owner.run(context=context, on_event=Mock()) - sessions.assert_not_called() + owner.run(context=context, on_event=_on_event) + assert sessions.opens == 0 stream_owner.consume.assert_not_called() with pytest.raises(NativeContextUnavailable, match="native_host_identity_mismatch"): owner.resume(context=context, generation=context.generation) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_observation_wiring.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_observation_wiring.py index 7bd6883350f..a70260dd761 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_observation_wiring.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_observation_wiring.py @@ -9,21 +9,25 @@ from __future__ import annotations import importlib import importlib.util import io +import json import logging +import sys from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from contextvars import ContextVar from threading import current_thread from time import monotonic from typing import TypedDict, cast -from unittest.mock import Mock import pytest from flask import Flask, current_app, has_app_context +from sqlalchemy.orm import Session from context.flask_app_context import capture_flask_context from graphon.runtime import VariablePool from libs import sensitive_http_telemetry as telemetry +from models.workflow import Workflow +from services.dify_builder.native_testing.authorization import EffectAuthorizationService from services.dify_builder.native_testing.checkpoint_bridge import NativeCheckpointBridge from services.dify_builder.native_testing.host_execution import NativeHostLaunch from services.dify_builder.native_testing.host_lifetime import NativeHostLifetime, NativeHostLifetimeRegistry @@ -37,6 +41,10 @@ from tests.unit_tests.services.dify_builder.native_testing.test_observation_priv from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import context as sample_context +def _refuse_protected_input(**_kwargs): + raise AssertionError("protected input effect must not begin") + + def contexts(): name = "services.dify_builder.native_testing.context_runtime" assert importlib.util.find_spec(name) is not None, "bounded native execution context is missing" @@ -45,6 +53,11 @@ def contexts(): def protect_logs(monkeypatch): """Test-only application sink setup; production never installs/toggles filters.""" + from services.dify_builder.native_testing.context_runtime import NATIVE_LOG_EMITTERS + from services.dify_builder.native_testing.observation_repository import _EMITTERS + from tests.unit_tests.logging_helpers import protect_existing_log_routes + + protect_existing_log_routes(monkeypatch, emitters=telemetry._HTTP_LOGGERS + NATIVE_LOG_EMITTERS + _EMITTERS) output = io.StringIO() handler = logging.StreamHandler(output) handler.addFilter(telemetry.SensitiveHttpLogFilter()) @@ -60,19 +73,25 @@ def component_guard(*, on_failure=lambda: None): def qualified_host(case): - authorization = Mock() + authorization = EffectAuthorizationService(case.store.loader) journal = NodeOperationJournal(state=case.store, authorization=authorization) life = NativeHostLifetime(context=case.context, dispatch_id="initial-observation") registry = NativeHostLifetimeRegistry() registry.activate(lifetime=life) # Fixture handoff, no durable claim proof. + from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import ( + DurablePorts, + NoCheckpointLifecycle, + ) + + durable = DurablePorts() host = NativeHostLaunch( context=case.context, dispatch_id=life.dispatch_id, lifetime=life, journal=journal, - checkpoints=NativeCheckpointBridge(state=case.store, lifecycle=Mock()), - fence_pending=Mock(), - stop_sealer=Mock(), + checkpoints=NativeCheckpointBridge(state=case.store, lifecycle=NoCheckpointLifecycle()), + fence_pending=durable.fence_pending, + stop_sealer=durable, authorization=authorization, http_baseline=(1, 1, 1), ) @@ -197,7 +216,6 @@ def bind_component_observation(case, monkeypatch): @pytest.mark.parametrize("feature", ["tts", "files", "trace", "hitl"]) def test_initial_preflight_refuses_unqualified_routes_before_worker(monkeypatch, feature): - from types import SimpleNamespace from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import LaunchCase from tests.unit_tests.services.dify_builder.native_testing.test_host_generation_wiring import generate_fixture @@ -206,9 +224,9 @@ def test_initial_preflight_refuses_unqualified_routes_before_worker(monkeypatch, generator, arguments = generate_fixture(case, monkeypatch) bind_component_observation(case, monkeypatch) entity = arguments["application_generate_entity"] - arguments["workflow"] = SimpleNamespace(features_dict={}) + arguments["workflow"] = Workflow(features="{}") if feature == "tts": - arguments["workflow"].features_dict = {"text_to_speech": {"enabled": True}} + arguments["workflow"].features = json.dumps({"text_to_speech": {"enabled": True}}) elif feature == "files": entity.files = [object()] elif feature == "trace": @@ -225,7 +243,6 @@ def test_initial_preflight_refuses_unqualified_routes_before_worker(monkeypatch, def test_actual_generate_passes_same_guard_to_queue_and_pipeline_before_worker(monkeypatch): from threading import Event - from types import SimpleNamespace from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import LaunchCase from tests.unit_tests.services.dify_builder.native_testing.test_host_generation_wiring import generate_fixture @@ -233,7 +250,7 @@ def test_actual_generate_passes_same_guard_to_queue_and_pipeline_before_worker(m case = LaunchCase() generator, arguments = generate_fixture(case, monkeypatch) guard = bind_component_observation(case, monkeypatch) - arguments["workflow"] = SimpleNamespace(features_dict={}) + arguments["workflow"] = Workflow(features="{}") seen = [] done = Event() @@ -316,6 +333,8 @@ def supported_engine(*, kind, mode, monkeypatch, commands=None, graph=None): import httpx + from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom + from core.workflow.node_runtime import DifyFileReferenceFactory from graphon.engine import Engine from graphon.engine.command import InMemoryChannel from graphon.entities.base_node_data import BaseNodeData @@ -396,6 +415,15 @@ def supported_engine(*, kind, mode, monkeypatch, commands=None, graph=None): get = head = post = put = delete = patch = request + file_references = DifyFileReferenceFactory( + DifyRunContext( + tenant_id=context.tenant_id, + app_id=context.app_id, + user_id=context.actor_id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ) + ) originals = (StartNode, EndNode, HttpRequestNode, IterationNode, IterationStartNode, LoopNode, LoopStartNode) @dataclass @@ -426,7 +454,7 @@ def supported_engine(*, kind, mode, monkeypatch, commands=None, graph=None): "http_client": Client(), "file_manager": RefusingNativeHttpFiles(), "tool_file_manager_factory": lambda: None, - "file_reference_factory": Mock(), + "file_reference_factory": file_references, } return cast(Callable[..., Node[BaseNodeData]], cls)( node_id=node_config["id"], @@ -518,8 +546,7 @@ def test_actual_supported_loop_dispatcher_condition_error_is_protected(monkeypat lease.release_after_exit() -def test_actual_initial_entry_rejects_persisted_profile_before_input_effect(case, monkeypatch): - from types import SimpleNamespace +def test_actual_initial_entry_rejects_persisted_profile_before_input_effect(case, monkeypatch, mocker): from core.app.entities.app_invoke_entities import InvokeFrom from models.account import Account @@ -530,13 +557,13 @@ def test_actual_initial_entry_rejects_persisted_profile_before_input_effect(case protect_logs(monkeypatch) host = qualified_host(case) - prepare = Mock(side_effect=AssertionError("protected input effect must not begin")) + prepare = mocker.spy(sys.modules[__name__], "_refuse_protected_input") bridge = NativeRuntimeBridge( state=case.store, - collaborators=Mock(), + collaborators=_refuse_protected_input, boundary_owner=host.boundary_owner, control_sink=host.control_sink, - node_arguments=Mock(), + node_arguments=_refuse_protected_input, prepare_inputs=prepare, ) context = case.context @@ -549,13 +576,13 @@ def test_actual_initial_entry_rejects_persisted_profile_before_input_effect(case return actual_factory(**kwargs) monkeypatch.setattr(observation_owner, "create_http_observation_guard", changed_profile) + account = Account(name="Native actor", email="native@example.test") + account.id = context.actor_id with pytest.raises(NativeObservationError, match="^native_observation_failed$"): WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run).generate( - app_model=cast(App, SimpleNamespace(id=context.app_id, tenant_id=context.tenant_id)), - workflow=cast( - Workflow, SimpleNamespace(id=context.workflow_id, graph_dict=context.graph, features_dict={}) - ), - user=cast(Account, SimpleNamespace(id=context.actor_id)), + app_model=App(id=context.app_id, tenant_id=context.tenant_id), + workflow=Workflow(id=context.workflow_id, graph=json.dumps(context.graph), features="{}"), + user=account, args={"inputs": context.inputs}, streaming=True, call_depth=0, @@ -597,7 +624,7 @@ def test_actual_handle_response_builds_same_guard_pipeline_and_converter(monkeyp native_observation=guard, ) assert len(seen) == 1 - saver.assert_not_called() + assert saver.calls == [] def test_same_guard_rechecks_exact_state_without_holding_host_lock(case, monkeypatch): @@ -627,7 +654,11 @@ def test_same_guard_rechecks_exact_state_without_holding_host_lock(case, monkeyp assert host.native_observation is guard assert locks assert host._journal is not None - host._journal._state = Mock(load_context=lambda **_kwargs: case.context) + from services.dify_builder.native_testing.state_store import NativeExecutionStateStore + + other_state = NativeExecutionStateStore(case.store.loader) + monkeypatch.setattr(other_state, "load_context", lambda **_kwargs: case.context) + host._journal._state = other_state with pytest.raises(NativeObservationError): _ = host.native_observation assert host.lifetime.unknown @@ -741,7 +772,6 @@ def test_actual_worker_replaces_raw_error_before_queue_error_model_and_logging(m import contextvars from contextlib import nullcontext from threading import Thread - from types import SimpleNamespace from pydantic import BaseModel, ValidationError @@ -769,10 +799,10 @@ def test_actual_worker_replaces_raw_error_before_queue_error_model_and_logging(m workflow_repository=workflow_repo, node_repository=node_repo, ) - workflow = SimpleNamespace(graph_dict=case.state.context.graph) - monkeypatch.setattr( - module.session_factory, "create_session", lambda: nullcontext(SimpleNamespace(scalar=lambda _query: workflow)) - ) + workflow = Workflow(graph=json.dumps(case.state.context.graph)) + session = Session() + monkeypatch.setattr(session, "scalar", lambda _query: workflow) + monkeypatch.setattr(module.session_factory, "create_session", lambda: session) published = [] actual_publish = queue.publish_error @@ -833,16 +863,23 @@ def test_actual_worker_replaces_raw_error_before_queue_error_model_and_logging(m def test_initial_host_binds_current_profile_without_native_activity(case, monkeypatch): """Actual pre-claim observation composition, labeled owner/object doubles.""" protect_logs(monkeypatch) - authorization = Mock() + authorization = EffectAuthorizationService(case.store.loader) lifetime = NativeHostLifetime(context=case.context, dispatch_id="initial-observation") + from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import ( + DurablePorts, + NoCheckpointLifecycle, + ) + + durable = DurablePorts() + host = NativeHostLaunch( context=case.context, dispatch_id=lifetime.dispatch_id, lifetime=lifetime, journal=NodeOperationJournal(state=case.store, authorization=authorization), - checkpoints=NativeCheckpointBridge(state=case.store, lifecycle=Mock()), - fence_pending=Mock(), - stop_sealer=Mock(), + checkpoints=NativeCheckpointBridge(state=case.store, lifecycle=NoCheckpointLifecycle()), + fence_pending=durable.fence_pending, + stop_sealer=DurablePorts(), authorization=authorization, http_baseline=(1, 1, 1), ) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_resume_scheduling.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_resume_scheduling.py index 943d1a4edd6..b59e6d150f5 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_resume_scheduling.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_resume_scheduling.py @@ -10,7 +10,6 @@ from copy import deepcopy from datetime import UTC, datetime from threading import Thread, current_thread from time import monotonic -from types import SimpleNamespace import httpx import pytest @@ -32,10 +31,17 @@ from services.dify_builder.native_testing.http_binding import ( describe_http_resource, native_http_config, ) +from services.dify_builder.native_testing.lifecycle import NativeDispatchRef, NativeTestLifecycleService from services.dify_builder.native_testing.observation_adapters import NativeObservationGuard from services.dify_builder.native_testing.observation_privacy import NativeHttpObservationPolicy from services.dify_builder.native_testing.operation_journal import NodeOperationJournal -from services.dify_builder.native_testing.runtime import build_delivery_bridge +from services.dify_builder.native_testing.private_reads import PrivateReadOwner +from services.dify_builder.native_testing.repository import OwnedNativeTest +from services.dify_builder.native_testing.runtime import ( + DifyNativeResumeSettlement, + DifyNativeRuntime, + build_delivery_bridge, +) from services.workflow_runtime.entry import WorkflowEntry from services.workflow_runtime.node_factory import DifyGraphInitContext, DifyNodeFactory from tests.unit_tests.services.dify_builder.native_testing.test_call_adapter import REF, Authorization, State @@ -48,6 +54,7 @@ from tests.unit_tests.services.dify_builder.native_testing.test_http_adapter imp protected_application_logs as protected_application_logs, # noqa: PLC0414 - real logging fixture ) from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import context +from tests.unit_tests.services.dify_builder.native_testing.test_runtime_bridge import _api class SchedulingState(State): @@ -101,6 +108,44 @@ class SchedulingAuthorization: return ledger.record_outcome(permit=permit, outcome=outcome, result_ref=result_ref) +class SchedulingHostCase: + """Objects for one real Graphon scheduling generation and its host ports.""" + + runtime: NativeGraphRuntimeState + entry: WorkflowEntry + journal: NodeOperationJournal + + def __init__( + self, + *, + state, + host: NativeHostLaunch, + lifetime: NativeHostLifetime, + registry: NativeHostLifetimeRegistry, + ports: DurablePorts, + ): + self.state, self.host, self.lifetime = state, host, lifetime + self.registry, self.ports = registry, ports + + +def scheduling_owner(state): + context = state.context + return OwnedNativeTest( + ref=context.ref, + tenant_id=context.tenant_id, + actor_id=context.actor_id, + app_id=context.app_id, + workflow_id=context.workflow_id, + session_id="session", + test_input_id="input", + bindings={}, + generation=context.generation, + task_approved=True, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="active", + ) + + def make_host(state, authorization, monkeypatch, *, snapshot=None): journal = NodeOperationJournal(state=state, authorization=authorization) if snapshot is not None: @@ -123,7 +168,7 @@ def make_host(state, authorization, monkeypatch, *, snapshot=None): authorization=authorization, http_baseline=(timeout.connect, timeout.read, timeout.write), ) - case = SimpleNamespace(state=state, host=host, lifetime=lifetime, registry=registry, ports=ports) + case = SchedulingHostCase(state=state, host=host, lifetime=lifetime, registry=registry, ports=ports) _, _, response_filter, _, _ = bind_generation(case, monkeypatch) guard = NativeObservationGuard( policy=NativeHttpObservationPolicy.qualify(context=state.context, profile=build_native_http_profile()), @@ -142,21 +187,20 @@ def make_host(state, authorization, monkeypatch, *, snapshot=None): else NativeGraphRuntimeState.from_snapshot(snapshot.graph_runtime_state_json) ) runtime.variable_pool.add(("start", "items"), ["one", "two"]) - bridge = build_delivery_bridge( - runtime=SimpleNamespace( - # Mirrors the real runtime default; Code binding stays off here. - code_qualification=None, - host=host, - lifecycle=SimpleNamespace( - store=state, - loader=SimpleNamespace( - load=lambda **_: SimpleNamespace(expires_at=datetime(2030, 1, 1, tzinfo=UTC)) - ), - ), - authorization=authorization, - _journal=journal, - ) + api = _api() + monkeypatch.setattr(api.loader, "load", lambda **_: scheduling_owner(state)) + lifecycle = NativeTestLifecycleService( + api.loader, state_store=state, resume_settlement=DifyNativeResumeSettlement() ) + delivery_runtime = DifyNativeRuntime( + delivery=NativeDispatchRef(REF, state.context.generation, dispatch_id), + lifecycle=lifecycle, + registry=registry, + ) + delivery_runtime.host = host + delivery_runtime.authorization = authorization + delivery_runtime._journal = journal + bridge = build_delivery_bridge(runtime=delivery_runtime) run_context = DifyRunContext( tenant_id=state.context.tenant_id, app_id=state.context.app_id, @@ -277,7 +321,22 @@ def test_actual_http_pause_restore_dispatches_same_waiting_intent_once(monkeypat monkeypatch.setattr(httpx.Client, "send", send) monkeypatch.setattr( "services.dify_builder.native_testing.http_binding.validate_private_read", - lambda *_: SimpleNamespace(generation=state.context.generation), + lambda *_: PrivateReadOwner( + tenant_id=state.context.tenant_id, + actor_id=state.context.actor_id, + app_id=state.context.app_id, + workflow_id=state.context.workflow_id, + parent_invocation_id=None, + context_object_id="context", + generation=state.context.generation, + graph_revision=state.context.graph_revision, + root_revision_matches=True, + input_revision=None, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + cancelled=False, + active=True, + request_state="running", + ), ) monkeypatch.setattr(NativeHttpBindingProducer, "_install", lambda *_, **__: None) first = make_host(state, authorization, monkeypatch) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_runtime.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_runtime.py index 9b463da6e45..5a6615c5117 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_runtime.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_runtime.py @@ -1,20 +1,77 @@ """Actual lifecycle routing; SQL boundary doubles do not establish durability.""" +from __future__ import annotations + import json +from collections import UserList from contextlib import contextmanager -from types import SimpleNamespace -from typing import cast, override +from dataclasses import dataclass +from datetime import UTC, datetime +from threading import local +from typing import TYPE_CHECKING, cast, override import pytest +from sqlalchemy import inspect from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.sql.elements import BinaryExpression, ColumnClause +from core.dify_builder.models import Actor +from models.dify_builder_native_tests import NativeDispatch, NativeInvocationState from runtime_contracts.builder.native.runtime_contracts import NativeWorkflowOutcome +from services.dify_builder.native_testing.http_binding import NativeHttpBindingResolver from services.dify_builder.native_testing.lifecycle import NativeRuntimePort, NativeTestLifecycleService -from services.dify_builder.native_testing.repository import OwnedNativeTestLoader +from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader from services.dify_builder.native_testing.state_store import NativeExecutionStateStore from tests.unit_tests.services.dify_builder.native_testing.test_observation_owner import case from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import context as sample_context +if TYPE_CHECKING: + from collections.abc import Callable + from contextlib import AbstractContextManager + + from services.dify_builder.native_testing.api_service import NativeTestApiService + from services.dify_builder.native_testing.host_execution import NativeHostLaunch + from services.dify_builder.native_testing.public_contracts import NativeTestPublicReceipt + from services.dify_builder.native_testing.runtime import DifyNativeRuntimeOwner + from tests.unit_tests.services.dify_builder.native_testing.test_host_capacity import SQL + + +@dataclass +class RestoredHostCase: + host: NativeHostLaunch + + +class ImpostorResumeHost: + def require_resume_bindings(self, **_kwargs): + return True + + +class HistoryQueryRows(UserList): + def scalars(self): + return self + + def first(self): + return self[0] if self else None + + +@dataclass +class CancellationCase: + api: NativeTestApiService + owner: DifyNativeRuntimeOwner + published: list + sql: SQL + sessions: Callable[[], AbstractContextManager[SQL]] + actor: Actor + started: NativeTestPublicReceipt + rows: dict + + +class _SqlDepth(local): + def __init__(self) -> None: + self.value = 0 + self.writers = 0 + + __all__ = ["case"] @@ -56,11 +113,14 @@ def test_initial_dispatch_uses_no_checkpoint_and_duplicate_delivery_never_runs_a del kwargs yield None, None, None + loader = OwnedNativeTestLoader( + lambda: pytest.fail("initial checkpoint SQL"), + actor=Actor(tenant_id=context.tenant_id, account_id=context.actor_id), + resolver=NativeHttpBindingResolver(), + ) + monkeypatch.setattr(loader, "locked", locked) lifecycle = NativeTestLifecycleService( - cast( - OwnedNativeTestLoader, - SimpleNamespace(locked=locked, session_factory=lambda: pytest.fail("initial checkpoint SQL")), - ), + loader, state_store=cast(NativeExecutionStateStore, Store()), runtime=cast(NativeRuntimePort, Runtime()), ) @@ -85,7 +145,6 @@ def test_initial_dispatch_uses_no_checkpoint_and_duplicate_delivery_never_runs_a @pytest.mark.parametrize(("dispatch_state", "cleaned"), [("claimed", True), ("completed", False), ("unknown", True)]) def test_resume_settlement_refuses_committed_wait_before_actual_finalization(dispatch_state, cleaned): from models.dify_builder import DifyBuilderExecutionRequest - from models.dify_builder_native_tests import NativeDispatch from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import CheckpointRef from services.dify_builder.native_testing.http_binding import build_native_http_profile @@ -93,17 +152,40 @@ def test_resume_settlement_refuses_committed_wait_before_actual_finalization(dis from tests.unit_tests.services.dify_builder.native_testing.test_host_capacity import SQL context = sample_context() - owned = SimpleNamespace( + owned = OwnedNativeTest( + session_id="session", + test_input_id="input", + bindings={}, + generation=0, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", ref=context.ref, tenant_id=context.tenant_id, actor_id=context.actor_id, app_id=context.app_id, workflow_id=context.workflow_id, ) - state = SimpleNamespace(generation=0, cleanup_complete=cleaned, checkpoint_id="checkpoint") + state = NativeInvocationState( + generation=0, + cleanup_complete=cleaned, + checkpoint_id="checkpoint", + tenant_id=context.tenant_id, + request_id=context.ref.request_id, + invocation_id=context.ref.invocation_id, + context_object_id="context", + state="waiting", + pending_ids=[], + resolution=[], + ) rows = { DifyBuilderExecutionRequest: [ - SimpleNamespace( + DifyBuilderExecutionRequest( + app_id=context.app_id, + workflow_id=context.workflow_id, + session_id="session", + test_input_id="input", + context_digest=context.ref.context_digest, id=context.ref.request_id, tenant_id=context.tenant_id, actor_id=context.actor_id, @@ -111,7 +193,8 @@ def test_resume_settlement_refuses_committed_wait_before_actual_finalization(dis ) ], NativeDispatch: [ - SimpleNamespace( + NativeDispatch( + idempotency_digest="a" * 64, tenant_id=context.tenant_id, request_id=context.ref.request_id, invocation_id=context.ref.invocation_id, @@ -195,8 +278,13 @@ def test_actual_wait_snapshot_restores_exact_objects_journal_and_generation_gap( assert payload is not None state, auth = initial.case.state, initial.case.authorization state.context = state.context.model_copy(update={"generation": generation}) + loader = OwnedNativeTestLoader( + initial.sessions, + actor=Actor(tenant_id=state.context.tenant_id, account_id=state.context.actor_id), + resolver=NativeHttpBindingResolver(), + ) lifecycle = NativeTestLifecycleService( - cast(OwnedNativeTestLoader, SimpleNamespace(session_factory=initial.sessions)), + loader, state_store=cast(NativeExecutionStateStore, state), resume_settlement=DifyNativeResumeSettlement(), ) @@ -227,7 +315,7 @@ def test_actual_wait_snapshot_restores_exact_objects_journal_and_generation_gap( authorization=auth, http_baseline=(1, 1, 1), ) - bind_component_observation(SimpleNamespace(host=host), monkeypatch) + bind_component_observation(RestoredHostCase(host=host), monkeypatch) restored = host.restore_claimed_resume(lifecycle=lifecycle, expected_payload=payload) assert restored.entity.workflow_execution_id == payload.native_run_id assert restored.entity.native_test_ref == state.context.ref @@ -337,20 +425,23 @@ def test_cleanup_only_log_release_refuses_live_dispatcher_and_keeps_original_lea def test_resume_history_refuses_fake_host_before_any_repository_cache_or_io(): - from unittest.mock import Mock - from runtime_contracts.builder.execution import BuilderExecutionPolicyError from services.dify_builder.native_testing.runtime import read_claimed_history - repository = Mock(side_effect=AssertionError("history IO before actual host")) + touched = [] + + def repository(*args, **kwargs): + touched.append((args, kwargs)) + raise AssertionError("history IO before actual host") + with pytest.raises(BuilderExecutionPolicyError, match="native_resume_owner_mismatch"): read_claimed_history( - host=SimpleNamespace(require_resume_bindings=lambda **_kw: True), + host=ImpostorResumeHost(), entity=object(), workflow_repository=repository, node_repository=repository, ) - assert repository.mock_calls == [] + assert touched == [] @pytest.mark.parametrize( @@ -390,10 +481,10 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk The separate CI witness runs the actual SQL owners and operation approval. """ from copy import deepcopy - from unittest.mock import Mock import httpx from flask import Flask + from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm import sessionmaker from core.app.features.rate_limiting import RateLimit @@ -428,7 +519,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk original_diagnostic = NativeHostLaunch.diagnostic_event def diagnostic(host, *, event): - maps = [(name, getattr(event, name, None)) for name in ("metadata", "execution_metadata")] + maps = [(name, dict(event).get(name)) for name in ("metadata", "execution_metadata")] for name, values in maps: if type(values) is dict and type(values.get("total_price")) is Decimal: before = event.model_copy(deep=True) @@ -450,9 +541,9 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk observed = original_diagnostic(host, event=event) assert event == before assert type(values["total_price"]) is Decimal - assert type(getattr(observed, name)["total_price"]) is int - assert getattr(observed, name)["total_price"] == 0 - assert {k: v for k, v in getattr(observed, name).items() if k != "total_price"} == { + assert type(dict(observed)[name]["total_price"]) is int + assert dict(observed)[name]["total_price"] == 0 + assert {k: v for k, v in dict(observed)[name].items() if k != "total_price"} == { k: v for k, v in values.items() if k != "total_price" } normalized.append(kind) @@ -610,21 +701,18 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk @override def execute(self, _query) -> Rows: values = self.scalars(_query) - return cast( - Rows, SimpleNamespace(scalars=lambda: SimpleNamespace(first=lambda: values[0] if values else None)) - ) + return HistoryQueryRows(values) history = HistorySQL(case.rows, []) # The reused single-reader fixture's global SQL depth is unsuitable for # actual concurrent workers. This boundary retains per-thread no-IO-in-SQL # assertions; it does not replace the production ownership reads. - from threading import local - sql_depth = local() + sql_depth = _SqlDepth() @contextmanager def concurrent_sessions(): - sql_depth.value = getattr(sql_depth, "value", 0) + 1 + sql_depth.value = sql_depth.value + 1 try: yield SQL(case.rows, case.events) finally: @@ -648,7 +736,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk case.local.cleanup_complete = False def permission(actor, app_id, sessions): - assert getattr(sql_depth, "value", 0) == 0 + assert sql_depth.value == 0 assert actor == case.loader.actor assert app_id == context.app_id assert sessions is concurrent_sessions @@ -661,7 +749,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk monkeypatch.setattr(permission_path, permission) def protected_context(row): - assert getattr(sql_depth, "value", 0) == 0 + assert sql_depth.value == 0 assert row.id == "context" return deepcopy(case.context.model_dump(mode="json")) @@ -695,13 +783,13 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk lambda **kw: repositories(node=True, **kw), ) monkeypatch.setattr(generator_module.session_factory, "create_session", lambda: history) - monkeypatch.setattr( - generator_module, "db", SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)) - ) + database = SQLAlchemy() + monkeypatch.setattr(generator_module, "db", database) monkeypatch.setattr(generator_module, "sessionmaker", lambda **_kw: sessionmaker()) from core.app.apps import base_app_queue_manager + from tests.unit_tests.services.dify_builder.native_testing.test_observation_stream import bind_queue_cache - monkeypatch.setattr(base_app_queue_manager, "redis_client", Mock()) + bind_queue_cache(monkeypatch) monkeypatch.setattr(base_app_queue_manager.AppQueueManager, "_is_stopped", lambda _self: False) monkeypatch.setattr(RateLimit, "flush_cache", lambda *_args, **_kw: None) for name in ("enter", "exit"): @@ -812,7 +900,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk registry = NativeHostLifetimeRegistry() def prepare(generation): - from models.dify_builder_native_tests import NativeContinuation, NativeDispatch + from models.dify_builder_native_tests import NativeContinuation lifecycle = NativeTestLifecycleService( case.loader, state_store=case.store, resume_settlement=DifyNativeResumeSettlement() @@ -907,7 +995,8 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk registry=registry, ) case.rows[NativeDispatch] = [ - SimpleNamespace( + NativeDispatch( + idempotency_digest="a" * 64, id=f"delivery-{generation}", request_id=context.ref.request_id, invocation_id=context.ref.invocation_id, @@ -923,7 +1012,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk case.local.checkpoint_id = checkpoint_ref.checkpoint_id if generation else None if generation: case.rows[NativeContinuation] = [ - SimpleNamespace( + NativeContinuation( request_id=context.ref.request_id, invocation_id=context.ref.invocation_id, tenant_id=context.tenant_id, @@ -934,7 +1023,8 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk ) ] case.rows[NativePayloadObject].append( - SimpleNamespace( + NativePayloadObject( + value_digest="b" * 64, id=checkpoint_ref.checkpoint_id, request_id=context.ref.request_id, invocation_id=context.ref.invocation_id, @@ -956,6 +1046,8 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run(case, monk return runtime application = Flask(__name__) + application.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://" + database.init_app(application) with application.app_context(): initial = prepare(0) waiting = initial.run_initial(context=context) @@ -1096,7 +1188,6 @@ def install_owner_join_boundary(case, monkeypatch): def test_actual_inactive_retained_delivery_and_missing_cancel_handles_grant_no_cleanup(case, monkeypatch): - from models.dify_builder_native_tests import NativeDispatch from services.dify_builder.native_testing.lifecycle import NativeDispatchRef from services.dify_builder.native_testing.runtime import DifyNativeRuntimeOwner from tests.unit_tests.services.dify_builder.native_testing.test_native_observation_wiring import protect_logs @@ -1104,7 +1195,8 @@ def test_actual_inactive_retained_delivery_and_missing_cancel_handles_grant_no_c protect_logs(monkeypatch) context = case.context case.rows[NativeDispatch] = [ - SimpleNamespace( + NativeDispatch( + idempotency_digest="a" * 64, id="inactive", tenant_id=context.tenant_id, request_id=context.ref.request_id, @@ -1134,7 +1226,6 @@ def test_actual_inactive_retained_delivery_and_missing_cancel_handles_grant_no_c def test_actual_activated_startup_failure_consumes_only_original_zero_activity_verifier(case, monkeypatch): - from models.dify_builder_native_tests import NativeDispatch from runtime_contracts.builder.execution import BuilderExecutionPolicyError from services.dify_builder.native_testing.host_lifetime import NativeHostLifetimeRegistry from services.dify_builder.native_testing.lifecycle import NativeDispatchRef @@ -1152,7 +1243,8 @@ def test_actual_activated_startup_failure_consumes_only_original_zero_activity_v registry=NativeHostLifetimeRegistry(), ) case.rows[NativeDispatch] = [ - SimpleNamespace( + NativeDispatch( + idempotency_digest="a" * 64, id="start-failed", tenant_id=context.tenant_id, request_id=context.ref.request_id, @@ -1189,7 +1281,6 @@ def test_same_worker_reclaims_over_128_closed_consumed_requests_but_keeps_unknow from models.dify_builder import DifyBuilderExecutionRequest from models.dify_builder_native_tests import ( - NativeDispatch, NativeInvocationState, NativePayloadObject, NativeTestInvocation, @@ -1248,7 +1339,8 @@ def test_same_worker_reclaims_over_128_closed_consumed_requests_but_keeps_unknow row.id = "context-" + identity objects[row.id] = context.model_dump(mode="json") case.rows[model].append(row) - dispatch = SimpleNamespace( + dispatch = NativeDispatch( + idempotency_digest="a" * 64, id="delivery-" + identity, request_id=identity, invocation_id=ref.invocation_id, @@ -1334,7 +1426,6 @@ def test_same_worker_reclaims_over_128_closed_consumed_requests_but_keeps_unknow def cancel_composition(case, monkeypatch): """Real API/compiler/C03/runtime owners; mutable SQL and protected bytes are boundaries.""" from copy import deepcopy - from threading import local from uuid import uuid4 from core.dify_builder.input_schema import schema_hash, start_schema @@ -1381,18 +1472,22 @@ def cancel_composition(case, monkeypatch): for model in tuple(case.rows): if model.__module__ == "models.dify_builder_native_tests" or model.__name__ == "DifyBuilderExecutionRequest": case.rows[model] = [] - depth = local() + depth = _SqlDepth() class MutableSQL(SQL): @override def get(self, model, identity): key = next(iter(model.__table__.primary_key.columns)).key - return next((row for row in self.rows.get(model, []) if getattr(row, key) == identity), None) + if key == "id": + return next((row for row in self.rows.get(model, []) if row.id == identity), None) + if key == "request_id": + return next((row for row in self.rows.get(model, []) if row.request_id == identity), None) + raise AssertionError("unsupported SQL primary key") @override def add(self, row): for column in row.__table__.columns: - if getattr(row, column.key) is None and column.default is not None: + if inspect(row).attrs[column.key].value is None and column.default is not None: value = column.default.arg setattr(row, column.key, value(None) if callable(value) else deepcopy(value)) self.rows.setdefault(type(row), []).append(row) @@ -1412,9 +1507,11 @@ def cancel_composition(case, monkeypatch): dispatches = list(self.scalars(query)) expiring = any( - getattr(clause.left, "key", None) == "claim_expires_at" + isinstance(clause, BinaryExpression) + and isinstance(clause.left, ColumnClause) + and clause.left.key == "claim_expires_at" for clause in query._where_criteria - if hasattr(clause, "left") + if isinstance(clause, BinaryExpression) ) if expiring: dispatches = [ @@ -1439,7 +1536,7 @@ def cancel_composition(case, monkeypatch): @contextmanager def begin(self): - depth.writers = getattr(depth, "writers", 0) + 1 + depth.writers = depth.writers + 1 try: yield sql finally: @@ -1449,7 +1546,7 @@ def cancel_composition(case, monkeypatch): actor = Actor(account_id=original.actor_id, tenant_id=original.tenant_id) def permission(actual, app_id, source): - assert not getattr(depth, "writers", 0) + assert not depth.writers assert (actual, app_id, source) == (actor, original.app_id, sessions) for path in ( @@ -1464,7 +1561,7 @@ def cancel_composition(case, monkeypatch): def prepare(store, *, ref, tenant_id, kind, logical_key, payload): del store - assert not getattr(depth, "writers", 0) + assert not depth.writers raw, digest = state_module.encode_payload( tenant_id=tenant_id, request_id=ref.request_id, invocation_id=ref.invocation_id, kind=kind, payload=payload ) @@ -1483,7 +1580,7 @@ def cancel_composition(case, monkeypatch): ) def read(row): - assert not getattr(depth, "writers", 0) + assert not depth.writers return state_module.decode_payload( objects[row.id], tenant_id=row.tenant_id, @@ -1522,7 +1619,7 @@ def cancel_composition(case, monkeypatch): mode="native", idempotency_key="cancel-composed-start", ) - return SimpleNamespace( + return CancellationCase( api=api, owner=owner, published=published, @@ -1535,7 +1632,7 @@ def cancel_composition(case, monkeypatch): def test_composed_start_cancel_before_delivery_reconciles_without_physical_acquisition(cancel_composition): - from models.dify_builder_native_tests import NativeDispatch, NativeInvocationState + from models.dify_builder_native_tests import NativeInvocationState from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import decode_native_ref from services.dify_builder.native_testing.lifecycle import NativeDispatchRef @@ -1567,7 +1664,7 @@ def test_consumed_generation_zero_in_other_owner_cannot_ack_live_generation_one( """Real two retained owners and worker; SQL claim/resume publication are explicit boundaries.""" from threading import Event, Thread, current_thread - from models.dify_builder_native_tests import NativeDispatch, NativeInvocationState, NativeTestScope + from models.dify_builder_native_tests import NativeInvocationState, NativeTestScope from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import decode_native_ref from services.dify_builder.native_testing import runtime as module @@ -1648,7 +1745,7 @@ def test_cancel_original_joined_owner_rechecks_retained_snapshot( ): from threading import Thread, current_thread - from models.dify_builder_native_tests import NativeDispatch, NativeInvocationState + from models.dify_builder_native_tests import NativeInvocationState from runtime_contracts.builder.native.contracts import decode_native_ref from services.dify_builder.native_testing import runtime as module from services.dify_builder.native_testing.lifecycle import NativeDispatchRef @@ -1697,7 +1794,6 @@ def test_cancel_original_joined_owner_rechecks_retained_snapshot( @pytest.mark.parametrize("history", ["missing-outbox", "prior-lease", "foreign-outbox", "missing-context", "resumed"]) def test_composed_cancel_does_not_upgrade_incomplete_or_noninitial_history(cancel_composition, history): from models.dify_builder_native_tests import ( - NativeDispatch, NativeInvocationState, NativePayloadObject, NativeTestScope, @@ -1811,7 +1907,7 @@ def test_over_128_delivered_unclaimed_logical_cancellations_reclaim_same_worker_ def test_logical_preclaim_retirement_rechecks_proof_and_exact_retained_owners(cancel_composition, monkeypatch, race): from threading import Event, Thread, current_thread - from models.dify_builder_native_tests import NativeDispatch, NativeInvocationState, NativeTestScope + from models.dify_builder_native_tests import NativeInvocationState, NativeTestScope from runtime_contracts.builder.native.contracts import decode_native_ref from services.dify_builder.native_testing import host_capacity from services.dify_builder.native_testing.lifecycle import NativeDispatchRef @@ -1888,7 +1984,7 @@ def _cancel_projection_post_wait(c, dispatch_state): from uuid import uuid4 from models.dify_builder import DifyBuilderExecutionRequest - from models.dify_builder_native_tests import NativeDispatch, NativeInvocationState + from models.dify_builder_native_tests import NativeInvocationState from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.workflow import WorkflowRun, WorkflowType from runtime_contracts.builder.native.contracts import decode_native_ref @@ -1943,7 +2039,6 @@ def test_cancel_projection_includes_owned_sources(cancel_composition, source_own from uuid import uuid4 from models.dify_builder_native_tests import ( - NativeDispatch, NativeEffectOperation, NativeInvocationState, NativePayloadObject, @@ -1956,12 +2051,12 @@ def test_cancel_projection_includes_owned_sources(cancel_composition, source_own if source_owner == "child": child_id = str(uuid4()) selected = NativeInvocationState( - **{column.key: getattr(root, column.key) for column in NativeInvocationState.__table__.columns} + **{column.key: inspect(root).attrs[column.key].value for column in NativeInvocationState.__table__.columns} ) selected.invocation_id, selected.parent_invocation_id = child_id, root.invocation_id payload = c.rows[NativePayloadObject][0] child_payload = NativePayloadObject( - **{column.key: getattr(payload, column.key) for column in NativePayloadObject.__table__.columns} + **{column.key: inspect(payload).attrs[column.key].value for column in NativePayloadObject.__table__.columns} ) child_payload.id, child_payload.invocation_id = str(uuid4()), child_id selected.context_object_id = child_payload.id @@ -1977,7 +2072,7 @@ def test_cancel_projection_includes_owned_sources(cancel_composition, source_own ) ) source = NativeDispatch( - **{column.key: getattr(source, column.key) for column in NativeDispatch.__table__.columns} + **{column.key: inspect(source).attrs[column.key].value for column in NativeDispatch.__table__.columns} ) source.id, source.invocation_id, source.idempotency_digest = str(uuid4()), child_id, "b" * 64 c.sql.add(source) @@ -2049,11 +2144,13 @@ def test_cancel_projection_retains_older_unresolved_source(cancel_composition): """A newer completed acquisition cannot erase an older unresolved source.""" from uuid import uuid4 - from models.dify_builder_native_tests import NativeDispatch, NativeTestScope + from models.dify_builder_native_tests import NativeTestScope c = cancel_composition ref, _, state, old, _ = _cancel_projection_post_wait(c, "unknown") - current = NativeDispatch(**{column.key: getattr(old, column.key) for column in NativeDispatch.__table__.columns}) + current = NativeDispatch( + **{column.key: inspect(old).attrs[column.key].value for column in NativeDispatch.__table__.columns} + ) current.id, current.generation, current.state, current.idempotency_digest = str(uuid4()), 1, "completed", "f" * 64 c.sql.add(current) state.generation = c.rows[NativeTestScope][0].generation = 1 @@ -2069,7 +2166,7 @@ def test_cancel_projection_retains_older_unresolved_source(cancel_composition): "fault", ["missing-source", "foreign-tenant", "foreign-invocation", "future-generation", "missing-context"] ) def test_cancel_projection_never_infers_clean_from_incomplete_source_history(cancel_composition, fault): - from models.dify_builder_native_tests import NativeDispatch, NativePayloadObject + from models.dify_builder_native_tests import NativePayloadObject c = cancel_composition ref, _, state, source, _ = _cancel_projection_post_wait(c, "completed") @@ -2176,7 +2273,10 @@ def test_get_cancel_interleaving_refreshes_orm_state_and_projects_one_cancellati with sqlite_session_factory.begin() as writer: writer.add( NativeInvocationState( - **{column.key: getattr(durable, column.key) for column in NativeInvocationState.__table__.columns} + **{ + column.key: inspect(durable).attrs[column.key].value + for column in NativeInvocationState.__table__.columns + } ) ) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_node_inputs.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_node_inputs.py index 8597aa9f8b6..a29d0e77d33 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_node_inputs.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_node_inputs.py @@ -7,18 +7,28 @@ real encryption, actual Graphon identity, or authorization-owner qualification. import copy import json import warnings +from collections.abc import Callable from contextlib import contextmanager +from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta -from types import SimpleNamespace import pytest +from sqlalchemy import inspect from models.dify_builder import DifyBuilderExecutionRequest -from models.dify_builder_native_tests import NativeInvocationState, NativePayloadObject, NativeTestInvocation +from models.dify_builder_native_tests import ( + NativeInvocationState, + NativePayloadObject, + NativeTestInvocation, + NativeTestScope, +) +from models.workflow import Workflow from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest from runtime_contracts.builder.native.contracts import NativeTestContextRef, OperationKey, operation_digest from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext from services.dify_builder.native_testing import state_store as module +from services.dify_builder.native_testing.private_reads import PrivateReadOwner +from services.dify_builder.native_testing.repository import OwnedNativeTest NATIVE_ID = "81d492fd-05ce-46a8-8c82-a35d4b343d48" CANARY = "private-input-canary-982314" @@ -35,7 +45,11 @@ class SQLStandIn: if entity is NativePayloadObject: filters = {key.rsplit("_", 1)[0]: value for key, value in query.compile().params.items()} return next( - (row for row in self.owner.rows if all(getattr(row, key) == value for key, value in filters.items())), + ( + row + for row in self.owner.rows + if all(inspect(row).attrs[key].value == value for key, value in filters.items()) + ), None, ) raise AssertionError("unexpected SQL entity") @@ -64,7 +78,9 @@ class SQLStandIn: class LoaderStandIn: def __init__(self): self.ref = NativeTestContextRef(request_id="request", invocation_id="inv", context_digest="a" * 64) - self.state = SimpleNamespace( + self.state = NativeInvocationState( + pending_ids=[], + resolution={}, tenant_id="tenant", request_id="request", invocation_id="inv", @@ -75,11 +91,38 @@ class LoaderStandIn: generation=0, state="running", ) - self.scope = SimpleNamespace( - generation=0, expires_at=datetime.now(UTC) + timedelta(minutes=10), cancelled=False + self.scope = NativeTestScope( + request_id="request", + tenant_id="tenant", + max_calls=100, + max_dispatches=100, + max_cost_units=100, + generation=0, + task_approved=True, + budget_limits={}, + budget_spent={}, + profile={}, + expires_at=datetime.now(UTC) + timedelta(minutes=10), + cancelled=False, ) - self.request = SimpleNamespace(context={"native_run_id": "run", "mode": "native"}, native_run_id=None) - self.owned = SimpleNamespace( + self.request = DifyBuilderExecutionRequest( + id="request", + tenant_id="tenant", + app_id="app", + workflow_id="workflow", + actor_id="actor", + session_id="session", + test_input_id="input", + context_digest=self.ref.context_digest, + context={"native_run_id": "run", "mode": "native"}, + native_run_id=None, + ) + self.owned = OwnedNativeTest( + session_id="session", + test_input_id="input", + bindings={}, + task_approved=True, + revision_context=None, ref=self.ref, tenant_id="tenant", actor_id="actor", @@ -136,6 +179,31 @@ class LoaderStandIn: self.writing = False +@dataclass +class NodeInputsCase: + store: module.NativeExecutionStateStore + loader: LoaderStandIn + ref: NativeTestContextRef + context: ResolvedNativeTestContext + workflow: Workflow + events: list[str] + after_readback: list[Callable[[], None]] + blobs: dict[str, bytes] + + +def ancestor_state(invocation_id: str, state: str) -> NativeInvocationState: + return NativeInvocationState( + tenant_id="tenant", + request_id="request", + invocation_id=invocation_id, + context_object_id="ancestor-context", + generation=0, + state=state, + pending_ids=[], + resolution={}, + ) + + @pytest.fixture def case(monkeypatch): loader = LoaderStandIn() @@ -159,7 +227,7 @@ def case(monkeypatch): cancellation_ref="request", execution_mode="native", ) - workflow = SimpleNamespace(graph=json.dumps(context.graph)) + workflow = Workflow(graph=json.dumps(context.graph)) blobs = {} events = [] after_readback = [lambda: None] @@ -188,7 +256,7 @@ def case(monkeypatch): def private_read(owner, ref): owner.load(ref=ref) - return SimpleNamespace( + return PrivateReadOwner( tenant_id="tenant", actor_id="actor", app_id=loader.state.app_id, @@ -224,7 +292,7 @@ def case(monkeypatch): loader.rows.append(row) loader.state.context_object_id = row.id events.clear() - return SimpleNamespace( + return NodeInputsCase( store=store, loader=loader, ref=loader.ref, @@ -237,7 +305,7 @@ def case(monkeypatch): def persist(case, inputs, **changes): - method = getattr(case.store, "store_node_inputs", None) + method = case.store.store_node_inputs assert callable(method), "independent node input persistence is unavailable" return method(ref=case.ref, generation=0, native_execution_id=NATIVE_ID, inputs=inputs, **changes) @@ -320,13 +388,13 @@ def test_running_branch_retains_generation_after_unrelated_scope_resume(case, ph case.loader.rows = [row] case.loader.state.context_object_id = row.id case.loader.state.parent_invocation_id = parent_id - case.loader.ancestors = [SimpleNamespace(invocation_id=parent_id, parent_invocation_id=None, state="running")] + case.loader.ancestors = [ancestor_state(parent_id, "running")] case.loader.active_ancestors = [parent_id] case.events.clear() def resume_other_branch(): case.loader.scope.generation = 1 - case.loader.owned.generation = 1 # OwnedNativeTest.generation projects the scope, not this invocation. + case.loader.owned = replace(case.loader.owned, generation=1) # Scope counter, not this invocation. if phase == "before-save": resume_other_branch() @@ -345,7 +413,7 @@ def test_running_branch_retains_generation_after_unrelated_scope_resume(case, ph ["", NATIVE_ID.upper(), NATIVE_ID.replace("-", ""), "{" + NATIVE_ID + "}", "not-a-uuid", "a" * 129, None], ) def test_invalid_identity_fails_before_private_storage(case, identity): - method = getattr(case.store, "store_node_inputs", None) + method = case.store.store_node_inputs assert callable(method), "independent node input persistence is unavailable" with pytest.raises(BuilderExecutionPolicyError): method(ref=case.ref, generation=0, native_execution_id=identity, inputs={"secret": CANARY}) @@ -407,7 +475,7 @@ def test_current_authority_drift_never_acknowledges(case, phase, mutation): owner.request.context["mode"] = "mock" elif mutation == "ancestor": owner.state.parent_invocation_id = "quarantined" - owner.ancestors = [SimpleNamespace(invocation_id="quarantined", parent_invocation_id=None, state="unknown")] + owner.ancestors = [ancestor_state("quarantined", "unknown")] elif mutation == "expiry": owner.scope.expires_at = datetime.now(UTC) - timedelta(seconds=1) @@ -434,7 +502,11 @@ def test_failed_storage_or_ack_never_returns_reference_or_leaks_inputs(case, mon if failure in {"save", "decrypt", "readback"}: target = module.encrypter if failure == "decrypt" else module.storage name = {"save": "save", "decrypt": "decrypt_token", "readback": "load"}[failure] - original = getattr(target, name) + original = { + "save": module.storage.save, + "decrypt": module.encrypter.decrypt_token, + "readback": module.storage.load, + }[failure] def fail_inputs(*args, **kwargs): if "save" in case.events or failure == "save": diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_owner.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_owner.py index 2afc1a8beda..3d965933d4e 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_owner.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_owner.py @@ -4,12 +4,14 @@ import importlib import importlib.util import json import traceback +from collections.abc import Callable from contextlib import contextmanager from copy import deepcopy +from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta -from types import SimpleNamespace import pytest +from sqlalchemy import inspect from core.dify_builder.models import Actor from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantStatus @@ -24,6 +26,7 @@ from models.model import App from models.workflow import Workflow from runtime_contracts.builder.execution import canonical_digest from runtime_contracts.builder.native.contracts import NativeTestContextRef +from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext from services.dify_builder.native_testing.http_binding import ( NativeHttpBindingResolver, build_native_http_profile, @@ -37,6 +40,50 @@ from tests.unit_tests.services.dify_builder.native_testing.test_observation_priv from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import context as sample_context +@dataclass +class ObservationOwnerCase: + store: NativeExecutionStateStore + shape: str + loader: OwnedNativeTestLoader + context: ResolvedNativeTestContext + request: DifyBuilderExecutionRequest + local: NativeInvocationState + workflow: Workflow + rows: dict[type, list] + events: list[str] + reads: int = 0 + on_read: Callable[[int], None] | None = None + notices: list[str] = field(default_factory=list) + + def rebind(self): + ref = self.context.ref.model_copy(update={"context_digest": canonical_digest(self.request.context)}) + self.request.context_digest = ref.context_digest + self.context = self.context.model_copy(update={"ref": ref}, deep=True) + + +@dataclass +class PretendExecutionState: + """Deliberately wrong state class with plausible methods, rejected before IO.""" + + loader: OwnedNativeTestLoader + context: ResolvedNativeTestContext + + def load_context(self, **_kwargs): + return self.context + + +def owner_rows_snapshot(rows): + """Compare all actual mapped owner values before and after the observation read.""" + return deepcopy( + { + model: [ + tuple(inspect(row).attrs[column.key].value for column in inspect(model).column_attrs) for row in values + ] + for model, values in rows.items() + } + ) + + def owner(): name = "services.dify_builder.native_testing.observation_owner" assert importlib.util.find_spec(name) is not None, "trusted observation owner is missing" @@ -48,21 +95,25 @@ def case(monkeypatch, request): """No database/crypto/RPC claim: real methods read explicitly modeled owner rows.""" source = sample_context() graph, inputs = deepcopy(source.graph), deepcopy(source.inputs) - shape = getattr(request, "param", "literal_http") + try: + shape = request.param + except AttributeError: + shape = "literal_http" if shape == "start_end": graph.pop("viewport") graph["nodes"] = [n for n in graph["nodes"] if n["data"]["type"] != "http-request"] graph["nodes"][-1]["data"]["outputs"] = [] graph["nodes"][-1]["data"]["title"] = SENTINEL graph["edges"] = [{"id": "e1", "source": "start", "target": "end", "sourceHandle": "source"}] - workflow = SimpleNamespace( + workflow = Workflow( + created_by="actor", id="workflow", app_id="app", tenant_id="tenant", version="draft", type="workflow", graph=json.dumps(graph), - serialized_features="{}", + _features="{}", _environment_variables="{}", _conversation_variables="{}", ) @@ -99,7 +150,7 @@ def case(monkeypatch, request): context = source.model_copy( update={"ref": ref, "graph": graph, "graph_revision": revision, "resource_manifest": manifest}, deep=True ) - request = SimpleNamespace( + request = DifyBuilderExecutionRequest( id="request", tenant_id="tenant", actor_id="actor", @@ -111,7 +162,9 @@ def case(monkeypatch, request): context_digest=ref.context_digest, state="running", ) - state = SimpleNamespace( + state = NativeInvocationState( + pending_ids=[], + resolution=[], id="state", request_id="request", tenant_id="tenant", @@ -123,20 +176,41 @@ def case(monkeypatch, request): generation=0, state="queued", ) + account = Account(name="Test", email="test@example.invalid", status=AccountStatus.ACTIVE) + account.id = "actor" + tenant = Tenant(name="Test", status=TenantStatus.NORMAL) + tenant.id = "tenant" rows = { - Account: [SimpleNamespace(id="actor", status=AccountStatus.ACTIVE)], - Tenant: [SimpleNamespace(id="tenant", status=TenantStatus.NORMAL)], - TenantAccountJoin: [SimpleNamespace(tenant_id="tenant", account_id="actor", role="owner")], - App: [SimpleNamespace(id="app", tenant_id="tenant", status="normal")], + Account: [account], + Tenant: [tenant], + TenantAccountJoin: [TenantAccountJoin(tenant_id="tenant", account_id="actor", role="owner")], + App: [App(id="app", tenant_id="tenant", status="normal")], Workflow: [workflow], - DifyBuilderSession: [SimpleNamespace(id="session", tenant_id="tenant", app_id="app", owner_account_id="actor")], + DifyBuilderSession: [ + DifyBuilderSession( + id="session", + tenant_id="tenant", + app_id="app", + owner_account_id="actor", + entry_mode="new", + current_state="build.review", + ) + ], DifyBuilderTestInput: [ - SimpleNamespace(id="input", session_id="session", inputs=inputs, start_schema_hash="schema") + DifyBuilderTestInput( + id="input", session_id="session", inputs=inputs, start_schema_hash="schema", source="mock" + ) ], DifyBuilderExecutionRequest: [request], NativeInvocationState: [state], NativeTestScope: [ - SimpleNamespace( + NativeTestScope( + max_calls=16, + max_dispatches=16, + max_cost_units=16, + budget_limits={}, + budget_spent={}, + profile={}, request_id="request", tenant_id="tenant", generation=0, @@ -146,13 +220,21 @@ def case(monkeypatch, request): ) ], NativeTestInvocation: [ - SimpleNamespace( + NativeTestInvocation( request_id="request", tenant_id="tenant", invocation_id="invocation", active=True, bindings={} ) ], NativePayloadObject: [ - SimpleNamespace( - id="context", request_id="request", tenant_id="tenant", invocation_id="invocation", kind="context" + NativePayloadObject( + logical_key="context", + storage_key="private-context", + payload_digest="a" * 64, + value_digest="b" * 64, + id="context", + request_id="request", + tenant_id="tenant", + invocation_id="invocation", + kind="context", ) ], } @@ -200,7 +282,7 @@ def case(monkeypatch, request): factory, actor=Actor(tenant_id="tenant", account_id="actor"), resolver=NativeHttpBindingResolver() ) store = NativeExecutionStateStore(loader) - c = SimpleNamespace( + c = ObservationOwnerCase( store=store, shape=shape, loader=loader, @@ -226,23 +308,17 @@ def case(monkeypatch, request): monkeypatch.setattr(NativeExecutionStateStore, "_read_object", staticmethod(protected_object)) - def rebind(): - ref = c.context.ref.model_copy(update={"context_digest": canonical_digest(request.context)}) - request.context_digest = ref.context_digest - c.context = c.context.model_copy(update={"ref": ref}, deep=True) - - c.rebind = rebind return c def test_actual_stored_profile_builds_existing_guard_without_writes_or_effects(case): c = case - before = deepcopy(c.rows) + before = owner_rows_snapshot(c.rows) guard = owner()(state=c.store, context=c.context, on_failure=lambda: c.notices.append("unknown")) assert type(guard) is NativeObservationGuard assert not guard.failed assert SENTINEL not in json.dumps(guard.graph(c.context.graph)) - assert c.rows == before + assert owner_rows_snapshot(c.rows) == before assert c.reads == 2 assert c.events with pytest.raises(NativeObservationError): @@ -293,10 +369,10 @@ def test_caller_context_cannot_replace_actual_protected_snapshot(case, field, va def test_actual_c03_full_current_owner_chain_is_required(case, fault): c = case if fault == "fake_state": - _refuses(c, state=SimpleNamespace(loader=c.loader, load_context=lambda **_: c.context)) + _refuses(c, state=PretendExecutionState(c.loader, c.context)) return if fault == "fake_loader": - c.store.loader = SimpleNamespace() + c.store.loader = object() elif fault == "missing_context": c.rows[NativePayloadObject] = [] elif fault == "membership": @@ -304,7 +380,7 @@ def test_actual_c03_full_current_owner_chain_is_required(case, fault): elif fault == "input_owner": c.rows[DifyBuilderTestInput][0].session_id = "foreign" elif fault == "revision": - c.workflow.serialized_features = '{"changed":true}' + c.workflow._features = '{"changed":true}' else: c.local.generation = 1 _refuses(c) @@ -322,7 +398,7 @@ def test_private_read_boundaries_recheck_current_server_metadata_before_return(c elif count == 1 and fault == "generation_first_read": c.local.generation += 1 elif count == 1 and fault == "revision_first_read": - c.workflow.serialized_features = '{"changed":true}' + c.workflow._features = '{"changed":true}' elif count == 2 and fault == "input_final_read": c.rows[DifyBuilderTestInput][0].inputs = {"changed": True} @@ -368,12 +444,12 @@ def test_initial_current_profile_observation_preserves_private_snapshot(case): {"title", "type", "variables"}, {"title", "type", "outputs"}, ] - before = deepcopy(c.rows) + before = owner_rows_snapshot(c.rows) guard = owner()(state=c.store, context=context, on_failure=lambda: c.notices.append("unknown")) assert type(guard) is NativeObservationGuard assert not guard.failed assert c.reads == 2 - assert c.rows == before + assert owner_rows_snapshot(c.rows) == before assert c.notices == [] projected = json.dumps(guard.graph(context.graph)) assert SENTINEL not in projected diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_persistence.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_persistence.py index 0ed4f546dcf..bae32c91897 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_persistence.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_persistence.py @@ -12,12 +12,12 @@ import json import traceback from copy import deepcopy from datetime import datetime -from types import SimpleNamespace from typing import cast, override import pytest from sqlalchemy.orm import sessionmaker +from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity from core.ops.ops_trace_manager import TraceQueueManager from core.repositories.factory import OrderConfig @@ -47,6 +47,7 @@ from graphon.node_events import NodeRunResult from graphon.runtime import ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool from models import WorkflowNodeExecutionTriggeredFrom from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.model import AppMode from services.dify_builder.native_testing.http_binding import build_native_http_profile from services.dify_builder.native_testing.observation_privacy import NativeHttpObservationPolicy from services.file_service import FileService @@ -232,7 +233,9 @@ def make_layer(*, guard=None, trace=None): runtime.variable_pool.add(["sys", "query"], SENTINEL) entity = WorkflowAppGenerateEntity.model_construct( task_id="task", - app_config=SimpleNamespace(app_id="app", tenant_id="tenant"), + app_config=WorkflowUIBasedAppConfig( + app_id="app", tenant_id="tenant", workflow_id="workflow", app_mode=AppMode.WORKFLOW + ), inputs={SENTINEL: SENTINEL}, files=[], user_id="actor", @@ -518,12 +521,19 @@ def test_native_resumption_refuses_before_old_row_load_or_cache_and_run_save(): assert notices == [1] +class RejectRawTrace: + """Trace queue boundary that must never receive marked persistence events.""" + + def add_trace_task(self, _task): + pytest.fail("raw trace enqueue") + + def test_trace_owner_is_refused_before_marked_persistence_or_enqueues(): notices = [] module = adapters() guard = module.NativeObservationGuard(policy=policy(), on_failure=lambda: notices.append(1)) with pytest.raises(module.NativeObservationError): - make_layer(guard=guard, trace=SimpleNamespace(add_trace_task=lambda _task: pytest.fail("trace enqueue"))) + make_layer(guard=guard, trace=RejectRawTrace()) assert guard.failed is True assert notices == [1] @@ -729,9 +739,7 @@ def test_late_trace_owner_cannot_enqueue_after_marked_construction(): guard = adapters().NativeObservationGuard(policy=policy(), on_failure=lambda: None) layer, _, _, _, entity, _ = make_layer(guard=guard) layer.on_event(GraphRunStartedEvent()) - entity.trace_manager = cast( - TraceQueueManager, SimpleNamespace(add_trace_task=lambda _task: pytest.fail("raw trace enqueue")) - ) + entity.trace_manager = cast(TraceQueueManager, RejectRawTrace()) with pytest.raises(adapters().NativeObservationError): layer.on_event(GraphRunSucceededEvent()) assert guard.failed is True diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_repository.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_repository.py index 82268ad3bb0..a91972977cd 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_repository.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_repository.py @@ -1,9 +1,9 @@ """Real repositories/domain/guard, explicit in-memory SQL and storage ports only.""" +from collections import UserList from copy import deepcopy +from dataclasses import dataclass from datetime import datetime -from types import SimpleNamespace -from unittest.mock import Mock import pytest from sqlalchemy.orm import sessionmaker @@ -16,6 +16,7 @@ from models import Account, WorkflowNodeExecutionTriggeredFrom, WorkflowRun from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.workflow import WorkflowNodeExecutionModel from repositories.entities.workflow_execution import WorkflowExecution as TimedWorkflowExecution +from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext from services.dify_builder.native_testing.http_binding import build_native_http_profile from services.dify_builder.native_testing.observation_adapters import NativeObservationError, NativeObservationGuard from services.dify_builder.native_testing.observation_privacy import NativeHttpObservationPolicy @@ -25,6 +26,17 @@ from tests.unit_tests.services.dify_builder.native_testing.test_observation_priv NOW = datetime(2026, 10, 16) +class NativeRepositoryRows(UserList): + def all(self): + return list(self) + + def first(self): + return next(iter(self), None) + + def scalars(self): + return self + + class SQLPort: """Owner query/write boundary; no SQL engine, collection or query emulation.""" @@ -59,10 +71,10 @@ class SQLPort: def scalars(self, _statement): if self.fail: raise RuntimeError(SENTINEL) - return SimpleNamespace(all=lambda: self.selected) + return NativeRepositoryRows(self.selected) def execute(self, _statement): - return SimpleNamespace(scalars=lambda: SimpleNamespace(first=lambda: next(iter(self.selected), None))) + return NativeRepositoryRows(self.selected) def merge(self, row): self.rows[type(row), row.id] = row @@ -80,6 +92,16 @@ class SQLPort: self.commits += 1 +@dataclass +class RepositoryCase: + context: ResolvedNativeTestContext + guard: NativeObservationGuard + sql: SQLPort + runs: SQLAlchemyWorkflowExecutionRepository + nodes: SQLAlchemyWorkflowNodeExecutionRepository + user: Account + + @pytest.fixture def case(): ctx = context() @@ -97,7 +119,7 @@ def case(): sessionmaker(), ctx.tenant_id, user, ctx.app_id, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN ) runs._session_factory = nodes._session_factory = sql - return SimpleNamespace(context=ctx, guard=guard, sql=sql, runs=runs, nodes=nodes, user=user) + return RepositoryCase(context=ctx, guard=guard, sql=sql, runs=runs, nodes=nodes, user=user) def bind(c): @@ -379,23 +401,25 @@ def test_real_offload_relation_decode_precedes_cache_and_only_loads_once(case, m file, content = offloaded(c, row) c.sql.selected = [row] + storage_calls = [] + def load(key, *, stream): + storage_calls.append(key) assert stream is True assert not c.sql.writing assert not c.nodes._node_execution_cache assert key == file.key return storage_chunks(content) - storage = Mock(side_effect=load) - monkeypatch.setattr("extensions.ext_storage.storage.load", storage) + monkeypatch.setattr("extensions.ext_storage.storage.load", load) result = c.nodes.get_by_workflow_execution(c.context.native_run_id) assert result[0].outputs["redacted"] is True assert result[0].outputs["shape"]["fields"] == 1 - assert storage.call_count == 1 + assert len(storage_calls) == 1 cached = c.nodes._node_execution_cache["execution"] again = c.nodes._to_domain_model(cached) assert again.outputs == result[0].outputs - assert storage.call_count == 1 + assert len(storage_calls) == 1 assert row.offload_data[0].file is file @@ -419,16 +443,21 @@ def test_bad_offload_or_late_content_never_enters_cache(case, monkeypatch, fault if fault == "hash": file.hash = "0" * 64 c.sql.selected = [first, row] - storage = Mock( - side_effect=RuntimeError(SENTINEL) if fault == "late_error" else lambda _, **_kwargs: storage_chunks(content) - ) + storage_calls = [] + + def storage(key, **_kwargs): + storage_calls.append(key) + if fault == "late_error": + raise RuntimeError(SENTINEL) + return storage_chunks(content) + monkeypatch.setattr("extensions.ext_storage.storage.load", storage) with pytest.raises(NativeObservationError, match="native_observation_failed"): c.nodes.get_db_models_by_workflow_run(c.context.native_run_id) assert c.nodes._node_execution_cache == {} assert c.guard.failed if fault in {"tenant", "actor", "relation", "type"}: - storage.assert_not_called() + assert storage_calls == [] def test_safe_retry_history_is_retained_and_foreign_metadata_refuses(case): @@ -525,20 +554,30 @@ def test_native_actual_truncation_uploads_only_safe_data_then_cache_has_no_secon assert raw.outputs == {SENTINEL: SENTINEL} stored = c.sql.rows[WorkflowNodeExecutionModel, "row"] assert len(stored.offload_data) == 3 - storage = Mock(side_effect=AssertionError("cached validated data loaded storage again")) + storage_calls = [] + + def storage(*args, **kwargs): + storage_calls.append((args, kwargs)) + raise AssertionError("cached validated data loaded storage again") + monkeypatch.setattr("extensions.ext_storage.storage.load", storage) cached = c.nodes._to_domain_model(c.nodes._node_execution_cache["execution"]) assert cached.outputs["redacted"] is True - storage.assert_not_called() + assert storage_calls == [] # The SQL port explicitly resolves real offload→UploadFile relationships for a fresh query. for link in stored.offload_data: link.file = files[link.file_id] c.sql.selected = [stored] - reads = Mock(side_effect=lambda key, **_kwargs: storage_chunks(bodies[key])) + read_keys = [] + + def reads(key, **_kwargs): + read_keys.append(key) + return storage_chunks(bodies[key]) + monkeypatch.setattr("extensions.ext_storage.storage.load", reads) history = c.nodes.get_by_workflow_execution(c.context.native_run_id) assert history[0].outputs == cached.outputs - assert reads.call_count == 3 + assert len(read_keys) == 3 def test_empty_success_error_remains_empty(case): @@ -586,11 +625,16 @@ def test_foreign_storage_type_refuses_before_load(case, monkeypatch): file, content = offloaded(c, row) c.sql.selected = [row] file.storage_type = next(item for item in StorageType if item != file.storage_type) - load = Mock(return_value=content) + load_calls = [] + + def load(*args, **kwargs): + load_calls.append((args, kwargs)) + return content + monkeypatch.setattr("extensions.ext_storage.storage.load", load) with pytest.raises(NativeObservationError): c.nodes.get_db_models_by_workflow_run(c.context.native_run_id) - load.assert_not_called() + assert load_calls == [] def test_context_drift_during_offload_refuses_before_cache(case, monkeypatch): @@ -684,11 +728,16 @@ def test_public_native_run_read_wrong_id_refuses_before_sql(case, monkeypatch): c = case bind(c) assert hasattr(c.runs, "get_native_observation_run"), "public owned Run history read is missing" - read = Mock(side_effect=AssertionError("unexpected SQL")) + read_calls = [] + + def read(*args, **kwargs): + read_calls.append((args, kwargs)) + raise AssertionError("unexpected SQL") + monkeypatch.setattr(c.sql, "get", read) with pytest.raises(NativeObservationError): c.runs.get_native_observation_run(workflow_run_id="foreign-run") - read.assert_not_called() + assert read_calls == [] assert c.runs._execution_cache == {} @@ -962,9 +1011,9 @@ def test_native_node_non_none_malformed_metadata_refuses_before_write(case, meta @pytest.mark.parametrize("repository_name", ["runs", "nodes"]) def test_service_binding_requires_exact_guard_before_attaching_observer(case, repository_name): - repository = getattr(case, repository_name) + repository = {"runs": case.runs, "nodes": case.nodes}[repository_name] with pytest.raises(NativeObservationError, match="^native_observation_failed$"): - bind_native_repository_observation(repository=repository, context=case.context, guard=SimpleNamespace()) + bind_native_repository_observation(repository=repository, context=case.context, guard=object()) assert repository._native_observation is None assert not repository._native_observation_started @@ -974,7 +1023,7 @@ def test_repository_cannot_replace_retained_observer_even_with_same_guard(case, from services.dify_builder.native_testing.observation_repository import NativeRepositoryObservation bind(case) - repository = getattr(case, repository_name) + repository = {"runs": case.runs, "nodes": case.nodes}[repository_name] original = repository._native_observation replacement = NativeRepositoryObservation(repository=repository, context=case.context, guard=case.guard) with pytest.raises(NativeObservationError, match="^native_observation_failed$"): diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_stream.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_stream.py index 0e67acc1ed5..ec04fb2d677 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_stream.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_stream.py @@ -1,12 +1,12 @@ """Actual response owners; Redis/SQL/provider ports are explicit test boundaries.""" from importlib import import_module, util -from types import SimpleNamespace from typing import Protocol, cast -from unittest.mock import Mock import pytest +from redis import Redis +from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.draft_variable_saver import DraftVariableSaverFactory from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity @@ -15,6 +15,7 @@ from core.app.entities.task_entities import ErrorStreamResponse, WorkflowPauseSt from core.workflow.system_variables import build_system_variables from graphon.runtime import RuntimeState, VariablePool from models.account import Account +from models.model import AppMode from models.workflow import Workflow from repositories.entities.workflow_execution import WorkflowExecutionTiming from runtime_contracts.builder.execution import BuilderExecutionPolicyError @@ -45,27 +46,45 @@ def guard(): ) -def entity(**kwargs) -> WorkflowAppGenerateEntity: - """Structural constructor-refusal fixture; no native provenance claim.""" - return cast( - WorkflowAppGenerateEntity, - SimpleNamespace( - task_id="task", - app_config=SimpleNamespace(app_id="app", tenant_id="tenant"), - invoke_from=InvokeFrom.DEBUGGER, - files=[], - inputs={SENTINEL: SENTINEL}, - workflow_execution_id="run", - workflow_execution_timing=WorkflowExecutionTiming(), - call_depth=0, - native_test_required=True, - native_test_ref=None, - trace_manager=None, - **kwargs, +def entity() -> WorkflowAppGenerateEntity: + """Actual marked DTO with no acquired native host authority.""" + return WorkflowAppGenerateEntity( + task_id="task", + user_id="actor", + stream=True, + app_config=WorkflowUIBasedAppConfig( + app_id="app", tenant_id="tenant", workflow_id="workflow", app_mode=AppMode.WORKFLOW ), + invoke_from=InvokeFrom.DEBUGGER, + files=[], + inputs={SENTINEL: SENTINEL}, + workflow_execution_id="run", + workflow_execution_timing=WorkflowExecutionTiming(), + call_depth=0, + native_test_required=True, + native_test_ref=None, + trace_manager=None, ) +def bind_queue_cache(monkeypatch): + """Use the actual Redis client with explicit cache command boundaries.""" + client = Redis() + monkeypatch.setattr(client, "setex", lambda *_args, **_kwargs: True) + monkeypatch.setattr(client, "get", lambda *_args, **_kwargs: None) + monkeypatch.setattr(client, "delete", lambda *_args, **_kwargs: 0) + monkeypatch.setattr("core.app.apps.base_app_queue_manager.redis_client", client) + + +class RejectDraftSaverFactory: + def __init__(self): + self.calls = [] + + def __call__(self, app_id, node_id, node_type, node_execution_id, enclosing_node_id=None): + self.calls.append((app_id, node_id, node_type, node_execution_id, enclosing_node_id)) + raise AssertionError("unknown draft factory must never be invoked") + + def account(): user = Account(name=SENTINEL, email=SENTINEL + "@example.com") user.id = "actor" @@ -84,11 +103,16 @@ def test_domain_projects_and_retains_independent_canonical_markers(guard): def test_marked_converter_requires_guard_before_input_conversion(monkeypatch): - touch = Mock(side_effect=AssertionError("raw input conversion")) + touch_calls = [] + + def touch(*args, **kwargs): + touch_calls.append((args, kwargs)) + raise AssertionError("raw input conversion") + monkeypatch.setattr("services.workflow_runtime.response_converter.WorkflowEntry.handle_special_values", touch) with pytest.raises(NativeObservationError, match="native_observation_failed"): WorkflowResponseConverter(application_generate_entity=entity(), user=account(), system_variables=[]) - touch.assert_not_called() + assert touch_calls == [] def test_marked_pipeline_requires_guard_before_any_initialization(): @@ -104,9 +128,7 @@ def test_marked_pipeline_requires_guard_before_any_initialization(): def test_queue_projects_before_base_model_dump_and_direct_publish(guard, monkeypatch): - monkeypatch.setattr( - "core.app.apps.base_app_queue_manager.redis_client", Mock() - ) # Redis ownership/cache boundary only. + bind_queue_cache(monkeypatch) manager = WorkflowAppQueueManager("task", "actor", InvokeFrom.DEBUGGER, "workflow", native_observation=guard) raw = QueueWorkflowSucceededEvent(outputs={SENTINEL: SENTINEL}) original = QueueWorkflowSucceededEvent.model_dump @@ -148,7 +170,12 @@ def test_native_finish_reads_live_outputs_without_converter_or_file_routes(guard ) state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0) state.set_output(SENTINEL, SENTINEL) - tripwire = Mock(side_effect=AssertionError("raw encoding/file/truncation")) + tripwire_calls = [] + + def tripwire(*args, **kwargs): + tripwire_calls.append((args, kwargs)) + raise AssertionError("raw encoding/file/truncation") + monkeypatch.setattr("services.workflow_runtime.response_converter.WorkflowRuntimeTypeConverter", tripwire) monkeypatch.setattr(c, "fetch_files_from_node_outputs", tripwire) monkeypatch.setattr(c._truncator, "truncate_variable_mapping", tripwire) @@ -172,7 +199,7 @@ def test_native_finish_reads_live_outputs_without_converter_or_file_routes(guard assert response.data.exceptions_count == 2 assert not response.data.files assert state.outputs == {SENTINEL: SENTINEL} - tripwire.assert_not_called() + assert tripwire_calls == [] @pytest.mark.parametrize( @@ -261,16 +288,16 @@ def test_wrong_node_type_and_unqualified_hitl_fail_before_projection(guard): def make_pipeline( guard, monkeypatch, **updates -) -> tuple[WorkflowAppGenerateTaskPipeline, WorkflowAppQueueManager, Mock]: +) -> tuple[WorkflowAppGenerateTaskPipeline, WorkflowAppQueueManager, RejectDraftSaverFactory]: from tests.unit_tests.core.app.apps.workflow.test_generate_task_pipeline_core import _make_pipeline ordinary = _make_pipeline() e = ordinary._application_generate_entity.model_copy( update={"native_test_required": True, "invoke_from": InvokeFrom.DEBUGGER, **updates} ) - monkeypatch.setattr("core.app.apps.base_app_queue_manager.redis_client", Mock()) + bind_queue_cache(monkeypatch) queue = WorkflowAppQueueManager("task", "actor", InvokeFrom.DEBUGGER, "workflow", native_observation=guard) - saver = Mock(side_effect=AssertionError("unknown draft factory must never be invoked")) + saver = RejectDraftSaverFactory() pipeline = WorkflowAppGenerateTaskPipeline( application_generate_entity=e, workflow=ordinary._workflow, @@ -300,7 +327,7 @@ def test_pipeline_projects_internal_events_and_noops_draft_saver(guard, monkeypa replies = list(p._dispatch_event(event)) assert len(replies) == 1 assert SENTINEL not in replies[0].model_dump_json() - saver.assert_not_called() + assert saver.calls == [] assert p._workflow_response_converter._native_observation is guard assert q.native_observation is guard live = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) @@ -310,12 +337,17 @@ def test_pipeline_projects_internal_events_and_noops_draft_saver(guard, monkeypa def test_pipeline_refuses_tts_trace_and_files_before_actions(guard, monkeypatch): p, q, saver = make_pipeline(guard, monkeypatch) - tts = Mock(side_effect=AssertionError("TTS must not initialize")) + tts_calls = [] + + def tts(*args, **kwargs): + tts_calls.append((args, kwargs)) + raise AssertionError("TTS must not initialize") + monkeypatch.setattr("services.workflow_runtime.workflow_pipeline.AppGeneratorTTSPublisher", tts) p._workflow_features_dict = {"text_to_speech": {"enabled": True, "autoPlay": "enabled"}} with pytest.raises(NativeObservationError): list(p._wrapper_process_stream_response()) - tts.assert_not_called() + assert tts_calls == [] def test_pause_live_fallback_is_safe_without_hitl_resolution(guard, monkeypatch): @@ -329,7 +361,12 @@ def test_pause_live_fallback_is_safe_without_hitl_resolution(guard, monkeypatch) ) state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) state.set_output(SENTINEL, SENTINEL) - trip = Mock(side_effect=AssertionError("HITL resolution")) + trip_calls = [] + + def trip(*args, **kwargs): + trip_calls.append((args, kwargs)) + raise AssertionError("HITL resolution") + monkeypatch.setattr("services.workflow_runtime.response_converter.resolve_human_input_pause_reason_inputs", trip) r = c.workflow_pause_to_stream_response( event=QueueWorkflowPausedEvent(reasons=[SchedulingPause(message=SENTINEL)], paused_nodes=["http"]), @@ -348,7 +385,7 @@ def test_pause_live_fallback_is_safe_without_hitl_resolution(guard, monkeypatch) ) assert isinstance(explicit[0], WorkflowPauseStreamResponse) assert explicit[0].data.outputs["shape"]["fields"] == 2 - trip.assert_not_called() + assert trip_calls == [] def test_direct_native_error_handler_never_passes_raw_error_to_base(guard, monkeypatch): @@ -371,7 +408,16 @@ def test_direct_native_text_handler_rejects_tts_before_publish(guard, monkeypatc from core.app.entities.queue_entities import QueueTextChunkEvent p, q, saver = make_pipeline(guard, monkeypatch) - tts = Mock() + + class RejectedTTSPublisher: + def __init__(self): + self.published = [] + + def publish(self, *args, **kwargs): + self.published.append((args, kwargs)) + raise AssertionError("native text reached TTS") + + tts = RejectedTTSPublisher() with pytest.raises(NativeObservationError): list( p._handle_text_chunk_event( @@ -380,7 +426,7 @@ def test_direct_native_text_handler_rejects_tts_before_publish(guard, monkeypatc queue_message=cast(WorkflowQueueMessage, object()), ) ) - tts.publish.assert_not_called() + assert tts.published == [] @pytest.mark.parametrize("kind", ["iteration", "loop"]) @@ -396,7 +442,11 @@ def test_real_container_queue_and_converter_preserve_counts_without_values(kind) ), on_failure=lambda: None, ) - prefix = "Iteration" if kind == "iteration" else "Loop" + start_type, next_type, completed_type = ( + (q.QueueIterationStartEvent, q.QueueIterationNextEvent, q.QueueIterationCompletedEvent) + if kind == "iteration" + else (q.QueueLoopStartEvent, q.QueueLoopNextEvent, q.QueueLoopCompletedEvent) + ) base = { "node_execution_id": "container-execution", "node_id": "container", @@ -409,11 +459,11 @@ def test_real_container_queue_and_converter_preserve_counts_without_values(kind) task_id="task", workflow_run_id="run", workflow_id="workflow", reason=WorkflowStartReason.INITIAL ) events = [ - getattr(q, "Queue" + prefix + "StartEvent")( + start_type( **base, start_at=NOW.replace(tzinfo=None), inputs={SENTINEL: SENTINEL}, metadata={kind + "_length": 3} ), - getattr(q, "Queue" + prefix + "NextEvent")(**base, index=1, output=SENTINEL), - getattr(q, "Queue" + prefix + "CompletedEvent")( + next_type(**base, index=1, output=SENTINEL), + completed_type( **base, start_at=NOW.replace(tzinfo=None), steps=4, @@ -425,9 +475,18 @@ def test_real_container_queue_and_converter_preserve_counts_without_values(kind) for event, method in zip(events, ["start", "next", "completed"], strict=True): projected = domain().project_native_queue_event(guard=guard, event=event) assert projected == domain().project_native_queue_event(guard=guard, event=projected) - response = getattr(c, "workflow_" + kind + "_" + method + "_to_stream_response")( - task_id="task", workflow_execution_id="run", event=event - ) + response = { + "iteration": { + "start": c.workflow_iteration_start_to_stream_response, + "next": c.workflow_iteration_next_to_stream_response, + "completed": c.workflow_iteration_completed_to_stream_response, + }, + "loop": { + "start": c.workflow_loop_start_to_stream_response, + "next": c.workflow_loop_next_to_stream_response, + "completed": c.workflow_loop_completed_to_stream_response, + }, + }[kind][method](task_id="task", workflow_execution_id="run", event=event) assert SENTINEL not in response.model_dump_json() assert response is not None assert response.data.steps == 4 @@ -556,18 +615,23 @@ def test_pipeline_listen_projects_raw_internal_error_before_base(guard, monkeypa assert len(result) == 1 assert isinstance(result[0], ErrorStreamResponse) assert str(result[0].err) == "native_execution_error" - saver.assert_not_called() + assert saver.calls == [] def test_queue_unknown_value_refuses_before_base_serializer(guard, monkeypatch): - monkeypatch.setattr("core.app.apps.base_app_queue_manager.redis_client", Mock()) + bind_queue_cache(monkeypatch) q = WorkflowAppQueueManager("task", "actor", InvokeFrom.DEBUGGER, "workflow", native_observation=guard) - trip = Mock(side_effect=AssertionError("raw model_dump")) + trip_calls = [] + + def trip(*args, **kwargs): + trip_calls.append((args, kwargs)) + raise AssertionError("raw model_dump") + monkeypatch.setattr(QueueWorkflowSucceededEvent, "model_dump", trip) with pytest.raises(NativeObservationError): q.publish(QueueWorkflowSucceededEvent(outputs={"x": Unrenderable()}), PublishFrom.APPLICATION_MANAGER) assert q._q.empty() - trip.assert_not_called() + assert trip_calls == [] @pytest.mark.parametrize("bad", [None, object()]) @@ -599,12 +663,12 @@ def test_native_no_hitl_or_history_resume(guard): @pytest.mark.parametrize("route", ["projector", "publish", "direct", "converter"]) -@pytest.mark.parametrize("supplied_host", [None, SimpleNamespace()]) +@pytest.mark.parametrize("supplied_host", [None, object()]) def test_resumption_requires_actual_host_not_a_caller_port(guard, monkeypatch, route, supplied_host): from core.app.entities.queue_entities import QueueWorkflowStartedEvent from graphon.entities import WorkflowStartReason - monkeypatch.setattr("core.app.apps.base_app_queue_manager.redis_client", Mock()) + bind_queue_cache(monkeypatch) event = QueueWorkflowStartedEvent(reason=WorkflowStartReason.RESUMPTION) if supplied_host is not None and route != "projector": diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_operation_journal.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_operation_journal.py index d613a0311c0..65f29dd498b 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_operation_journal.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_operation_journal.py @@ -3,7 +3,7 @@ import importlib import importlib.util from copy import deepcopy -from types import SimpleNamespace +from dataclasses import dataclass import pytest @@ -18,10 +18,18 @@ from runtime_contracts.builder.native.contracts import ( PolicyStop, RecordedResult, ) -from runtime_contracts.builder.native.runtime_contracts import NativeCheckpointPayload, NodeJournalState +from runtime_contracts.builder.native.runtime_contracts import ( + NativeCheckpointPayload, + NodeJournalState, + ResolvedNativeTestContext, +) +from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService +from services.dify_builder.native_testing.operation_journal import NodeOperationJournal +from tests.unit_tests.services.dify_builder.native_testing.test_claimed_resume_checkpoint import ClaimedCheckpointCase from tests.unit_tests.services.dify_builder.native_testing.test_claimed_resume_checkpoint import ( metadata_case as metadata_case, # noqa: PLC0414 ) +from tests.unit_tests.services.dify_builder.native_testing.test_runtime_contracts import CONTEXT REF = NativeTestContextRef(request_id="request", invocation_id="invocation", context_digest="a" * 64) PATH = ("root", "iteration:items:2", "tool:send") @@ -50,7 +58,9 @@ class State: raise self.failure if ref != REF: raise BuilderExecutionPolicyError("native_owner_mismatch") - return SimpleNamespace(ref=ref, generation=self.generation, native_run_id="run") + return ResolvedNativeTestContext.model_validate( + {**CONTEXT, "ref": ref, "generation": self.generation, "native_run_id": "run"} + ) def read_checkpoint(self, *, ref, checkpoint): self.load_context(ref=ref) @@ -75,6 +85,40 @@ class Authorization: return self.decision +@dataclass +class ClaimedJournalCase: + owner: ClaimedCheckpointCase + state: State + lifecycle: NativeTestLifecycleService + target: NodeOperationJournal + authorization: Authorization + expected: NativeCheckpointPayload + original: EffectIntent + + +@dataclass +class ImpostorCheckpointLifecycle: + store: State + + def claimed_resume_checkpoint(self, **_kwargs): + return CP + + +@dataclass +class ImpostorCheckpointPayload: + payload: NativeCheckpointPayload + + def model_dump(self, **_kwargs): + return self.payload.model_dump() + + +@dataclass +class ImpostorRunContext: + ref: NativeTestContextRef + generation: int + native_run_id: str + + def make_journal(state=None, authorization=None): state, authorization = state or State(), authorization or Authorization() return journal_class()(state=state, authorization=authorization), state, authorization @@ -533,7 +577,7 @@ def claimed_case(metadata_case, monkeypatch): cursor.before_call(intent=original) state.payload = payload(source.snapshot(ref=c.ref)) target, _, _ = make_journal(state, authorization) - return SimpleNamespace( + return ClaimedJournalCase( owner=c, state=state, lifecycle=c.service, @@ -592,7 +636,7 @@ def test_claimed_restore_requires_actual_lifecycle_and_same_state(claimed_case, c = claimed_case cursor, saved = _retained_cursor(c) if owner == "namespace": - lifecycle = SimpleNamespace(store=c.state, claimed_resume_checkpoint=lambda **_: CP) + lifecycle = ImpostorCheckpointLifecycle(store=c.state) elif owner == "second_state": lifecycle = NativeTestLifecycleService(c.owner.loader, state_store=State()) else: @@ -641,7 +685,7 @@ def test_claimed_restore_revalidates_runtime_arguments_and_actual_context(claime elif fault == "foreign_ref": ref = ref.model_copy(update={"context_digest": "f" * 64}) elif fault == "expected_namespace": - expected = SimpleNamespace(model_dump=lambda **_: c.expected.model_dump()) + expected = ImpostorCheckpointPayload(c.expected) elif fault == "expected_generation_bool": expected = expected.model_copy(update={"generation": False}) elif fault == "expected_payload": @@ -653,7 +697,7 @@ def test_claimed_restore_revalidates_runtime_arguments_and_actual_context(claime elif fault == "context_mode": c.state.context = c.state.context.model_copy(update={"execution_mode": "mock"}) else: - c.state.context = SimpleNamespace(ref=ref, generation=2, native_run_id="run") + c.state.context = ImpostorRunContext(ref=ref, generation=2, native_run_id="run") with pytest.raises(BuilderExecutionPolicyError): c.target.restore_claimed( ref=ref, generation=generation, dispatch_id="dispatch", lifecycle=c.lifecycle, expected_payload=expected diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_pending_decision_guard.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_pending_decision_guard.py index e058af987aa..336482e240c 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_pending_decision_guard.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_pending_decision_guard.py @@ -1,10 +1,15 @@ """Post-read guard compares the complete frozen checkpoint identity.""" import importlib.util -from types import SimpleNamespace import pytest +from models.dify_builder_native_tests import ( + NativeContinuation, + NativeEffectOperation, + NativeInvocationState, + NativePendingAction, +) from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest from runtime_contracts.builder.native.contracts import CheckpointRef, NativeTestContextRef, operation_digest from tests.unit_tests.services.dify_builder.native_testing.test_pending_intents import effect @@ -71,10 +76,10 @@ def test_guard_rejects_changed_generation_checkpoint_key_intent_scope_and_owner( resolved=resolved, tenant_id="t", app_id="app", - pending=SimpleNamespace(**p), - operation=SimpleNamespace(**o), - state=SimpleNamespace(**s), - continuation=SimpleNamespace(**checkpoint), + pending=NativePendingAction(**p), + operation=NativeEffectOperation(**o), + state=NativeInvocationState(context_object_id="context", resolution=[], **s), + continuation=NativeContinuation(payload_ref="private-checkpoint", **checkpoint), scope_digest="e" * 64, expected_version=1, ) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_public_decision_projection.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_public_decision_projection.py index 0a7275e28de..1e3f3f96362 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_public_decision_projection.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_public_decision_projection.py @@ -1,10 +1,10 @@ """Saved decisions survive refresh without creating fresh execution authority.""" from datetime import UTC, datetime, timedelta -from types import SimpleNamespace import pytest +from models.dify_builder_native_tests import NativeEffectGrant from runtime_contracts.builder.native.approval_contracts import NativeApprovalTerms from services.dify_builder.native_testing.public_contracts import PendingActionDisclosure @@ -33,7 +33,12 @@ def test_saved_decision_projects_independently_of_current_grant_validity(decisio max_calls=1, max_dispatches=1, max_cost_units=1, expires_at=(now + timedelta(minutes=1)).isoformat() ), ) - grant = SimpleNamespace( + grant = NativeEffectGrant( + tenant_id="tenant", + request_id="request", + actor_id="actor", + scope_digest="a" * 64, + max_cost_units=1, decision=decision, expires_at=now + timedelta(seconds=-1 if expired else 60), spent_calls=0, diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_public_input_binding.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_public_input_binding.py index c41a40aa723..dcf2486c126 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_public_input_binding.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_public_input_binding.py @@ -8,6 +8,12 @@ from runtime_contracts.builder.execution import BuilderExecutionPolicyError from tests.unit_tests.services.dify_builder.test_execution_policy_service import owned as owned # noqa: PLC0414 +class RejectUnexpectedNativeLaunch: + def launch(self, *, ref): + del ref + pytest.fail("invalid current input reached native launch") + + def prepare(owned): factory, _actor, _app, workflow, sid, tid = owned with factory.begin() as session: @@ -61,8 +67,7 @@ def test_binding_rejects_stale_or_foreign_input(owned, change): current_input_binding(session=session, actor=actor, session_id=sid) -def test_standalone_start_refuses_noncurrent_same_session_input_before_compilation(owned): - from unittest.mock import Mock +def test_standalone_start_refuses_noncurrent_same_session_input_before_compilation(owned, monkeypatch): from uuid import uuid4 from services.dify_builder.native_testing.api_service import NativeAdmissionDependencies, NativeTestApiService @@ -72,12 +77,23 @@ def test_standalone_start_refuses_noncurrent_same_session_input_before_compilati other = str(uuid4()) with factory.begin() as session: session.add(DifyBuilderTestInput(id=other, session_id=sid, source="mock", inputs={}, start_schema_hash="")) - compiler = Mock() + from services.dify_builder.native_testing.context_binding import NativeAdmissionCompiler, NativeRuntimeProfile + + compiler = NativeAdmissionCompiler(session_factory=factory, resources=lambda **_kwargs: ()) + monkeypatch.setattr(compiler, "compile", lambda **_kwargs: pytest.fail("noncurrent input reached compilation")) service = NativeTestApiService( session_factory=factory, actor=actor, resolver=None, - admission=NativeAdmissionDependencies(compiler=compiler, profile=Mock(), launch=Mock()), + admission=NativeAdmissionDependencies( + compiler=compiler, + profile=NativeRuntimeProfile( + profile_id="unit", + implementation_revision="unit-v1", + limits={"calls": 1, "dispatches": 1, "cost_units": 1}, + ), + launch=RejectUnexpectedNativeLaunch(), + ), resolution=None, runtime=None, disclosure=None, @@ -86,12 +102,9 @@ def test_standalone_start_refuses_noncurrent_same_session_input_before_compilati service.start( actor=actor, session_id=sid, test_input_id=other, expected_version=1, mode="native", idempotency_key="start" ) - compiler.compile.assert_not_called() def test_start_rechecks_current_input_under_admission_lock_before_insert(owned, monkeypatch): - from unittest.mock import Mock - from sqlalchemy import func, select from models.dify_builder import DifyBuilderExecutionRequest @@ -109,7 +122,7 @@ def test_start_rechecks_current_input_under_admission_lock_before_insert(owned, commit.context = {"retained_test_input_ref": tid} monkeypatch.setattr(compiler, "recheck_local", replace_current) - launch = Mock() + launch = RejectUnexpectedNativeLaunch() service = NativeTestApiService( session_factory=factory, actor=actor, @@ -131,7 +144,6 @@ def test_start_rechecks_current_input_under_admission_lock_before_insert(owned, service.start( actor=actor, session_id=sid, test_input_id=tid, expected_version=1, mode="native", idempotency_key="start" ) - launch.launch.assert_not_called() with factory() as session: assert session.scalar(select(func.count()).select_from(DifyBuilderExecutionRequest)) == 0 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 2b407f53723..fe5bb89615e 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 @@ -3,18 +3,20 @@ from __future__ import annotations from collections.abc import Mapping -from types import SimpleNamespace from typing import TYPE_CHECKING, cast -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest +from sqlalchemy.orm import sessionmaker +from core.dify_builder.models import Actor from graphon.engine import Engine from graphon.entities.base_node_data import BaseNodeData from graphon.http import HttpResponse from graphon.nodes.base.node import Node from graphon.runtime import InitParams, RuntimeState from runtime_contracts.builder.native.contracts import NativeTestContextRef, OperationKey +from services.dify_builder.native_testing.api_service import NativeTestApiService from services.dify_builder.native_testing.control import ( BoundaryOwnerState, NativeControlAccepted, @@ -35,20 +37,44 @@ from services.dify_builder.native_testing.context_binding import NativeContextUn from tests.unit_tests.services.dify_builder.native_testing.test_runtime_contracts import CONTEXT -def bridge_fixture() -> tuple[ - ResolvedNativeTestContext, NativeRuntimeBridge, NativeCollaborators, Mock, BoundaryOwnerState, Mock -]: - from services.dify_builder.native_testing.runtime_bridge import NativeCollaborators, NativeRuntimeBridge +def _api(): + return NativeTestApiService( + session_factory=sessionmaker(), + actor=Actor(account_id="actor-1", tenant_id="tenant-1"), + resolver=None, + admission=None, + resolution=None, + runtime=None, + disclosure=None, + ) + +class _BridgeCollaborators: + """Explicit collaborator composition port for constructor-only bridge tests.""" + + def __init__(self, bundle: NativeCollaborators): + self.bundle = bundle + + def provide(self, *, association: NativeNodePreparation) -> NativeCollaborators: + assert isinstance(association, NativeNodePreparation) + return self.bundle + + def node_arguments(self, **_kwargs) -> Mapping[str, object]: + return {"http_client": self.bundle.http} + + +def bridge_fixture( + mocker, +) -> tuple[ResolvedNativeTestContext, NativeRuntimeBridge, NativeCollaborators, Mock, BoundaryOwnerState, Mock]: context = ResolvedNativeTestContext.model_validate(CONTEXT) - state = Mock(load_context=Mock(return_value=context)) + state = _api().store + mocker.patch.object(state, "load_context", return_value=context) bundle = NativeCollaborators(**{name: object() for name in NativeCollaborators.__dataclass_fields__}) - provider = Mock(return_value=bundle) - from services.dify_builder.native_testing.control import BoundaryOwnerState - + composition = _BridgeCollaborators(bundle) + provider = mocker.patch.object(composition, "provide", return_value=bundle) owner = BoundaryOwnerState(ref=context.ref) sink = SyntheticSink() - arguments = Mock(return_value={"http_client": bundle.http}) + arguments = mocker.patch.object(composition, "node_arguments", return_value={"http_client": bundle.http}) bridge = NativeRuntimeBridge( state=state, collaborators=provider, @@ -59,12 +85,11 @@ def bridge_fixture() -> tuple[ return context, bridge, bundle, provider, owner, arguments -def test_marked_factory_requires_bridge_before_eager_model_setup(monkeypatch: pytest.MonkeyPatch) -> None: +def test_marked_factory_requires_bridge_before_eager_model_setup(mocker) -> None: from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from services.workflow_runtime import node_factory - eager = Mock(side_effect=AssertionError("ordinary setup")) - monkeypatch.setattr(node_factory, "build_dify_model_access", eager) + eager = mocker.patch.object(node_factory, "build_dify_model_access", side_effect=AssertionError("ordinary setup")) context = DifyRunContext( tenant_id="tenant-1", app_id="app-1", @@ -75,19 +100,20 @@ def test_marked_factory_requires_bridge_before_eager_model_setup(monkeypatch: py ) with pytest.raises(NativeContextUnavailable, match="native_context_missing"): node_factory.DifyNodeFactory( - init_params=cast(InitParams, SimpleNamespace(run_context={"_dify": context})), + init_params=InitParams( + run_context={"_dify": context}, workflow_id="workflow", graph_config={}, call_depth=0 + ), runtime_state=cast(RuntimeState, object()), ) eager.assert_not_called() -def test_factory_preserves_bridge_on_runtime_clone_and_checks_owners(monkeypatch: pytest.MonkeyPatch) -> None: +def test_factory_preserves_bridge_on_runtime_clone_and_checks_owners(mocker) -> None: from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from services.workflow_runtime import node_factory - context, bridge, _, provider, _, _ = bridge_fixture() - eager = Mock(side_effect=AssertionError("ordinary setup")) - monkeypatch.setattr(node_factory, "build_dify_model_access", eager) + context, bridge, _, provider, _, _ = bridge_fixture(mocker) + eager = mocker.patch.object(node_factory, "build_dify_model_access", side_effect=AssertionError("ordinary setup")) run = DifyRunContext( tenant_id=context.tenant_id, app_id=context.app_id, @@ -118,35 +144,34 @@ def test_factory_preserves_bridge_on_runtime_clone_and_checks_owners(monkeypatch ) -def test_unmarked_factory_cannot_accept_native_bridge(monkeypatch: pytest.MonkeyPatch) -> None: +def test_unmarked_factory_cannot_accept_native_bridge(mocker) -> None: from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from services.workflow_runtime import node_factory - _, bridge, _, _, _, _ = bridge_fixture() - eager = Mock(side_effect=AssertionError("ordinary setup")) - monkeypatch.setattr(node_factory, "build_dify_model_access", eager) + _, bridge, _, _, _, _ = bridge_fixture(mocker) + eager = mocker.patch.object(node_factory, "build_dify_model_access", side_effect=AssertionError("ordinary setup")) run = DifyRunContext( tenant_id="t", app_id="a", user_id="u", user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER ) with pytest.raises(NativeContextUnavailable, match="native_context_missing"): node_factory.DifyNodeFactory( - init_params=cast(InitParams, SimpleNamespace(run_context={"_dify": run}, workflow_id="w")), + init_params=InitParams(run_context={"_dify": run}, workflow_id="w", graph_config={}, call_depth=0), runtime_state=cast(RuntimeState, object()), native_runtime=bridge, ) eager.assert_not_called() -def test_native_factory_constructs_real_registered_start_without_resolving_occurrence() -> None: +def test_native_factory_constructs_real_registered_start_without_resolving_occurrence(mocker) -> None: from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from graphon.nodes.start.start_node import StartNode from graphon.runtime import RuntimeState, VariablePool from services.workflow_runtime.node_factory import DifyGraphInitContext, DifyNodeFactory - context, bridge, bundle, provider, _, arguments = bridge_fixture() + context, bridge, bundle, provider, _, arguments = bridge_fixture(mocker) node: dict[str, object] = {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}} context = context.model_copy(update={"graph": {"nodes": [node], "edges": []}}) - cast(Mock, bridge.state).load_context.return_value = context + bridge.state.load_context.return_value = context arguments.return_value = dict[str, object]() run = DifyRunContext( @@ -191,13 +216,13 @@ class SyntheticSink: return NativeControlAccepted(record=record) -def preparation_fixture() -> tuple[ - ResolvedNativeTestContext, NativeRuntimeBridge, NativeNodePreparation, Node[BaseNodeData], Mock -]: +def preparation_fixture( + mocker, +) -> tuple[ResolvedNativeTestContext, NativeRuntimeBridge, NativeNodePreparation, Node[BaseNodeData], Mock]: from graphon.nodes.start.start_node import StartNode from graphon.runtime import InitParams, RuntimeState, VariablePool - context, bridge, _, provider, _, arguments = bridge_fixture() + context, bridge, _, provider, _, arguments = bridge_fixture(mocker) runtime = RuntimeState(workflow_id="unit", variable_pool=VariablePool(), start_at=0) arguments.return_value = dict[str, object]() prepared = bridge.prepare_node( @@ -217,9 +242,9 @@ def preparation_fixture() -> tuple[ return context, bridge, prepared, node, provider -def test_preparation_and_constructor_cannot_open_context_or_bound_occurrence() -> None: - context, bridge, prepared, node, _ = preparation_fixture() - cast(Mock, bridge.state).load_context.assert_not_called() +def test_preparation_and_constructor_cannot_open_context_or_bound_occurrence(mocker) -> None: + context, bridge, prepared, node, _ = preparation_fixture(mocker) + bridge.state.load_context.assert_not_called() for read in ( lambda: prepared.execution_id, lambda: prepared.occurrence_path, @@ -232,16 +257,16 @@ def test_preparation_and_constructor_cannot_open_context_or_bound_occurrence() - for read in (lambda: prepared.execution_id, prepared.resolve, lambda: prepared.runtime_state): with pytest.raises(NativeContextUnavailable, match="native_execution_id_missing"): read() - cast(Mock, bridge.state).load_context.assert_not_called() + bridge.state.load_context.assert_not_called() node.bind_execution_id("00000000-0000-4000-8000-000000000001") assert prepared.resolve().ref == context.ref assert prepared.runtime_state is node.runtime_state -def test_constructor_effect_attempt_is_refused_before_any_context_read() -> None: +def test_constructor_effect_attempt_is_refused_before_any_context_read(mocker) -> None: from graphon.nodes.start.start_node import StartNode - context, bridge, _, provider, _, _ = bridge_fixture() + context, bridge, _, provider, _, _ = bridge_fixture(mocker) def eager(*, association: NativeNodePreparation) -> NativeCollaborators: association.resolve() @@ -252,14 +277,14 @@ def test_constructor_effect_attempt_is_refused_before_any_context_read() -> None bridge.prepare_node( ref=context.ref, node_id="start", node_data={}, node_class=StartNode, runtime_state=object() ) - cast(Mock, bridge.state).load_context.assert_not_called() + bridge.state.load_context.assert_not_called() @pytest.mark.parametrize("mutation", ["class", "node_id", "runtime", "double", "ref", "binding", "rebind"]) -def test_association_rejects_foreign_or_changed_identity_without_context_read(mutation: str) -> None: +def test_association_rejects_foreign_or_changed_identity_without_context_read(mocker, mutation: str) -> None: from graphon.nodes.start.start_node import StartNode - context, bridge, prepared, node, _ = preparation_fixture() + context, bridge, prepared, node, _ = preparation_fixture(mocker) if mutation == "class": foreign = StartNode( node_id="start", @@ -293,14 +318,14 @@ def test_association_rejects_foreign_or_changed_identity_without_context_read(mu with pytest.raises(NativeContextUnavailable) as caught: prepared.resolve() assert "private-canary" not in str(caught.value) - cast(Mock, bridge.state).load_context.assert_not_called() + bridge.state.load_context.assert_not_called() -def test_preparation_requires_exact_owner_ref_and_explicit_control_dependencies() -> None: +def test_preparation_requires_exact_owner_ref_and_explicit_control_dependencies(mocker) -> None: from graphon.nodes.start.start_node import StartNode from services.dify_builder.native_testing.runtime_bridge import NativeRuntimeBridge - context, bridge, _, provider, _, _ = bridge_fixture() + context, bridge, _, provider, _, _ = bridge_fixture(mocker) with pytest.raises(NativeContextUnavailable, match="native_context_mismatch"): bridge.prepare_node( ref=context.ref.model_copy(update={"invocation_id": "foreign"}), @@ -320,12 +345,12 @@ def test_preparation_requires_exact_owner_ref_and_explicit_control_dependencies( ) -def test_effect_revalidates_full_private_context_each_time() -> None: - context, bridge, prepared, node, _ = preparation_fixture() +def test_effect_revalidates_full_private_context_each_time(mocker) -> None: + context, bridge, prepared, node, _ = preparation_fixture(mocker) prepared.attach(node) node.bind_execution_id("00000000-0000-4000-8000-000000000001") assert prepared.resolve().ref == context.ref - cast(Mock, bridge.state).load_context.return_value = context.model_copy( + bridge.state.load_context.return_value = context.model_copy( update={"ref": context.ref.model_copy(update={"invocation_id": "foreign"})} ) with pytest.raises(NativeContextUnavailable, match="native_context_mismatch"): @@ -414,7 +439,7 @@ class DeniedFiles: def native_factory( - config: Mapping[str, object], runtime: RuntimeState, witness: EffectsWitness + mocker, config: Mapping[str, object], runtime: RuntimeState, witness: EffectsWitness ) -> tuple[DifyNodeFactory, BoundaryOwnerState, SyntheticSink, list[NativeNodePreparation]]: from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from graphon.nodes.code.limits import CodeNodeLimits @@ -424,7 +449,8 @@ def native_factory( from services.workflow_runtime.node_factory import DifyGraphInitContext, DifyNodeFactory context = ResolvedNativeTestContext.model_validate({**CONTEXT, "graph": config}) - state = Mock(load_context=Mock(return_value=context)) + state = _api().store + mocker.patch.object(state, "load_context", return_value=context) owner, sink = BoundaryOwnerState(ref=context.ref), SyntheticSink() witness.owner = owner associations: list[NativeNodePreparation] = [] @@ -502,12 +528,12 @@ def native_factory( def native_engine( - config: Mapping[str, object], runtime: RuntimeState, witness: EffectsWitness + mocker, config: Mapping[str, object], runtime: RuntimeState, witness: EffectsWitness ) -> tuple[Engine, BoundaryOwnerState, SyntheticSink, list[NativeNodePreparation]]: from graphon.engine.command import InMemoryChannel from graphon.graph.graph import Graph - factory, owner, sink, associations = native_factory(config, runtime, witness) + factory, owner, sink, associations = native_factory(mocker, config, runtime, witness) graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="start") engine = Engine( graph=graph, @@ -523,7 +549,7 @@ def raw_node(node_id: str, kind: str, **data: object) -> dict[str, object]: @pytest.mark.parametrize("mode", ["allow", "wait"]) -def test_real_factory_http_effect_uses_public_binding_and_marked_control(mode: str) -> None: +def test_real_factory_http_effect_uses_public_binding_and_marked_control(mocker, mode: str) -> None: from graphon.engine_events.graph import GraphRunPausedEvent, GraphRunSucceededEvent from graphon.engine_events.node import NodeRunPauseRequestedEvent, NodeRunStartedEvent from graphon.runtime import RuntimeState, VariablePool @@ -546,7 +572,10 @@ def test_real_factory_http_effect_uses_public_binding_and_marked_control(mode: s } witness = EffectsWitness(mode) engine, owner, sink, associations = native_engine( - config, RuntimeState(workflow_id=CONTEXT["workflow_id"], variable_pool=VariablePool(), start_at=0), witness + mocker, + config, + RuntimeState(workflow_id=CONTEXT["workflow_id"], variable_pool=VariablePool(), start_at=0), + witness, ) assert witness.entries == [] events = list(engine.run()) @@ -567,7 +596,7 @@ def test_real_factory_http_effect_uses_public_binding_and_marked_control(mode: s @pytest.mark.parametrize("kind", ["loop", "iteration"]) -def test_actual_engine_equal_inputs_distinct_occurrences_restore_retry_and_fresh_clone(kind: str) -> None: +def test_actual_engine_equal_inputs_distinct_occurrences_restore_retry_and_fresh_clone(mocker, kind: str) -> None: from graphon.engine_events.graph import GraphRunPartialSucceededEvent, GraphRunPausedEvent from graphon.runtime import RuntimeState, VariablePool @@ -627,7 +656,7 @@ def test_actual_engine_equal_inputs_distinct_occurrences_restore_retry_and_fresh pool.add(("start", "items"), ["same", "same"]) runtime = RuntimeState(workflow_id=CONTEXT["workflow_id"], variable_pool=pool, start_at=0) before = EffectsWitness("wait") - first, owner, sink, preparations = native_engine(config, runtime, before) + first, owner, sink, preparations = native_engine(mocker, config, runtime, before) assert isinstance(list(first.run())[-1], GraphRunPausedEvent) assert before.dispatches == [] assert owner.status == "WAIT_RECORDED" @@ -639,7 +668,7 @@ def test_actual_engine_equal_inputs_distinct_occurrences_restore_retry_and_fresh assert {record.native_execution_id for record in sink.records} == {key.occurrence_path[0] for key in waited_keys} restored = RuntimeState.from_snapshot(runtime.dumps()) after = EffectsWitness("retry") - second, new_owner, _, new_preparations = native_engine(config, restored, after) + second, new_owner, _, new_preparations = native_engine(mocker, config, restored, after) terminal = list(second.run())[-1] assert isinstance(terminal, GraphRunPartialSucceededEvent) assert terminal.exceptions_count == 2 @@ -671,50 +700,50 @@ def test_actual_engine_equal_inputs_distinct_occurrences_restore_retry_and_fresh "reserved", ["node_id", "data", "graph_init_params", "graph_runtime_state", "native_control_owner", "native_control_sink"], ) -def test_constructor_adapter_cannot_replace_attached_identity_or_control_owner(reserved: str) -> None: +def test_constructor_adapter_cannot_replace_attached_identity_or_control_owner(mocker, reserved: str) -> None: from graphon.nodes.start.start_node import StartNode - context, bridge, _, _, _, arguments = bridge_fixture() + context, bridge, _, _, _, arguments = bridge_fixture(mocker) arguments.return_value = {reserved: "private-canary"} with pytest.raises(NativeContextUnavailable, match="native_node_arguments_invalid") as caught: bridge.prepare_node( ref=context.ref, node_id="start", node_data={}, node_class=StartNode, runtime_state=object() ) assert "private-canary" not in str(caught.value) - cast(Mock, bridge.state).load_context.assert_not_called() + bridge.state.load_context.assert_not_called() @pytest.mark.parametrize("invalid", [None, "missing-slot"]) -def test_missing_deferred_collaborator_never_uses_ordinary_fallback(invalid: str | None) -> None: +def test_missing_deferred_collaborator_never_uses_ordinary_fallback(mocker, invalid: str | None) -> None: from dataclasses import replace from graphon.nodes.start.start_node import StartNode - context, bridge, bundle, provider, _, _ = bridge_fixture() + context, bridge, bundle, provider, _, _ = bridge_fixture(mocker) provider.return_value = None if invalid is None else replace(bundle, http=None) with pytest.raises(NativeContextUnavailable, match="native_collaborator_unavailable"): bridge.prepare_node( ref=context.ref, node_id="start", node_data={}, node_class=StartNode, runtime_state=object() ) - cast(Mock, bridge.state).load_context.assert_not_called() + bridge.state.load_context.assert_not_called() @pytest.mark.parametrize("field", ["ref", "node_id", "node_class", "arguments"]) -def test_preparation_public_expected_identity_cannot_be_replaced(field: str) -> None: - _, _, prepared, _, _ = preparation_fixture() +def test_preparation_public_expected_identity_cannot_be_replaced(mocker, field: str) -> None: + _, _, prepared, _, _ = preparation_fixture(mocker) with pytest.raises(AttributeError): setattr(prepared, field, "private-canary") -def test_constructor_argument_mapping_cannot_replace_control_dependencies() -> None: - _, _, prepared, _, _ = preparation_fixture() +def test_constructor_argument_mapping_cannot_replace_control_dependencies(mocker) -> None: + _, _, prepared, _, _ = preparation_fixture(mocker) with pytest.raises(TypeError): cast(dict[str, object], prepared.arguments)["native_control_owner"] = "private-canary" @pytest.mark.parametrize("mutation", ["id", "runtime", "ref", "execution_id"]) -def test_effect_rechecks_attached_identity_after_private_context_read(mutation: str) -> None: - context, bridge, prepared, node, _ = preparation_fixture() +def test_effect_rechecks_attached_identity_after_private_context_read(mocker, mutation: str) -> None: + context, bridge, prepared, node, _ = preparation_fixture(mocker) prepared.attach(node) node.bind_execution_id("00000000-0000-4000-8000-000000000001") @@ -730,14 +759,14 @@ def test_effect_rechecks_attached_identity_after_private_context_read(mutation: node.bind_execution_id("00000000-0000-4000-8000-000000000002") return context - cast(Mock, bridge.state).load_context.side_effect = load + bridge.state.load_context.side_effect = load with pytest.raises(NativeContextUnavailable) as caught: prepared.resolve() assert "private-canary" not in str(caught.value) -def test_bound_node_getter_requires_attachment_and_public_binding_without_context_read() -> None: - _, bridge, prepared, node, _ = preparation_fixture() +def test_bound_node_getter_requires_attachment_and_public_binding_without_context_read(mocker) -> None: + _, bridge, prepared, node, _ = preparation_fixture(mocker) with pytest.raises(NativeContextUnavailable, match="native_node_unattached"): _ = prepared.bound_node prepared.attach(node) @@ -745,15 +774,15 @@ def test_bound_node_getter_requires_attachment_and_public_binding_without_contex _ = prepared.bound_node node.bind_execution_id("00000000-0000-4000-8000-000000000001") assert prepared.bound_node is node - cast(Mock, bridge.state).load_context.assert_not_called() + bridge.state.load_context.assert_not_called() property_name = "bound_node" with pytest.raises(AttributeError): setattr(prepared, property_name, object()) @pytest.mark.parametrize("mutation", ["node_id", "runtime", "class", "ref", "execution_id"]) -def test_bound_node_getter_rejects_attached_identity_drift_without_context_read(mutation: str) -> None: - context, bridge, prepared, node, _ = preparation_fixture() +def test_bound_node_getter_rejects_attached_identity_drift_without_context_read(mocker, mutation: str) -> None: + context, bridge, prepared, node, _ = preparation_fixture(mocker) prepared.attach(node) node.bind_execution_id("00000000-0000-4000-8000-000000000001") assert prepared.bound_node is node @@ -772,12 +801,12 @@ def test_bound_node_getter_rejects_attached_identity_drift_without_context_read( with pytest.raises(NativeContextUnavailable) as caught: _ = prepared.bound_node assert "private-canary" not in str(caught.value) - cast(Mock, bridge.state).load_context.assert_not_called() + bridge.state.load_context.assert_not_called() @pytest.mark.parametrize("foreign", ["owner", "sink"]) -def test_attach_rejects_foreign_control_composition_with_same_native_class_and_runtime(foreign: str) -> None: - context, bridge, prepared, node, _ = preparation_fixture() +def test_attach_rejects_foreign_control_composition_with_same_native_class_and_runtime(mocker, foreign: str) -> None: + context, bridge, prepared, node, _ = preparation_fixture(mocker) arguments = dict(prepared.arguments) if foreign == "owner": arguments["native_control_owner"] = BoundaryOwnerState(ref=context.ref) @@ -792,11 +821,38 @@ def test_attach_rejects_foreign_control_composition_with_same_native_class_and_r ) with pytest.raises(NativeContextUnavailable, match="native_node_mismatch"): prepared.attach(other) - cast(Mock, bridge.state).load_context.assert_not_called() + bridge.state.load_context.assert_not_called() + + +def _delivery_runtime(mocker, case): + from services.dify_builder.native_testing.lifecycle import NativeDispatchRef + from services.dify_builder.native_testing.runtime import DifyNativeResumeSettlement, DifyNativeRuntime + from services.dify_builder.native_testing.sandbox.host import NativeCodeHostOwner + + api = _api() + load = mocker.patch.object(api.store, "load_context", return_value=case.state.context) + api.lifecycle.resume_settlement = DifyNativeResumeSettlement() + from services.dify_builder.native_testing.host_execution import NativeHostLaunch + + # This constructor-only bridge contract does not acquire the telemetry owner. + with patch.object(NativeHostLaunch, "bind_observation"): + runtime = DifyNativeRuntime( + delivery=NativeDispatchRef( + ref=case.state.context.ref, generation=case.state.context.generation, dispatch_id="dispatch" + ), + lifecycle=api.lifecycle, + registry=case.registry, + ) + runtime.host = case.host + runtime._journal = case.journal + runtime.authorization = case.authorization + runtime.code_owner = NativeCodeHostOwner(host=case.host, loader=api.loader) + load.reset_mock() + return runtime @pytest.mark.parametrize("kind", ["code", "template-transform"]) -def test_code_delivery_composes_actual_native_protocol_without_eager_payload(kind): +def test_code_delivery_composes_actual_native_protocol_without_eager_payload(mocker, kind): from graphon.nodes.code.code_node import CodeNode from graphon.nodes.code.entities import CodeNodeData from graphon.nodes.template_transform.entities import TemplateTransformNodeData @@ -816,16 +872,10 @@ def test_code_delivery_composes_actual_native_protocol_without_eager_payload(kin case = code_case() q, _ = qualification() - store = Mock() + runtime = _delivery_runtime(mocker, case) + runtime.code_qualification = q + store = runtime.lifecycle.store store.load_context.side_effect = AssertionError("eager protected input") - runtime = SimpleNamespace( - host=case.host, - lifecycle=SimpleNamespace(loader=object(), store=store), - code_qualification=q, - _journal=case.journal, - authorization=case.authorization, - code_owner=object(), - ) bridge = build_delivery_bridge(runtime=cast(DifyNativeRuntime, runtime)) state = RuntimeState(workflow_id=CONTEXT["workflow_id"], variable_pool=VariablePool(), start_at=0) cls = CodeNode if kind == "code" else TemplateTransformNode @@ -858,7 +908,7 @@ def test_code_delivery_composes_actual_native_protocol_without_eager_payload(kin store.load_context.assert_not_called() -def test_default_delivery_refuses_code_before_protected_payload(): +def test_default_delivery_refuses_code_before_protected_payload(mocker): from graphon.nodes.code.code_node import CodeNode from graphon.nodes.code.entities import CodeNodeData from runtime_contracts.builder.execution import BuilderExecutionPolicyError @@ -871,13 +921,9 @@ def test_default_delivery_refuses_code_before_protected_payload(): ) case = code_case() - store = Mock() + runtime = _delivery_runtime(mocker, case) + store = runtime.lifecycle.store store.load_context.side_effect = AssertionError("unqualified payload read") - runtime = SimpleNamespace( - host=case.host, - lifecycle=SimpleNamespace(loader=object(), store=store), - code_qualification=None, - ) bridge = build_delivery_bridge(runtime=cast(DifyNativeRuntime, runtime)) with pytest.raises(BuilderExecutionPolicyError, match="native_capability_unavailable"): bridge.prepare_node( diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_contracts.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_contracts.py index e17ccebb3b0..2126d9e0573 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_contracts.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_contracts.py @@ -272,7 +272,20 @@ def test_ports_expose_keyword_only_persistence_and_child_invocation_inputs(): } for port, methods in expected.items(): for name, names in methods.items(): - params = list(inspect.signature(getattr(port, name)).parameters.values())[1:] + methods_by_port = { + NativeStatePort: { + "load_context": NativeStatePort.load_context, + "commit_checkpoint": NativeStatePort.commit_checkpoint, + "read_checkpoint": NativeStatePort.read_checkpoint, + "store_result": NativeStatePort.store_result, + "load_result": NativeStatePort.load_result, + }, + ChildInvocationPort: { + "get_or_create": ChildInvocationPort.get_or_create, + "complete": ChildInvocationPort.complete, + }, + } + params = list(inspect.signature(methods_by_port[port][name]).parameters.values())[1:] assert tuple(param.name for param in params) == names assert all(param.kind == inspect.Parameter.KEYWORD_ONLY for param in params) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_sealing.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_sealing.py index bc079c37cf0..f4bf75e820d 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_sealing.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_sealing.py @@ -3,23 +3,58 @@ from collections import UserList from contextlib import contextmanager from copy import deepcopy -from types import SimpleNamespace -from typing import cast +from dataclasses import dataclass +from datetime import UTC, datetime import pytest +from sqlalchemy import inspect +from sqlalchemy.orm import sessionmaker +from core.dify_builder.models import Actor from models.dify_builder import DifyBuilderExecutionRequest -from models.dify_builder_native_tests import NativeDispatch, NativeInvocationState, NativeTestInvocation +from models.dify_builder_native_tests import ( + NativeDispatch, + NativeInvocationState, + NativeTestInvocation, + NativeTestScope, +) from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import NativeTestContextRef from runtime_contracts.builder.native.runtime_contracts import NativeWorkflowOutcome +from services.dify_builder.native_testing.http_binding import NativeHttpBindingResolver from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService -from services.dify_builder.native_testing.repository import OwnedNativeTestLoader +from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader from services.dify_builder.native_testing.state_store import NativeExecutionStateStore REF = NativeTestContextRef(request_id="request", invocation_id="root", context_digest="a" * 64) +def control_owner(owner_state: str) -> OwnedNativeTest: + return OwnedNativeTest( + ref=REF, + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=3, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state=owner_state, + ) + + +def state_snapshots(states): + return deepcopy( + [ + tuple(inspect(row).attrs[column.key].value for column in inspect(NativeInvocationState).column_attrs) + for row in states + ] + ) + + class Rows(UserList): def all(self): return list(self) @@ -29,9 +64,22 @@ class SQLBoundary: """Explicit owner rows; deliberately does not pretend to evaluate SQL joins.""" def __init__(self): - self.request = SimpleNamespace(id="request", tenant_id="tenant", state="running") + self.request = DifyBuilderExecutionRequest( + id="request", + tenant_id="tenant", + state="running", + app_id="app", + workflow_id="workflow", + actor_id="actor", + session_id="session", + test_input_id="input", + context={}, + context_digest=REF.context_digest, + ) self.states = [ - SimpleNamespace( + NativeInvocationState( + context_object_id="context-" + identity, + resolution=[], request_id="request", tenant_id="tenant", invocation_id=identity, @@ -48,18 +96,21 @@ class SQLBoundary: for identity, parent in (("root", None), ("child", "root")) ] self.invocations = [ - SimpleNamespace(request_id="request", tenant_id="tenant", invocation_id=s.invocation_id, active=True) + NativeTestInvocation( + request_id="request", tenant_id="tenant", invocation_id=s.invocation_id, active=True, bindings={} + ) for s in self.states ] self.dispatches = [ - SimpleNamespace( + NativeDispatch( + idempotency_digest="b" * 64, id=identity, request_id="request", tenant_id="tenant", invocation_id=invocation, generation=3, state=state, - claim_expires_at="retained-lease", + claim_expires_at=datetime(2030, 1, 1, tzinfo=UTC), ) for identity, invocation, state in (("dispatch", "root", "claimed"), ("queued", "child", "queued")) ] @@ -97,31 +148,51 @@ class Loader: self.sql = sql self.events = [] self.commit_error = None - self.scope = SimpleNamespace(cancelled=False, generation=27) + self.scope = NativeTestScope( + cancelled=False, + generation=27, + tenant_id="tenant", + request_id="request", + task_approved=False, + max_calls=16, + max_dispatches=16, + max_cost_units=16, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + budget_limits={}, + budget_spent={}, + profile={}, + ) @contextmanager def locked(self, *, ref, settlement): assert ref == REF assert settlement is True self.events.append("writer-enter") - owned = SimpleNamespace( - ref=ref, tenant_id="tenant", app_id="app", workflow_id="workflow", state=self.sql.request.state - ) + owned = control_owner(self.sql.request.state) yield self.sql, owned, self.scope self.events.append("committed") if self.commit_error: raise self.commit_error +@dataclass +class StopSealCase: + sql: SQLBoundary + loader: Loader + lifecycle: NativeTestLifecycleService + + @pytest.fixture -def case(): +def case(monkeypatch): sql = SQLBoundary() loader = Loader(sql) # These labeled boundary objects grant no physical, SQL, or storage proof. - lifecycle = NativeTestLifecycleService( - cast(OwnedNativeTestLoader, loader), state_store=cast(NativeExecutionStateStore, SimpleNamespace()) + owner_loader = OwnedNativeTestLoader( + sessionmaker(), actor=Actor(tenant_id="tenant", account_id="actor"), resolver=NativeHttpBindingResolver() ) - return SimpleNamespace(sql=sql, loader=loader, lifecycle=lifecycle) + monkeypatch.setattr(owner_loader, "locked", loader.locked) + lifecycle = NativeTestLifecycleService(owner_loader, state_store=NativeExecutionStateStore(owner_loader)) + return StopSealCase(sql=sql, loader=loader, lifecycle=lifecycle) def seal(case, **changes): @@ -131,14 +202,14 @@ def seal(case, **changes): def test_stop_seals_authority_after_commit_without_claiming_physical_cleanup(case): - states_before = deepcopy([vars(s) for s in case.sql.states]) + states_before = state_snapshots(case.sql.states) assert seal(case) is None assert case.loader.events == ["writer-enter", "committed"] assert case.sql.request.state == "sealed" assert not any(s.active for s in case.sql.invocations) - assert [vars(s) for s in case.sql.states] == states_before + assert state_snapshots(case.sql.states) == states_before assert case.sql.dispatches[0].state == "claimed" - assert case.sql.dispatches[0].claim_expires_at == "retained-lease" + assert case.sql.dispatches[0].claim_expires_at == datetime(2030, 1, 1, tzinfo=UTC) assert case.sql.dispatches[1].state == "cancelled" assert not case.loader.scope.cancelled @@ -181,18 +252,17 @@ def test_stop_rejects_orphan_dispatch_before_any_authority_or_outbox_mutation(ca orphan = deepcopy(case.sql.dispatches[1]) orphan.id, orphan.invocation_id, orphan.state = "orphan-dispatch", "missing-invocation", dispatch_state case.sql.dispatches.append(orphan) + rows = [case.sql.request, case.loader.scope, *case.sql.states, *case.sql.invocations, *case.sql.dispatches] before = deepcopy( - [vars(case.sql.request), vars(case.loader.scope)] - + [vars(row) for row in (*case.sql.states, *case.sql.invocations, *case.sql.dispatches)] + [tuple(inspect(row).attrs[column.key].value for column in inspect(type(row)).column_attrs) for row in rows] ) with pytest.raises(BuilderExecutionPolicyError, match="native_owner_mismatch"): seal(case) - assert ( - [vars(case.sql.request), vars(case.loader.scope)] - + [vars(row) for row in (*case.sql.states, *case.sql.invocations, *case.sql.dispatches)] - ) == before + assert [ + tuple(inspect(row).attrs[column.key].value for column in inspect(type(row)).column_attrs) for row in rows + ] == before assert case.loader.events == ["writer-enter"] @@ -212,7 +282,7 @@ def test_sealed_request_cannot_be_finalized_as_completed_or_resumable_wait(case, case.sql.request.state = "sealed" for invocation in case.sql.invocations: invocation.active = False - owned = SimpleNamespace(ref=REF, tenant_id="tenant", state="sealed") + owned = control_owner("sealed") outcome = NativeWorkflowOutcome( status=status, ref=REF, diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_settlement.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_settlement.py index 2b3b881f71a..07683a99514 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_settlement.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_settlement.py @@ -2,27 +2,27 @@ from threading import Event, Thread from time import monotonic -from types import SimpleNamespace import pytest -from runtime_contracts.builder.native.contracts import NativeTestContextRef -from services.dify_builder.native_testing.control import BoundaryOwnerState, NativeControlRecord, NativePolicyStop +from services.dify_builder.native_testing.control import NativeControlRecord, NativePolicyStop from services.dify_builder.native_testing.stop_settlement import NativeEvidenceReadGate def gate_case(): - ref = NativeTestContextRef(request_id="request", invocation_id="invocation", context_digest="a" * 64) + from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import LaunchCase + + case = LaunchCase() + ref = case.state.context.ref record = NativeControlRecord( control=NativePolicyStop(ref=ref, reason_code="effect_denied"), node_id="http", native_execution_id="execution", carrier_ref="carrier", ) - owner = BoundaryOwnerState(ref=ref) + owner = case.host.boundary_owner owner._begin(record) - host = SimpleNamespace(boundary_owner=owner, lifetime=SimpleNamespace(unknown=False)) - return NativeEvidenceReadGate(host), owner, record + return NativeEvidenceReadGate(case.host), owner, record def test_stop_waits_for_active_read_and_nested_read_finishes_without_deadlock(): diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_stream_runtime.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_stream_runtime.py index e536d75dda8..1e77d7e2832 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_stream_runtime.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_stream_runtime.py @@ -11,11 +11,11 @@ import importlib.util from inspect import GEN_SUSPENDED, getgeneratorstate from threading import Event, Thread, current_thread from time import monotonic -from types import SimpleNamespace from typing import cast import pytest +from core.dify_builder.models import Actor from graphon.enums import WorkflowExecutionStatus from models.enums import CreatorUserRole from models.workflow import WorkflowRun @@ -23,7 +23,9 @@ from runtime_contracts.builder.native.contracts import ApprovalWait, CheckpointR from runtime_contracts.builder.native.runtime_contracts import NativeCheckpointPayload from services.dify_builder.native_testing.generation_wiring import HostOwnedNativeGeneratorStream from services.dify_builder.native_testing.host_capacity import NativeHostCapacity +from services.dify_builder.native_testing.http_binding import NativeHttpBindingResolver from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService +from services.dify_builder.native_testing.repository import OwnedNativeTestLoader from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import LaunchCase, native_entry @@ -116,12 +118,14 @@ class StreamCase: scalar = staticmethod(read) self.sessions = SessionPort - lifecycle = SimpleNamespace( - _capacity=None, - loader=SimpleNamespace(session_factory=self.sessions), - pause=self.pause, - _mark_unknown=lambda **_kwargs: self.events.append("quarantine"), + loader = OwnedNativeTestLoader( + self.sessions, + actor=Actor(tenant_id=context.tenant_id, account_id=context.actor_id), + resolver=NativeHttpBindingResolver(), ) + lifecycle = NativeTestLifecycleService(loader, state_store=case.state) + monkeypatch.setattr(lifecycle, "pause", self.pause) + monkeypatch.setattr(lifecycle, "_mark_unknown", lambda **_kwargs: self.events.append("quarantine")) self.capacity = NativeHostCapacity(cast(NativeTestLifecycleService, lifecycle), cleanup_verifier=case.lifetime) monkeypatch.setattr(self.capacity, "record_cleanup", self.cleanup) self.checkpoints = case.host._checkpoints diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_zero_http_admission.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_zero_http_admission.py index e020fef4518..7883167f2dc 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_zero_http_admission.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_zero_http_admission.py @@ -5,20 +5,58 @@ units qualify no database authorization, persistence, host cleanup or external I """ import json +from collections.abc import Callable from contextlib import contextmanager from copy import deepcopy -from types import SimpleNamespace +from dataclasses import dataclass, field from typing import cast import httpx import pytest from core.dify_builder.models import Actor +from models.dify_builder import DifyBuilderSession, DifyBuilderTestInput +from models.model import App +from models.workflow import Workflow from runtime_contracts.builder.execution import BuilderExecutionPolicyError +from services.dify_builder.execution_policy_service import ExecutionOwnerSnapshot from services.dify_builder.native_testing import context_binding, http_binding from services.dify_builder.native_testing.context_binding import NativeAdmissionCompiler +@dataclass +class ZeroHttpCase: + graph: dict + workflow: Workflow + actor: Actor + monkeypatch: pytest.MonkeyPatch + active: int = 0 + permissions: list[tuple[str, str, str]] = field(default_factory=list) + owner_reads: list[dict] = field(default_factory=list) + workflow_reads: int = 0 + permission_failure: int | None = None + workflow_failure: str | None = None + sessions: Callable | None = None + forbidden_io: Callable | None = None + snapshot: http_binding.NativeHttpResourceSnapshot | None = None + compiler: NativeAdmissionCompiler | None = None + + def compile(self): + self.workflow.graph = json.dumps(self.graph) + assert self.compiler is not None + return self.compiler.compile( + actor=self.actor, + session_id="session", + test_input_id="input", + expected_version=1, + mode="native", + request_id="request", + invocation_id="invocation", + native_run_id="run", + profile=http_binding.build_native_http_profile(), + ) + + @pytest.fixture def zero_http_case(monkeypatch): graph = { @@ -28,30 +66,33 @@ def zero_http_case(monkeypatch): ], "edges": [{"source": "start", "target": "end"}], } - workflow = SimpleNamespace( + workflow = Workflow( + tenant_id="tenant", + created_by="actor", + version="draft", id="workflow", app_id="app", graph=json.dumps(graph), type="workflow", - kind_or_standard="standard", - serialized_features="{}", + kind="standard", + _features="{}", _environment_variables="[]", _conversation_variables="[]", ) actor = Actor(tenant_id="tenant", account_id="actor") - builder = SimpleNamespace(id="session", app_id="app", version=1) - test_input = SimpleNamespace(id="input", inputs={}, start_schema_hash="a" * 64) - case = SimpleNamespace( - graph=graph, - workflow=workflow, - actor=actor, - active=0, - permissions=[], - owner_reads=[], - workflow_reads=0, - permission_failure=None, - workflow_failure=None, + builder = DifyBuilderSession( + id="session", + app_id="app", + version=1, + tenant_id="tenant", + owner_account_id="actor", + entry_mode="build", + current_state="build.execution", ) + test_input = DifyBuilderTestInput( + id="input", inputs={}, start_schema_hash="a" * 64, session_id="session", source="mock" + ) + case = ZeroHttpCase(graph=graph, workflow=workflow, actor=actor, monkeypatch=monkeypatch) class Session: def get(self, model, object_id): @@ -87,8 +128,8 @@ def zero_http_case(monkeypatch): assert case.active == 1 assert kwargs == {"actor": actor, "app_id": "app", "session_id": "session", "test_input_id": "input"} case.owner_reads.append(kwargs) - return SimpleNamespace( - app=SimpleNamespace(id="app", mode="workflow"), + return ExecutionOwnerSnapshot( + app=App(id="app", mode="workflow", tenant_id="tenant", status="normal"), workflow=workflow, session=builder, test_input=test_input, @@ -117,28 +158,12 @@ def zero_http_case(monkeypatch): session_factory=cast(context_binding.sessionmaker, sessions), resources=case.snapshot ) - def compile_owned(): - workflow.graph = json.dumps(case.graph) - return case.compiler.compile( - actor=actor, - session_id="session", - test_input_id="input", - expected_version=1, - mode="native", - request_id="request", - invocation_id="invocation", - native_run_id="run", - profile=http_binding.build_native_http_profile(), - ) - - case.compile = compile_owned return case def delivery_factory(case, context): from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from graphon.runtime import RuntimeState, VariablePool - from services.dify_builder.native_testing.control import BoundaryOwnerState from services.dify_builder.native_testing.runtime import DifyNativeRuntime, build_delivery_bridge from services.workflow_runtime.node_factory import DifyGraphInitContext, DifyNodeFactory @@ -146,24 +171,43 @@ def delivery_factory(case, context): assert ref == context.ref return context - owner = BoundaryOwnerState(ref=context.ref) - runtime = SimpleNamespace( - # Mirrors the real runtime default; Code binding stays off for this case. - code_qualification=None, - host=SimpleNamespace( - boundary_owner=owner, - control_sink=SimpleNamespace(record_control=case.forbidden_io), - make_calls=case.forbidden_io, - register_proposals=case.forbidden_io, - register_http_request=case.forbidden_io, - ), - lifecycle=SimpleNamespace( - loader=SimpleNamespace(session_factory=case.sessions), - store=SimpleNamespace(load_context=load_context), - ), - _journal=SimpleNamespace(open=case.forbidden_io), - authorization=SimpleNamespace(before_call=case.forbidden_io), + from services.dify_builder.native_testing.api_service import NativeTestApiService + from services.dify_builder.native_testing.host_lifetime import NativeHostLifetimeRegistry + from services.dify_builder.native_testing.lifecycle import NativeDispatchRef + from services.dify_builder.native_testing.runtime import DifyNativeResumeSettlement + + # This unit replaces protected-object/RBAC readback, while preserving the + # actual delivery, host, journal, control and collaborator composition. + api = NativeTestApiService( + session_factory=case.sessions, + actor=case.actor, + resolver=http_binding.NativeHttpBindingResolver(), + admission=None, + resolution=None, + runtime=None, + disclosure=None, ) + case.monkeypatch.setattr(api.store, "load_context", load_context) + api.lifecycle.resume_settlement = DifyNativeResumeSettlement() + from services.dify_builder.native_testing.host_execution import NativeHostLaunch + + # C03/telemetry acquisition is outside this compiler/bridge unit boundary. + # Preserve the real inactive constructor, then explicitly activate this + # test-owned lifetime for the isolated Graphon execution below. + with case.monkeypatch.context() as construction: + construction.setattr(NativeHostLaunch, "bind_observation", lambda _self: None) + runtime = DifyNativeRuntime( + delivery=NativeDispatchRef(context.ref, context.generation, "dispatch"), + lifecycle=api.lifecycle, + registry=NativeHostLifetimeRegistry(), + ) + runtime.registry.activate(lifetime=runtime.host.lifetime) + owner = runtime.host.boundary_owner + for method in ("make_calls", "register_proposals", "register_http_request"): + case.monkeypatch.setattr(runtime.host, method, case.forbidden_io) + case.monkeypatch.setattr(runtime.host.control_sink, "record_control", case.forbidden_io) + case.monkeypatch.setattr(runtime._journal, "open", case.forbidden_io) + case.monkeypatch.setattr(runtime.authorization, "before_call", case.forbidden_io) bridge = build_delivery_bridge(runtime=cast(DifyNativeRuntime, runtime)) prepared = bridge.prepare_inputs(context) assert prepared.inputs == {} diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_asset_operations.py b/api/tests/unit_tests/services/dify_builder/test_agent_asset_operations.py index f3971057d76..eced84f05bc 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_asset_operations.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_asset_operations.py @@ -1,16 +1,51 @@ """Confirmed published assets retain config and one durable shell authority.""" -from types import SimpleNamespace - import pytest +from dify_agent.protocol.native_testing import AgentNativeTestRef from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig +from runtime_contracts.builder.native.agent_contracts import AgentPreparedConfig +from services.dify_builder.native_testing.agent_lineage import NativeAgentEndpointContext from services.dify_builder.native_testing.agent_runtime_config import ( NativeAgentDefinition, NativeAgentRuntimeConfigBuilder, ) from services.dify_builder.native_testing.agent_tool_declarations import PreparedAgentToolDeclarations -from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import PARENT, WORKSPACE +from tests.unit_tests.services.dify_builder import native_owner_fixtures +from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import ( + PARENT, + WORKSPACE, + preparation_owner, + preparation_session, + resolved_context, +) + +operations_service = native_owner_fixtures.operations_service +preparation_sql = native_owner_fixtures.preparation_sql + + +def protected_config(config=None): + return AgentPreparedConfig( + parent_node=PARENT, + workflow_native_run_id="workflow", + workspace=WORKSPACE, + config_ref="config", + config_digest="a" * 64, + config={} if config is None else config, + ) + + +def endpoint_context(config): + return NativeAgentEndpointContext( + ref=AgentNativeTestRef.model_validate(PARENT.ref.model_dump(mode="json")), + config=config, + association=None, + native_test_profile="confirmed", + ) + + +def definition(): + return NativeAgentDefinition("node", "agent", "snapshot", AgentSoulConfig(), WorkflowNodeJobConfig(), "s") @pytest.mark.parametrize( @@ -22,19 +57,19 @@ from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import {"tools": {"cli_tools": [{"name": "jq", "install_commands": ["command -v jq"]}]}}, ], ) -def test_confirmed_composer_keeps_published_config_and_shell(assets): +def test_confirmed_composer_keeps_published_config_and_shell(assets, monkeypatch, operations_service): soul = AgentSoulConfig.model_validate( {"model": {"plugin_id": "langgenius/openai", "model_provider": "openai", "model": "test"}, **assets} ) + from services.dify_builder.native_testing.agent_tool_declarations import AgentToolDeclarationService + + declarations = AgentToolDeclarationService() + monkeypatch.setattr(declarations, "prepare", lambda **_kwargs: PreparedAgentToolDeclarations(None, None, ())) builder = NativeAgentRuntimeConfigBuilder( - loader=None, - profile="confirmed", - declarations=SimpleNamespace(prepare=lambda **_kwargs: PreparedAgentToolDeclarations(None, None, ())), + loader=operations_service.loader, profile="confirmed", declarations=declarations ) request = builder.compose( - context=SimpleNamespace( - tenant_id="tenant", actor_id="actor", app_id="app", workflow_id="workflow", native_run_id="workflow" - ), + context=resolved_context(), definition=NativeAgentDefinition("node", "agent", "pin", soul, WorkflowNodeJobConfig(), "s"), parent_node=PARENT, workspace=WORKSPACE, @@ -57,22 +92,23 @@ def test_confirmed_composer_keeps_published_config_and_shell(assets): @pytest.mark.parametrize( "mutation", ["none", "binding", "shell", "runtime", "kind", "asset", "composition", "prepared"] ) -def test_shell_intent_uses_exact_protected_composition_and_current_workspace(monkeypatch, mutation): +def test_shell_intent_uses_exact_protected_composition_and_current_workspace( + monkeypatch, mutation, operations_service, preparation_sql +): from contextlib import contextmanager from runtime_contracts.builder.execution import BuilderExecutionPolicyError from services.dify_builder.native_testing import agent_confirmed_workspace - from services.dify_builder.native_testing.agent_operations import AgentOperationService layers = [ {"name": "shell", "config": {"runtime": "runtime"}}, {"name": "runtime", "config": {"backend_binding_ref": "scope"}}, {"name": "config", "config": {"mentioned_file_names": ["reference.txt"]}}, ] - prepared = SimpleNamespace( - config={"composition": {"layers": layers}}, workspace=WORKSPACE, parent_node=PARENT, config_ref="config" - ) - endpoint = SimpleNamespace(config=prepared) + prepared = protected_config({"composition": {"layers": layers}}) + # The protected DTO detaches nested JSON; mutate its actual protected composition. + layers = prepared.config["composition"]["layers"] + endpoint = endpoint_context(prepared) if mutation == "binding": layers[1]["config"]["backend_binding_ref"] = "foreign" if mutation == "composition": @@ -88,19 +124,24 @@ def test_shell_intent_uses_exact_protected_composition_and_current_workspace(mon if mutation == "asset": layers[2]["config"]["mentioned_file_names"] = [] + from dataclasses import replace + + sql, owned, scope = preparation_session(monkeypatch, operations_service, preparation_sql) + owned = replace(owned, bindings={agent_confirmed_workspace.ALLOCATION: {}}) + @contextmanager def locked(**_kwargs): - yield object(), SimpleNamespace(bindings={agent_confirmed_workspace.ALLOCATION: {}}), object() + yield sql, owned, scope - service = AgentOperationService( - loader=SimpleNamespace(locked=locked), - state_store=SimpleNamespace( - load_prepared_config=lambda **_kwargs: object() if mutation == "prepared" else prepared - ), - assessor=None, + service = operations_service + monkeypatch.setattr(service.loader, "locked", locked) + monkeypatch.setattr( + service.state_store, + "load_prepared_config", + lambda **_kwargs: prepared.model_copy(update={"config_ref": "other"}) if mutation == "prepared" else prepared, ) monkeypatch.setattr(service, "_endpoint", lambda _ref: endpoint) - monkeypatch.setattr(service, "_definition", lambda **_kwargs: (object(), object())) + monkeypatch.setattr(service, "_definition", lambda **_kwargs: (resolved_context(), definition())) monkeypatch.setattr( agent_confirmed_workspace, "current_confirmed_workspace", lambda **_kwargs: (WORKSPACE, "stamp") ) @@ -120,35 +161,54 @@ def test_shell_intent_uses_exact_protected_composition_and_current_workspace(mon ("claimed", "outcome", "allowed"), [(False, None, False), (True, "unknown", False), (True, "completed", False), (True, None, True)], ) -def test_stub_read_requires_claimed_unsettled_shell_operation(monkeypatch, claimed, outcome, allowed): - from contextlib import contextmanager +def test_stub_read_requires_claimed_unsettled_shell_operation( + monkeypatch, claimed, outcome, allowed, operations_service +): from models.dify_builder_native_tests import NativeEffectDispatchClaim from runtime_contracts.builder.execution import BuilderExecutionPolicyError - from services.dify_builder.native_testing.agent_operations import AgentOperationService from tests.unit_tests.services.dify_builder.test_agent_operation_owner import envelope wire = envelope() wire = wire.model_copy(update={"intent": wire.intent.model_copy(update={"kind": "shell"})}) - @contextmanager - def sessions(): - yield SimpleNamespace( - scalar=lambda query: ( - (object() if claimed else None) - if query.column_descriptions[0]["entity"] is NativeEffectDispatchClaim - else outcome - ) - ) + from dataclasses import replace + from datetime import UTC, datetime - owned = SimpleNamespace(tenant_id="tenant", ref=PARENT.ref) - service = AgentOperationService( - loader=SimpleNamespace(load=lambda **_kwargs: owned, session_factory=sessions), state_store=None, assessor=None - ) - endpoint = object() + from models.dify_builder_native_tests import NativeEffectOutcome + from services.dify_builder.native_testing.agent_bridge import api_ref + + service = operations_service + owned = replace(preparation_owner(), ref=api_ref(wire.ref)) + monkeypatch.setattr(service.loader, "load", lambda **_kwargs: owned) + with service.loader.session_factory.begin() as session: + if claimed: + session.add( + NativeEffectDispatchClaim( + tenant_id="tenant", + request_id=wire.ref.request_id, + invocation_id=wire.ref.invocation_id, + operation_id=wire.permit.operation_id, + attempt_id=wire.permit.attempt_id, + claimed_at=datetime.now(UTC), + ) + ) + if outcome: + session.add( + NativeEffectOutcome( + tenant_id="tenant", + request_id=wire.ref.request_id, + invocation_id=wire.ref.invocation_id, + operation_id=wire.permit.operation_id, + attempt_id=wire.permit.attempt_id, + outcome=outcome, + result_ref="result" if outcome == "completed" else None, + ) + ) + endpoint = endpoint_context(protected_config()) monkeypatch.setattr(service, "_endpoint", lambda _ref: endpoint) monkeypatch.setattr(service, "revalidate", lambda **_kwargs: None) - service.bridge.authorization = SimpleNamespace(validate_dispatch=lambda **_kwargs: None) + monkeypatch.setattr(service.bridge.authorization, "validate_dispatch", lambda **_kwargs: None) if allowed: assert service.validate_claimed_shell(envelope=wire) is endpoint else: @@ -157,11 +217,12 @@ def test_stub_read_requires_claimed_unsettled_shell_operation(monkeypatch, claim @pytest.mark.parametrize(("destructive", "changed"), [(False, False), (True, False), (False, True)]) -def test_shell_revalidation_refuses_destructive_or_changed_intent_before_claim(monkeypatch, destructive, changed): +def test_shell_revalidation_refuses_destructive_or_changed_intent_before_claim( + monkeypatch, destructive, changed, operations_service +): from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import operation_digest from services.dify_builder.native_testing.agent_bridge import api_intent, api_ref - from services.dify_builder.native_testing.agent_operations import AgentOperationService from tests.unit_tests.services.dify_builder.test_agent_operation_owner import envelope wire = envelope() @@ -174,12 +235,23 @@ def test_shell_revalidation_refuses_destructive_or_changed_intent_before_claim(m ) descriptor = { "shell_boundary": "shell_run", - "composition_digest": "config-digest", - "workspace": {"binding_id": "binding"}, + "composition_digest": "a" * 64, + "workspace": WORKSPACE.model_dump(mode="json"), } - owned = SimpleNamespace(bindings={"_agent_operations_v1": {operation_digest(effect.key): descriptor}}) - service = AgentOperationService( - loader=SimpleNamespace(load=lambda **_kwargs: owned), state_store=None, assessor=None + from dataclasses import replace + + from services.dify_builder.native_testing.agent_operations import AgentCallAssessment + + owned = replace(preparation_owner(), bindings={"_agent_operations_v1": {operation_digest(effect.key): descriptor}}) + service = operations_service + monkeypatch.setattr(service.loader, "load", lambda **_kwargs: owned) + assessment = AgentCallAssessment( + action="Run configured shell", + account_label=None, + target_summary={}, + parameters_summary={}, + risk_notice="Runs one configured command", + destructive=destructive, ) monkeypatch.setattr( service, @@ -187,23 +259,13 @@ def test_shell_revalidation_refuses_destructive_or_changed_intent_before_claim(m lambda **_kwargs: ( effect.model_copy(update={"parameters": {"changed": True}}) if changed else effect, None, - SimpleNamespace(destructive=destructive), + assessment, ), ) monkeypatch.setattr( service, "_shell", - lambda **_kwargs: ( - None, - SimpleNamespace(snapshot_id="snapshot"), - SimpleNamespace( - config=SimpleNamespace( - config_digest="config-digest", - workspace=SimpleNamespace(model_dump=lambda **_kwargs: {"binding_id": "binding"}), - ) - ), - {}, - ), + lambda **_kwargs: (resolved_context(), definition(), endpoint_context(protected_config()), {}), ) if destructive or changed: with pytest.raises(BuilderExecutionPolicyError): @@ -212,20 +274,23 @@ def test_shell_revalidation_refuses_destructive_or_changed_intent_before_claim(m assert service.revalidate(ref=api_ref(wire.ref), intent=effect) is None -def test_runtime_shell_result_commits_before_return_and_preserves_exact_key(monkeypatch): - from services.dify_builder.native_testing.agent_operations import AgentOperationService +def test_runtime_shell_result_commits_before_return_and_preserves_exact_key(monkeypatch, operations_service): from tests.unit_tests.services.dify_builder.test_agent_operation_owner import envelope wire = envelope() wire = wire.model_copy(update={"intent": wire.intent.model_copy(update={"kind": "shell"})}) events = [] - store = SimpleNamespace( - store_result=lambda **_kwargs: events.append(("store", _kwargs["result"])) or "saved-result" + service = operations_service + monkeypatch.setattr( + service.state_store, + "store_result", + lambda **_kwargs: events.append(("store", _kwargs["result"])) or "saved-result", ) - service = AgentOperationService(loader=None, state_store=store, assessor=None) monkeypatch.setattr(service, "validate_claimed_shell", lambda **_kwargs: events.append("validated")) - service.bridge.authorization = SimpleNamespace( - record_outcome=lambda **_kwargs: events.append(("outcome", _kwargs["outcome"], _kwargs["result_ref"])) + monkeypatch.setattr( + service.bridge.authorization, + "record_outcome", + lambda **_kwargs: events.append(("outcome", _kwargs["outcome"], _kwargs["result_ref"])), ) result = service.store_result( ref=wire.ref, key=wire.intent.key, result={"exit_code": 0}, native_dispatch=wire.model_dump(mode="json") @@ -234,12 +299,13 @@ def test_runtime_shell_result_commits_before_return_and_preserves_exact_key(monk assert events == ["validated", ("store", {"exit_code": 0}), ("outcome", "completed", "saved-result")] -def test_saved_shell_operation_revalidates_for_public_approval_without_runtime_request(monkeypatch): +def test_saved_shell_operation_revalidates_for_public_approval_without_runtime_request( + monkeypatch, operations_service, preparation_sql +): from contextlib import contextmanager from runtime_contracts.builder.native.agent_contracts import AgentPreparedConfig from services.dify_builder.native_testing import agent_confirmed_workspace - from services.dify_builder.native_testing.agent_operations import AgentOperationService config = AgentPreparedConfig( parent_node=PARENT, @@ -258,16 +324,19 @@ def test_saved_shell_operation_revalidates_for_public_approval_without_runtime_r }, ) + from dataclasses import replace + + sql, owned, scope = preparation_session(monkeypatch, operations_service, preparation_sql) + owned = replace(owned, bindings={agent_confirmed_workspace.ALLOCATION: {}}) + @contextmanager def locked(**_kwargs): - yield object(), SimpleNamespace(bindings={agent_confirmed_workspace.ALLOCATION: {}}), object() + yield sql, owned, scope - service = AgentOperationService( - loader=SimpleNamespace(locked=locked), - state_store=SimpleNamespace(load_prepared_config=lambda **_kwargs: config), - assessor=None, - ) - monkeypatch.setattr(service, "_definition", lambda **_kwargs: (object(), object())) + service = operations_service + monkeypatch.setattr(service.loader, "locked", locked) + monkeypatch.setattr(service.state_store, "load_prepared_config", lambda **_kwargs: config) + monkeypatch.setattr(service, "_definition", lambda **_kwargs: (resolved_context(), definition())) monkeypatch.setattr( agent_confirmed_workspace, "current_confirmed_workspace", lambda **_kwargs: (WORKSPACE, "stamp") ) diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_config_access.py b/api/tests/unit_tests/services/dify_builder/test_agent_config_access.py index 99f0ca693a6..cf04c90dc6f 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_config_access.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_config_access.py @@ -1,20 +1,24 @@ """Confirmed config reads keep the claimed operation and published asset scope.""" -from types import SimpleNamespace -from unittest.mock import Mock +from dataclasses import replace +from unittest.mock import patch import pytest from runtime_contracts.builder.execution import BuilderExecutionPolicyError from services.dify_builder.native_testing.agent_config_access import validate_config_read +from tests.unit_tests.services.dify_builder import native_owner_fixtures +from tests.unit_tests.services.dify_builder.test_agent_asset_operations import endpoint_context, protected_config +from tests.unit_tests.services.dify_builder.test_agent_operation_owner import envelope as dispatch_envelope + +operations_service = native_owner_fixtures.operations_service -def fixture(): - context = SimpleNamespace( - native_test_profile="confirmed", - ref="ref", - config=SimpleNamespace( - config={ +@pytest.fixture +def config_read(operations_service): + context = endpoint_context( + protected_config( + { "composition": { "layers": [ {"name": "execution_context", "config": {"tenant_id": "t", "user_id": "u"}}, @@ -32,11 +36,16 @@ def fixture(): ] } } - ), + ) ) - envelope = SimpleNamespace(ref="ref", intent=SimpleNamespace(kind="shell", target={"kind": "file"})) - service = Mock() - service.validate_claimed_shell.return_value = context + wire = dispatch_envelope() + envelope = wire.model_copy( + update={ + "ref": context.ref, + "intent": wire.intent.model_copy(update={"kind": "shell", "target": {"kind": "file"}}), + } + ) + service = operations_service query = { "tenant_id": "t", "user_id": "u", @@ -44,11 +53,12 @@ def fixture(): "config_version_id": "snapshot", "config_version_kind": "snapshot", } - return context, envelope, service, query + with patch.object(service, "validate_claimed_shell", return_value=context): + yield context, envelope, service, query -def test_selected_asset_requires_current_claimed_operation(): - context, envelope, service, query = fixture() +def test_selected_asset_requires_current_claimed_operation(config_read): + context, envelope, service, query = config_read validate_config_read( context=context, envelope=envelope, operations=service, source={"kind": "file", "name": "input.txt"}, **query ) @@ -65,31 +75,31 @@ def test_selected_asset_requires_current_claimed_operation(): ("config_version_kind", "draft"), ], ) -def test_owner_or_snapshot_substitution_stops_before_config_service(field, value): - context, envelope, service, query = fixture() +def test_owner_or_snapshot_substitution_stops_before_config_service(field, value, config_read): + context, envelope, service, query = config_read query[field] = value with pytest.raises(BuilderExecutionPolicyError): validate_config_read(context=context, envelope=envelope, operations=service, **query) -def test_other_declared_kind_or_unknown_asset_is_not_a_file_pull(): - context, envelope, service, query = fixture() +def test_other_declared_kind_or_unknown_asset_is_not_a_file_pull(config_read): + context, envelope, service, query = config_read for source in ({"kind": "file", "name": "secret.txt"}, {"kind": "skill", "name": "check"}): with pytest.raises(BuilderExecutionPolicyError): validate_config_read(context=context, envelope=envelope, operations=service, source=source, **query) -def test_revoked_or_completed_claim_never_falls_back_to_ordinary_access(): - context, envelope, service, query = fixture() +def test_revoked_or_completed_claim_never_falls_back_to_ordinary_access(config_read): + context, envelope, service, query = config_read service.validate_claimed_shell.side_effect = BuilderExecutionPolicyError("native_effect_owner_mismatch") with pytest.raises(BuilderExecutionPolicyError): validate_config_read(context=context, envelope=envelope, operations=service, **query) -def test_changed_protected_context_rejected_after_claim_validation(): - context, envelope, service, query = fixture() - service.validate_claimed_shell.return_value = SimpleNamespace( - ref="ref", native_test_profile="confirmed", config=None +def test_changed_protected_context_rejected_after_claim_validation(config_read): + context, envelope, service, query = config_read + service.validate_claimed_shell.return_value = replace( + context, config=context.config.model_copy(update={"config_ref": "changed-protected-config"}) ) with pytest.raises(BuilderExecutionPolicyError): validate_config_read(context=context, envelope=envelope, operations=service, **query) diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_confirmed_workspace.py b/api/tests/unit_tests/services/dify_builder/test_agent_confirmed_workspace.py index 8a99d58b2ae..1ed023cbea8 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_confirmed_workspace.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_confirmed_workspace.py @@ -1,7 +1,6 @@ """Normal confirmed workspace references confer neither isolation nor allocation.""" from dataclasses import asdict -from types import SimpleNamespace import pytest @@ -20,6 +19,10 @@ from models.workflow import WorkflowNodeExecutionModel, WorkflowRun from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.agent_contracts import AgentTestWorkspaceRef from runtime_contracts.builder.native.contracts import NativeTestContextRef +from tests.unit_tests.services.dify_builder import native_owner_fixtures + +operations_service = native_owner_fixtures.operations_service +preparation_sql = native_owner_fixtures.preparation_sql @pytest.mark.parametrize( @@ -37,7 +40,7 @@ from runtime_contracts.builder.native.contracts import NativeTestContextRef ("home", "home_tenant"), ], ) -def test_current_normal_workspace_requires_complete_sql_owner_chain(monkeypatch, home_id, drift): +def test_current_normal_workspace_requires_complete_sql_owner_chain(monkeypatch, home_id, drift, sqlite_session): from services.agent import access_service from services.agent.workspace_service import AgentWorkspaceService from services.dify_builder.native_testing import agent_confirmed_workspace as module @@ -62,7 +65,11 @@ def test_current_normal_workspace_requires_complete_sql_owner_chain(monkeypatch, home_snapshot_id=home_id, workspace_generation=0, ) - context = SimpleNamespace( + from models.agent import AgentWorkspaceBinding, WorkflowAgentNodeBinding + from models.workflow import Workflow + from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import preparation_owner, resolved_context + + context = resolved_context( ref=NativeTestContextRef(request_id="request", invocation_id="inv", context_digest="a" * 64), tenant_id="tenant", app_id="app", @@ -76,9 +83,19 @@ def test_current_normal_workspace_requires_complete_sql_owner_chain(monkeypatch, }, ) rows = { - DifyBuilderExecutionRequest: SimpleNamespace(context={"profile": {"profile_id": "builder-agent-confirmed-v1"}}), - WorkflowRun: SimpleNamespace(tenant_id="tenant", app_id="app", workflow_id="workflow", created_by="actor"), - WorkflowNodeExecutionModel: SimpleNamespace( + DifyBuilderExecutionRequest: DifyBuilderExecutionRequest( + id="request", + tenant_id="tenant", + app_id="app", + workflow_id="workflow", + actor_id="actor", + session_id="session", + test_input_id="input", + context_digest="a" * 64, + context={"profile": {"profile_id": "builder-agent-confirmed-v1"}}, + ), + WorkflowRun: WorkflowRun(tenant_id="tenant", app_id="app", workflow_id="workflow", created_by="actor"), + WorkflowNodeExecutionModel: WorkflowNodeExecutionModel( tenant_id="tenant", app_id="app", workflow_id="workflow", @@ -86,20 +103,20 @@ def test_current_normal_workspace_requires_complete_sql_owner_chain(monkeypatch, node_id="node", agent_workspace_binding_id="binding", ), - AgentWorkspace: SimpleNamespace(backend_workspace_ref="ordinary-workspace"), - AgentConfigSnapshot: SimpleNamespace( + AgentWorkspace: AgentWorkspace(backend_workspace_ref="ordinary-workspace"), + AgentConfigSnapshot: AgentConfigSnapshot( id="snapshot", tenant_id="tenant", agent_id="agent", home_snapshot_id=home_id, - config_snapshot_dict=AgentSoulConfig().model_dump(mode="json"), + config_snapshot=AgentSoulConfig(), ), - AgentHomeSnapshot: SimpleNamespace( + AgentHomeSnapshot: AgentHomeSnapshot( id=home_id, tenant_id="tenant", agent_id="agent", status=AgentWorkingResourceStatus.ACTIVE ), - Agent: SimpleNamespace(tenant_id="tenant", active_config_snapshot_id="snapshot"), + Agent: Agent(tenant_id="tenant", active_config_snapshot_id="snapshot"), } - binding = SimpleNamespace( + binding = AgentWorkspaceBinding( workspace_id="workspace", agent_id="agent", base_home_snapshot_id=home_id, @@ -109,12 +126,12 @@ def test_current_normal_workspace_requires_complete_sql_owner_chain(monkeypatch, ) calls = [] monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", lambda **kwargs: calls.append(kwargs) or binding) - monkeypatch.setattr( - module, "validate_invocation_workflow", lambda *_args, **_kwargs: SimpleNamespace(version="draft") - ) + monkeypatch.setattr(module, "validate_invocation_workflow", lambda *_args, **_kwargs: Workflow(version="draft")) monkeypatch.setattr(access_service, "require_builder_agent_access", lambda **_kwargs: None) - node_binding = SimpleNamespace(node_job_config_dict=WorkflowNodeJobConfig().model_dump(mode="json")) - session = SimpleNamespace(get=lambda model, _identifier: rows[model], scalar=lambda _query: node_binding) + node_binding = WorkflowAgentNodeBinding(node_job_config=WorkflowNodeJobConfig()) + session = sqlite_session + monkeypatch.setattr(session, "get", lambda model, _identifier: rows[model]) + monkeypatch.setattr(session, "scalar", lambda _query: node_binding) if drift == "profile": rows[DifyBuilderExecutionRequest].context["profile"]["profile_id"] = "isolated" elif drift == "run_owner": @@ -133,7 +150,7 @@ def test_current_normal_workspace_requires_complete_sql_owner_chain(monkeypatch, rows[AgentHomeSnapshot].tenant_id = "foreign" kwargs = { "session": session, - "owned": object(), + "owned": preparation_owner(), "context": context, "allocation": {"scope": asdict(scope), "workspace": workspace_ref.model_dump(mode="json")}, } @@ -152,8 +169,9 @@ def test_current_normal_workspace_requires_complete_sql_owner_chain(monkeypatch, @pytest.mark.parametrize( ("input_present", "config_present"), [(False, False), (True, False), (True, True), (False, True)] ) -def test_parent_discovery_reuses_input_identity_and_never_writes(input_present, config_present): - from contextlib import contextmanager +def test_parent_discovery_reuses_input_identity_and_never_writes( + input_present, config_present, monkeypatch, operations_service +): from runtime_contracts.builder.execution import canonical_digest from services.dify_builder.native_testing.agent_confirmed_workspace import load_confirmed_agent_parent @@ -162,22 +180,50 @@ def test_parent_discovery_reuses_input_identity_and_never_writes(input_present, inputs = {"query": "original dynamic input"} calls = [] - @contextmanager - def sessions(): - yield SimpleNamespace( - scalar=lambda _query: SimpleNamespace(id="original-input") if input_present else None, - scalars=lambda _query: ["config"] if config_present else [], - ) + from dataclasses import replace - loader = SimpleNamespace(load=lambda **_kwargs: SimpleNamespace(tenant_id="tenant"), session_factory=sessions) + from models.dify_builder_native_tests import NativePayloadObject + from runtime_contracts.builder.native.agent_contracts import AgentPreparedConfig + from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import WORKSPACE, preparation_owner + + loader = operations_service.loader + store = operations_service.state_store + owned = replace(preparation_owner(), ref=ref) + monkeypatch.setattr(loader, "load", lambda **_kwargs: owned) + execution = "a46d79e6-0ae1-4e19-90af-5b58e9a496f3" + with loader.session_factory.begin() as session: + for identifier, logical_key in ([("original-input", "node-input:" + execution)] if input_present else []) + ( + [("config", "agent-config:stored")] if config_present else [] + ): + session.add( + NativePayloadObject( + id=identifier, + tenant_id="tenant", + request_id="request", + invocation_id="inv", + kind="result", + logical_key=logical_key, + storage_key=identifier, + payload_digest="a" * 64, + value_digest="b" * 64, + ) + ) def prepared(**kwargs): calls.append(kwargs["parent_node"]) - return SimpleNamespace(parent_node=kwargs["parent_node"]) + return AgentPreparedConfig( + parent_node=kwargs["parent_node"], + workflow_native_run_id="workflow", + workspace=WORKSPACE, + config_ref="config", + config_digest="a" * 64, + config={}, + ) - store = SimpleNamespace(loader=loader, load_result=lambda **_kwargs: inputs, load_prepared_config=prepared) + monkeypatch.setattr(store, "load_result", lambda **_kwargs: inputs) + monkeypatch.setattr(store, "load_prepared_config", prepared) execution = "a46d79e6-0ae1-4e19-90af-5b58e9a496f3" - kwargs = {"service": SimpleNamespace(state_store=store), "ref": ref, "execution_id": execution} + kwargs = {"state_store": store, "ref": ref, "execution_id": execution} if config_present and not input_present: with pytest.raises(BuilderExecutionPolicyError, match="native_agent_input_unavailable"): load_confirmed_agent_parent(**kwargs) diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_endpoint_lineage.py b/api/tests/unit_tests/services/dify_builder/test_agent_endpoint_lineage.py index 1fa8c182826..7904de4914a 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_endpoint_lineage.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_endpoint_lineage.py @@ -1,7 +1,5 @@ """Actual lineage policy; backend and protected state I/O are explicit doubles.""" -from types import SimpleNamespace - import pytest from dify_agent.protocol.native_testing import AgentRuntimeIdentity @@ -10,12 +8,17 @@ from runtime_contracts.builder.native.agent_contracts import ( AgentNodeAssociation, AgentPreparedConfig, ) +from tests.unit_tests.services.dify_builder import native_owner_fixtures from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import ( CONFIG, PARENT, WORKSPACE, + resolved_context, ) +operations_service = native_owner_fixtures.operations_service +preparation_sql = native_owner_fixtures.preparation_sql + def owner(parent=True, mutation=None, unbound=False): from dify_agent.protocol.native_testing import AgentRunTransportSelection @@ -52,61 +55,72 @@ def owner(parent=True, mutation=None, unbound=False): ) phase = [] - class Store: - def load_context(self, **kwargs): - del kwargs - return SimpleNamespace( - tenant_id="foreign" if phase and mutation == "tenant-owner" else "tenant", - actor_id="foreign" if phase and mutation == "actor-owner" else "actor", - app_id="app", - native_run_id="workflow", - generation=5 if phase and mutation == "owner" else 4, - ) + def load_context(**kwargs): + del kwargs + return resolved_context( + tenant_id="foreign" if phase and mutation == "tenant-owner" else "tenant", + actor_id="foreign" if phase and mutation == "actor-owner" else "actor", + app_id="app", + native_run_id="workflow", + generation=5 if phase and mutation == "owner" else 4, + ) - def load_prepared_config(self, **kwargs): - del kwargs - return config.model_copy(update={"config_digest": "f" * 64}) if phase and mutation == "config" else config + def load_prepared_config(**kwargs): + del kwargs + return config.model_copy(update={"config_digest": "f" * 64}) if phase and mutation == "config" else config - def discover_agent_run(self, **kwargs): - del kwargs - if unbound: - return None - return ( - association.model_copy(update={"agent_run_id": "foreign"}) if mutation == "association" else association - ) + def discover_agent_run(**kwargs): + del kwargs + if unbound: + return None + return association.model_copy(update={"agent_run_id": "foreign"}) if mutation == "association" else association - class Backend: - def read_run_identity_sync(self, run_id, query): - assert run_id == "run" - assert query.request_digest == "a" * 64 - assert query.parent_node == selection.parent_node - phase.append("backend") - if mutation == "unavailable": - raise TimeoutError("private read unavailable") - if mutation == "missing": - return None - raw = { - "run_id": "run", - "parent_node": wire_parent, - "execution_mode": "builder_test" if parent else "ordinary", - "request_digest": "a" * 64, - "agent_generation": 0, - } - if mutation == "run": - raw["run_id"] = "foreign" - if mutation == "digest": - raw["request_digest"] = "b" * 64 - if mutation == "generation": - raw["agent_generation"] = 1 - if mutation == "parent": - raw["parent_node"] = PARENT.model_dump(mode="json") | {"input_result_ref": "other"} - return AgentRuntimeIdentity.model_validate(raw) + def read_run_identity_sync(run_id, query): + assert run_id == "run" + assert query.request_digest == "a" * 64 + assert query.parent_node == selection.parent_node + phase.append("backend") + if mutation == "unavailable": + raise TimeoutError("private read unavailable") + if mutation == "missing": + return None + raw = { + "run_id": "run", + "parent_node": wire_parent, + "execution_mode": "builder_test" if parent else "ordinary", + "request_digest": "a" * 64, + "agent_generation": 0, + } + if mutation == "run": + raw["run_id"] = "foreign" + if mutation == "digest": + raw["request_digest"] = "b" * 64 + if mutation == "generation": + raw["agent_generation"] = 1 + if mutation == "parent": + raw["parent_node"] = PARENT.model_dump(mode="json") | {"input_result_ref": "other"} + return AgentRuntimeIdentity.model_validate(raw) - store = Store() - lineage = NativeAgentEndpointLineage( - backend=Backend(), - preparation_for_ref=lambda _ref: SimpleNamespace(state_store=store), + from dify_agent.client import Client + from sqlalchemy.orm import sessionmaker + + from core.dify_builder.models import Actor + from services.dify_builder.native_testing.agent_operations import AgentOperationBindingResolver + from services.dify_builder.native_testing.agent_preparation import AgentPreparationService + from services.dify_builder.native_testing.repository import OwnedNativeTestLoader + from services.dify_builder.native_testing.state_store import NativeExecutionStateStore + + loader = OwnedNativeTestLoader( + sessionmaker(), actor=Actor(account_id="actor", tenant_id="tenant"), resolver=AgentOperationBindingResolver() ) + store = NativeExecutionStateStore(loader) + store.load_context = load_context + store.load_prepared_config = load_prepared_config + store.discover_agent_run = discover_agent_run + backend = Client(base_url="http://agent.invalid") + backend.read_run_identity_sync = read_run_identity_sync + preparation = AgentPreparationService(state_store=store, backend=backend, workspace=None, config_builder=None) + lineage = NativeAgentEndpointLineage(backend=backend, preparation_for_ref=lambda _ref: preparation) return lineage, AgentServicePrincipal(), selection, phase @@ -246,44 +260,27 @@ def test_validated_native_context_checks_exact_effect_but_never_spends_permit(ch @pytest.mark.parametrize("mutation", ["none", "identity-io", "object-io"]) -def test_real_s1_config_and_final_resource_recheck_composes_with_lineage(monkeypatch, mutation): +def test_real_s1_config_and_final_resource_recheck_composes_with_lineage( + monkeypatch, mutation, operations_service, preparation_sql +): """Real protected read/recheck; SQL/access/storage/backend are explicit doubles.""" - from contextlib import contextmanager - from dify_agent.protocol.native_testing import AgentRunTransportSelection from runtime_contracts.builder.execution import canonical_digest from services.dify_builder.native_testing import state_store as state - from services.dify_builder.native_testing.agent_lineage import ( - AgentServicePrincipal, - NativeAgentEndpointLineage, - ) + from services.dify_builder.native_testing.agent_lineage import AgentServicePrincipal, NativeAgentEndpointLineage + from services.dify_builder.native_testing.agent_preparation import AgentPreparationService from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import ( - SQLDouble, + preparation_session, + prepared_state, ) - sql = SQLDouble() - - @contextmanager - def sessions(): - yield sql - - store = state.NativeExecutionStateStore(SimpleNamespace(locked=sql.locked_root, session_factory=sessions)) - context = SimpleNamespace( - tenant_id="tenant", - actor_id="actor", - app_id="app", - native_run_id="workflow", - generation=4, - ) - prepared = SimpleNamespace( - parent=PARENT, - context=context, - workspace=WORKSPACE, - resource_digest="e" * 64, - authority=SimpleNamespace(), - objects=[], - ) + sql, owned, scope = preparation_session(monkeypatch, operations_service, preparation_sql) + store = operations_service.state_store + monkeypatch.setattr(store.loader, "session_factory", lambda: sql) + monkeypatch.setattr(store.loader, "_load", lambda *_args, **_kwargs: (owned, scope)) + prepared = prepared_state() + context = prepared.context value = { "schema": "agent-config-v2", "parent_node": PARENT.model_dump(mode="json"), @@ -303,7 +300,8 @@ def test_real_s1_config_and_final_resource_recheck_composes_with_lineage(monkeyp "a" * 64, canonical_digest(value), ) - sql.rows = [SimpleNamespace(**vars(receipt))] + sql.add(state.NativePayloadObject(**vars(receipt))) + sql.commit() resource = {"stamp": "e" * 64} monkeypatch.setattr(store, "_agent_prepare", lambda **_kwargs: prepared) monkeypatch.setattr(store, "load_context", lambda **_kwargs: context) @@ -319,7 +317,7 @@ def test_real_s1_config_and_final_resource_recheck_composes_with_lineage(monkeyp def read(*args, **kwargs): del args, kwargs - assert not sql.locked + assert not sql.info["locked"] reads.append("object") if mutation == "object-io" and len(reads) > 1: resource["stamp"] = "f" * 64 @@ -338,24 +336,25 @@ def test_real_s1_config_and_final_resource_recheck_composes_with_lineage(monkeyp ) monkeypatch.setattr(store, "discover_agent_run", lambda **_kwargs: association) - class Backend: - def read_run_identity_sync(self, run_id, selection): - del run_id, selection - assert not sql.locked - if mutation == "identity-io": - resource["stamp"] = "f" * 64 - return AgentRuntimeIdentity( - run_id="run", - parent_node=PARENT.model_dump(mode="json"), - request_digest=canonical_digest(CONFIG), - execution_mode="builder_test", - agent_generation=0, - ) + def read_run_identity_sync(run_id, selection): + del run_id, selection + assert not sql.info["locked"] + if mutation == "identity-io": + resource["stamp"] = "f" * 64 + return AgentRuntimeIdentity( + run_id="run", + parent_node=PARENT.model_dump(mode="json"), + request_digest=canonical_digest(CONFIG), + execution_mode="builder_test", + agent_generation=0, + ) - lineage = NativeAgentEndpointLineage( - backend=Backend(), - preparation_for_ref=lambda _ref: SimpleNamespace(state_store=store), - ) + from dify_agent.client import Client + + backend = Client(base_url="http://agent.invalid") + monkeypatch.setattr(backend, "read_run_identity_sync", read_run_identity_sync) + preparation = AgentPreparationService(state_store=store, backend=backend, workspace=None, config_builder=None) + lineage = NativeAgentEndpointLineage(backend=backend, preparation_for_ref=lambda _ref: preparation) selection = AgentRunTransportSelection( agent_run_id="run", parent_node=PARENT.model_dump(mode="json"), @@ -380,7 +379,7 @@ def test_real_s1_config_and_final_resource_recheck_composes_with_lineage(monkeyp boundary="model", caller={}, ) - assert not sql.locked + assert not sql.info["locked"] def test_expired_request_native_context_cannot_taint_later_ordinary_preflight(): diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_factory.py b/api/tests/unit_tests/services/dify_builder/test_agent_factory.py index 1f77494c68f..4b502ee0797 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_factory.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_factory.py @@ -1,5 +1,4 @@ from collections.abc import Sequence -from unittest.mock import Mock import pytest @@ -16,7 +15,7 @@ def test_default_factory_returns_llm_agent() -> None: def test_override_factory_is_used_then_reset() -> None: - sentinel = Mock(spec=DifyBuilderAgent) + sentinel = LlmBuilderAgent("tenant") set_dify_builder_agent_factory(lambda: sentinel) try: assert build_dify_builder_agent() is sentinel diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_model_result_canonical.py b/api/tests/unit_tests/services/dify_builder/test_agent_model_result_canonical.py index be9d60bcb6a..a84b28b1f78 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_model_result_canonical.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_model_result_canonical.py @@ -2,7 +2,6 @@ import copy import json -from types import SimpleNamespace import pytest from dify_agent.adapters.llm.provider import ProtectedModelChunkReceipt @@ -13,14 +12,16 @@ from graphon.model_runtime.entities.message_entities import AssistantPromptMessa from models.dify_builder_native_tests import NativeEffectOperation from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_json from runtime_contracts.builder.native.contracts import operation_digest -from services.dify_builder.native_testing.agent_operations import AgentOperationService +from tests.unit_tests.services.dify_builder import native_owner_fixtures from tests.unit_tests.services.dify_builder.test_agent_operation_owner import envelope +operations_service = native_owner_fixtures.operations_service + @pytest.mark.parametrize("with_tool_history", [False, True]) @pytest.mark.parametrize("repeated_prompt", [False, True]) def test_model_receipt_matches_sdk_roundtrip_without_changing_stream_or_accepting_replacement( - sqlite_session_factory, monkeypatch, with_tool_history, repeated_prompt + sqlite_session_factory, monkeypatch, with_tool_history, repeated_prompt, operations_service ): pending = envelope() messages = ( @@ -77,18 +78,21 @@ def test_model_receipt_matches_sdk_roundtrip_without_changing_stream_or_acceptin ) sql.commit() - service = AgentOperationService( - loader=SimpleNamespace( - load=lambda **_kwargs: SimpleNamespace(tenant_id="tenant"), session_factory=sqlite_session_factory - ), - state_store=SimpleNamespace( - store_result=store, load_result=lambda **kwargs: copy.deepcopy(stored[kwargs["result_ref"]]) - ), - assessor=object(), - ) - service.bridge.authorization = SimpleNamespace( - claim_dispatch=lambda **kwargs: claims.append(kwargs), record_outcome=completed + from dataclasses import replace + + from services.dify_builder.native_testing.agent_bridge import api_ref + from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import preparation_owner + + service = operations_service + owned = replace(preparation_owner(), ref=api_ref(pending.ref)) + monkeypatch.setattr(service.loader, "load", lambda **_kwargs: owned) + monkeypatch.setattr(service.loader, "session_factory", sqlite_session_factory) + monkeypatch.setattr(service.state_store, "store_result", store) + monkeypatch.setattr( + service.state_store, "load_result", lambda **kwargs: copy.deepcopy(stored[kwargs["result_ref"]]) ) + monkeypatch.setattr(service.bridge.authorization, "claim_dispatch", lambda **kwargs: claims.append(kwargs)) + monkeypatch.setattr(service.bridge.authorization, "record_outcome", completed) monkeypatch.setattr(service, "validate_model", lambda **_kwargs: None) monkeypatch.setattr(service, "_endpoint", lambda _ref: None) @@ -120,17 +124,15 @@ def test_model_receipt_matches_sdk_roundtrip_without_changing_stream_or_acceptin assert calls == ["provider"] -def test_actual_oversized_model_output_stops_before_yield_and_keeps_unknown_without_replay(monkeypatch): +def test_actual_oversized_model_output_stops_before_yield_and_keeps_unknown_without_replay( + monkeypatch, operations_service +): pending = envelope() claims, outcomes, stores, calls = [], [], [], [] - service = AgentOperationService( - loader=object(), - state_store=SimpleNamespace(store_result=lambda **kwargs: stores.append(kwargs)), - assessor=object(), - ) - service.bridge.authorization = SimpleNamespace( - claim_dispatch=lambda **kwargs: claims.append(kwargs), record_outcome=lambda **kwargs: outcomes.append(kwargs) - ) + service = operations_service + monkeypatch.setattr(service.state_store, "store_result", lambda **kwargs: stores.append(kwargs)) + monkeypatch.setattr(service.bridge.authorization, "claim_dispatch", lambda **kwargs: claims.append(kwargs)) + monkeypatch.setattr(service.bridge.authorization, "record_outcome", lambda **kwargs: outcomes.append(kwargs)) monkeypatch.setattr(service, "validate_model", lambda **_kwargs: None) def provider(): diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_operation_owner.py b/api/tests/unit_tests/services/dify_builder/test_agent_operation_owner.py index 7d764a2a8cd..72708121256 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_operation_owner.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_operation_owner.py @@ -1,14 +1,23 @@ """The actual API dispatcher spends only a current, exact operation permit.""" -from types import SimpleNamespace - import pytest from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload from dify_agent.protocol.native_testing import AgentDispatchPermit, AgentEffectIntent, AgentNativeTestRef +from core.dify_builder.models import Actor +from models.dify_builder_native_tests import ( + NativeEffectOperation, + NativeEffectOutcome, + NativeTestInvocation, +) from runtime_contracts.builder.execution import BuilderExecutionPolicyError +from services.dify_builder.native_testing.agent_operations import AgentOperationService +from tests.unit_tests.services.dify_builder import native_owner_fixtures from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent +operations_service = native_owner_fixtures.operations_service +preparation_sql = native_owner_fixtures.preparation_sql + def envelope(): effect = intent().model_copy(update={"kind": "tool"}) @@ -23,7 +32,7 @@ def envelope(): ) -def owner(events, *, drift=False): +def owner(events, operations_service, monkeypatch, *, drift=False): from services.dify_builder.native_testing.agent_operations import AgentToolDispatchOwner def revalidate(**_kwargs): @@ -31,22 +40,27 @@ def owner(events, *, drift=False): if drift: raise BuilderExecutionPolicyError("native_credential_drift") + authorization = operations_service.bridge.authorization + monkeypatch.setattr(authorization, "validate_dispatch", lambda **_kwargs: events.append("validate")) + monkeypatch.setattr(authorization, "claim_dispatch", lambda **_kwargs: events.append("claim")) + monkeypatch.setattr( + authorization, + "record_outcome", + lambda **kwargs: events.append(("outcome", kwargs["outcome"], kwargs["result_ref"])), + ) + monkeypatch.setattr( + operations_service.state_store, + "store_result", + lambda **kwargs: events.append(("stored", kwargs["result"])) or "result", + ) return AgentToolDispatchOwner( - authorization=SimpleNamespace( - validate_dispatch=lambda **_kwargs: events.append("validate"), - claim_dispatch=lambda **_kwargs: events.append("claim"), - record_outcome=lambda **kwargs: events.append(("outcome", kwargs["outcome"], kwargs["result_ref"])), - ), - state_store=SimpleNamespace( - store_result=lambda **kwargs: events.append(("stored", kwargs["result"])) or "result" - ), - revalidate=revalidate, + authorization=authorization, state_store=operations_service.state_store, revalidate=revalidate ) -def test_physical_claim_is_adjacent_to_dispatch_and_real_result_is_saved(): +def test_physical_claim_is_adjacent_to_dispatch_and_real_result_is_saved(operations_service, monkeypatch): events = [] - operation = owner(events) + operation = owner(events, operations_service, monkeypatch) result = operation.invoke(envelope=envelope(), invoke=lambda: events.append("business") or {"observation": "real"}) assert result == {"observation": "real"} assert events == [ @@ -59,14 +73,16 @@ def test_physical_claim_is_adjacent_to_dispatch_and_real_result_is_saved(): ] -def test_current_resource_drift_stops_before_physical_claim_or_business(): +def test_current_resource_drift_stops_before_physical_claim_or_business(operations_service, monkeypatch): events = [] with pytest.raises(BuilderExecutionPolicyError, match="native_credential_drift"): - owner(events, drift=True).invoke(envelope=envelope(), invoke=lambda: events.append("business")) + owner(events, operations_service, monkeypatch, drift=True).invoke( + envelope=envelope(), invoke=lambda: events.append("business") + ) assert events == ["validate", "revalidate"] -def test_lost_business_response_closes_unknown_and_never_retries(): +def test_lost_business_response_closes_unknown_and_never_retries(operations_service, monkeypatch): events = [] def invoke(): @@ -74,7 +90,7 @@ def test_lost_business_response_closes_unknown_and_never_retries(): raise TimeoutError with pytest.raises(TimeoutError): - owner(events).invoke(envelope=envelope(), invoke=invoke) + owner(events, operations_service, monkeypatch).invoke(envelope=envelope(), invoke=invoke) assert events == ["validate", "revalidate", "claim", "business", ("outcome", "unknown", None)] @@ -129,11 +145,13 @@ def test_reasoning_assessment_cannot_grant_or_hide_opaque_tool_scope(): @pytest.mark.parametrize("boundary", ["core_tool", "model", "shell_run", "bootstrap", "config_pull"]) -def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth(monkeypatch, boundary): +def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth( + monkeypatch, boundary, operations_service +): from contextlib import contextmanager from datetime import UTC, datetime - from models.agent_config_entities import AgentSoulConfig + from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig from runtime_contracts.builder.native.contracts import NativeTestContextRef, OperationKey, operation_digest from services.dify_builder.native_testing import agent_operations as module from services.dify_builder.native_testing.agent_tool_declarations import AgentToolMetadata @@ -142,21 +160,63 @@ def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth ref = NativeTestContextRef(request_id="request", invocation_id="inv", context_digest="a" * 64) execution = "a46d79e6-0ae1-4e19-90af-5b58e9a496f3" key = OperationKey(invocation_id="inv", occurrence_path=(execution, boundary, "call-1"), cursor=0) - invocation = SimpleNamespace(bindings={}) - owned = SimpleNamespace( - tenant_id="tenant", bindings=invocation.bindings, expires_at=datetime(2030, 1, 1, tzinfo=UTC) + from models.dify_builder_native_tests import NativeTestScope + from runtime_contracts.builder.execution import canonical_digest + from services.dify_builder.native_testing.repository import OwnedNativeTest + from services.dify_builder.native_testing.state_store import ObjectReceipt + + service = operations_service + invocation = NativeTestInvocation( + id="invocation-row", tenant_id="tenant", request_id="request", invocation_id="inv", active=True, bindings={} + ) + with service.loader.session_factory.begin() as session: + session.add(invocation) + owned = OwnedNativeTest( + ref=ref, + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=0, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", + ) + scope = NativeTestScope( + request_id="request", + tenant_id="tenant", + max_calls=1, + max_dispatches=1, + max_cost_units=1, + generation=0, + task_approved=False, + cancelled=False, + expires_at=owned.expires_at, + budget_limits={}, + budget_spent={}, + profile={}, ) @contextmanager def locked(**_kwargs): - yield SimpleNamespace(scalar=lambda _query: invocation), owned, object() + with service.loader.session_factory.begin() as session: + yield session, owned, scope - loader = SimpleNamespace(load=lambda **_kwargs: owned, locked=locked) + monkeypatch.setattr(service.loader, "load", lambda **_kwargs: owned) + monkeypatch.setattr(service.loader, "locked", locked) stored = [] - store = SimpleNamespace( - prepare_object=lambda **kwargs: stored.append(kwargs) or object(), - acknowledge_object=lambda _session, _receipt: None, - ) + + def prepare_object(**kwargs): + stored.append(kwargs) + digest = canonical_digest(kwargs["payload"]) + return ObjectReceipt( + "object", "tenant", "request", "inv", "result", kwargs["logical_key"], "opaque-storage-key", digest, digest + ) + + monkeypatch.setattr(service.state_store, "prepare_object", prepare_object) reasoning = [] assessment = { "action": "Write declared value", @@ -166,11 +226,60 @@ def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth "risk_notice": "Creates one test value.", "destructive": False, } - service = module.AgentOperationService( - loader=loader, state_store=store, assessor=lambda **kwargs: reasoning.append(kwargs) or assessment + from runtime_contracts.builder.native.agent_contracts import ( + AgentParentNodeRef, + AgentPreparedConfig, + AgentTestWorkspaceRef, + ) + from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext + from services.dify_builder.native_testing.agent_lineage import NativeAgentEndpointContext + from services.dify_builder.native_testing.agent_runtime_config import NativeAgentDefinition + + service.assessor = lambda **kwargs: reasoning.append(kwargs) or assessment + parent = AgentParentNodeRef(ref=ref, execution_id=execution, input_result_ref="input", input_digest="b" * 64) + workspace = AgentTestWorkspaceRef( + binding_id="binding", + workspace_id="workspace", + backend_binding_ref="binding", + backend_workspace_ref="workspace", + pinned_snapshot_id="snapshot", + home_snapshot_id=None, + workspace_generation=0, + ) + endpoint = NativeAgentEndpointContext( + ref=AgentNativeTestRef.model_validate(ref.model_dump(mode="json")), + config=AgentPreparedConfig( + parent_node=parent, + workflow_native_run_id="workflow-run", + workspace=workspace, + config_digest="e" * 64, + config_ref="config", + config={}, + ), + association=None, + native_test_profile="confirmed", + ) + monkeypatch.setattr(service, "_endpoint", lambda _ref: endpoint) + resolved = ResolvedNativeTestContext( + ref=ref, + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + graph={"nodes": [{"id": "node"}]}, + inputs={}, + graph_revision="d" * 64, + input_revision="b" * 64, + resource_manifest=(), + generation=0, + root_native_run_id="workflow-run", + native_run_id="workflow-run", + parent_invocation_id=None, + call_depth=0, + budget_ref="budget", + cancellation_ref="cancel", + execution_mode="native", ) - parent = SimpleNamespace(execution_id=execution, input_digest="b" * 64, model_dump=lambda **_kwargs: {}) - monkeypatch.setattr(service, "_endpoint", lambda _ref: SimpleNamespace(config=SimpleNamespace(parent_node=parent))) soul = AgentSoulConfig.model_validate( { "model": { @@ -180,7 +289,14 @@ def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth } } ) - definition = SimpleNamespace(node_id="node", agent_id="agent", snapshot_id="snapshot", soul=soul, stamp="c" * 64) + definition = NativeAgentDefinition( + node_id="node", + agent_id="agent", + snapshot_id="snapshot", + soul=soul, + node_job=WorkflowNodeJobConfig(), + stamp="c" * 64, + ) tool = module.AgentResolvedTool( "plugin", "test/plugin/provider", @@ -192,33 +308,14 @@ def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth None, False, ) - monkeypatch.setattr( - service, "_tool", lambda **_kwargs: (SimpleNamespace(graph_revision="d" * 64), definition, tool, None) - ) + monkeypatch.setattr(service, "_tool", lambda **_kwargs: (resolved, definition, tool, None)) monkeypatch.setattr(module, "_sql_resource", lambda **_kwargs: None) - monkeypatch.setattr( - service, "_definition", lambda **_kwargs: (SimpleNamespace(graph_revision="d" * 64), definition) - ) + monkeypatch.setattr(service, "_definition", lambda **_kwargs: (resolved, definition)) if boundary in {"shell_run", "bootstrap", "config_pull"}: monkeypatch.setattr( service, "_shell", - lambda **_kwargs: ( - SimpleNamespace( - graph_revision="d" * 64, native_run_id="workflow-run", graph={"nodes": [{"id": "node"}]} - ), - definition, - SimpleNamespace( - config=SimpleNamespace( - config_digest="e" * 64, - config_ref="config", - workspace=SimpleNamespace( - binding_id="binding", model_dump=lambda **_kwargs: {"binding_id": "binding"} - ), - ) - ), - {"shell": {"runtime": "runtime"}}, - ), + lambda **_kwargs: (resolved, definition, endpoint, {"shell": {"runtime": "runtime"}}), raising=False, ) target = ( @@ -243,7 +340,10 @@ def test_prepare_actual_call_freezes_builder_reasoning_without_business_or_oauth assert reasoning == [] assert stored[0]["payload"]["assessment"]["action"] == "Invoke selected Agent model" assert stored[0]["payload"]["proposal"]["terms"]["max_calls"] == 1 - binding = invocation.bindings[operation_digest(key)] + with service.loader.session_factory() as session: + persisted = session.get(NativeTestInvocation, invocation.id) + assert persisted is not None + binding = persisted.bindings[operation_digest(key)] assert binding["approval_scope"] == "operation" assert binding["isolated"] is False assert "parameters" not in binding @@ -264,10 +364,15 @@ def test_only_the_selected_confirmed_model_can_use_the_outer_task_grant(): def test_model_target_keeps_the_published_provider_and_model(): - from models.agent_config_entities import AgentSoulConfig - from services.dify_builder.native_testing.agent_operations import AgentOperationService + from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig + from services.dify_builder.native_testing.agent_runtime_config import NativeAgentDefinition - definition = SimpleNamespace( + definition = NativeAgentDefinition( + node_id="node", + agent_id="agent", + snapshot_id="snapshot", + node_job=WorkflowNodeJobConfig(), + stamp="c" * 64, soul=AgentSoulConfig.model_validate( { "model": { @@ -276,7 +381,7 @@ def test_model_target_keeps_the_published_provider_and_model(): "model": "gpt-4o-mini", } } - ) + ), ) assert AgentOperationService._model_target(definition) == { "plugin_id": "langgenius/openai", @@ -286,24 +391,26 @@ def test_model_target_keeps_the_published_provider_and_model(): @pytest.mark.parametrize("report", ["unknown", "completed"]) -def test_backend_lost_response_preserves_real_api_completed_result(report): - from contextlib import contextmanager +def test_backend_lost_response_preserves_real_api_completed_result(report, operations_service, monkeypatch): from services.dify_builder.native_testing.agent_bridge import api_ref - from services.dify_builder.native_testing.agent_operations import AgentOperationService pending = envelope() - persisted = SimpleNamespace( - operation_id="operation", attempt_id="attempt", outcome="completed", result_ref="real-result" - ) - - @contextmanager - def sessions(): - yield SimpleNamespace(scalar=lambda _query: persisted) - - loader = SimpleNamespace(actor=SimpleNamespace(tenant_id="tenant"), session_factory=sessions) - service = AgentOperationService(loader=loader, state_store=object(), assessor=object()) - service.bridge.authorization = SimpleNamespace(_permit_ref=lambda _permit: api_ref(pending.ref)) + service = operations_service + with service.loader.session_factory.begin() as session: + session.add( + NativeEffectOutcome( + id="outcome", + tenant_id="tenant", + request_id="request", + invocation_id="inv", + operation_id="operation", + attempt_id="attempt", + outcome="completed", + result_ref="real-result", + ) + ) + monkeypatch.setattr(service.bridge.authorization, "_permit_ref", lambda _permit: api_ref(pending.ref)) receipt = service.settle( ref=pending.ref, permit=pending.permit, @@ -316,25 +423,50 @@ def test_backend_lost_response_preserves_real_api_completed_result(report): service.settle(ref=pending.ref, permit=pending.permit, outcome="completed", result_ref="fabricated-result") -def test_result_upload_cannot_create_or_replace_a_physical_result(monkeypatch): - from contextlib import contextmanager +def test_result_upload_cannot_create_or_replace_a_physical_result(monkeypatch, operations_service): from dify_agent.protocol.native_testing import AgentOperationKey - from services.dify_builder.native_testing.agent_operations import AgentOperationService - pending = envelope() - operation = SimpleNamespace(result_ref="real-result") + from datetime import UTC, datetime - @contextmanager - def sessions(): - yield SimpleNamespace(scalar=lambda _query: operation) + from runtime_contracts.builder.native.contracts import OperationKey, operation_digest + from services.dify_builder.native_testing.agent_bridge import api_ref + from services.dify_builder.native_testing.repository import OwnedNativeTest - loader = SimpleNamespace(load=lambda **_kwargs: SimpleNamespace(tenant_id="tenant"), session_factory=sessions) - service = AgentOperationService( - loader=loader, state_store=SimpleNamespace(load_result=lambda **_kwargs: "real"), assessor=object() + service = operations_service + key = OperationKey.model_validate(pending.intent.key.model_dump(mode="json")) + with service.loader.session_factory.begin() as session: + session.add( + NativeEffectOperation( + id="operation", + tenant_id="tenant", + request_id="request", + invocation_id="inv", + operation_digest=operation_digest(key), + intent_digest="a" * 64, + scope_digest="b" * 64, + state="completed", + result_ref="real-result", + ) + ) + owned = OwnedNativeTest( + ref=api_ref(pending.ref), + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=0, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", ) - monkeypatch.setattr(service, "_endpoint", lambda _ref: object()) + monkeypatch.setattr(service.loader, "load", lambda **_kwargs: owned) + monkeypatch.setattr(service.state_store, "load_result", lambda **_kwargs: "real") + monkeypatch.setattr(service, "_endpoint", lambda _ref: None) kwargs = {"ref": pending.ref, "key": AgentOperationKey.model_validate(pending.intent.key.model_dump(mode="json"))} assert service.store_result(**kwargs, result="real") == "real-result" with pytest.raises(BuilderExecutionPolicyError, match="native_result_conflict"): @@ -344,7 +476,7 @@ def test_result_upload_cannot_create_or_replace_a_physical_result(monkeypatch): @pytest.mark.parametrize("approved", [False, True]) @pytest.mark.parametrize("sqlite_session", [("models.model.App",)], indirect=True) def test_actual_inner_tool_service_never_prepares_or_dispatches_before_permission( - sqlite_session, monkeypatch, approved + sqlite_session, monkeypatch, approved, operations_service ): from core.plugin.impl.oauth import OAuthHandler from core.tools.tool_engine import ToolEngine @@ -378,20 +510,15 @@ def test_actual_inner_tool_service_never_prepares_or_dispatches_before_permissio if not approved: raise BuilderExecutionPolicyError("invalid_dispatch_permit") - authorization = SimpleNamespace( - validate_dispatch=validate, - claim_dispatch=lambda **_kwargs: calls.append("claim"), - record_outcome=lambda **_kwargs: calls.append("settled"), + service = operations_service + service.loader.actor = Actor(account_id=request.caller.user_id, tenant_id=request.caller.tenant_id) + authorization = service.bridge.authorization + monkeypatch.setattr(authorization, "validate_dispatch", validate) + monkeypatch.setattr(authorization, "claim_dispatch", lambda **_kwargs: calls.append("claim")) + monkeypatch.setattr(authorization, "record_outcome", lambda **_kwargs: calls.append("settled")) + monkeypatch.setattr( + service.state_store, "store_result", lambda **kwargs: calls.append(kwargs["result"]) or "real-result" ) - loader = SimpleNamespace( - actor=SimpleNamespace(account_id=request.caller.user_id, tenant_id=request.caller.tenant_id) - ) - service = module.AgentOperationService( - loader=loader, - state_store=SimpleNamespace(store_result=lambda **kwargs: calls.append(kwargs["result"]) or "real-result"), - assessor=object(), - ) - service.bridge.authorization = authorization tool = module.AgentResolvedTool( "plugin", request.tool.provider_id, @@ -445,7 +572,7 @@ def test_direct_service_cannot_lose_a_known_native_envelope_before_runtime(): AgentToolInnerService().invoke(_request(), session=object()) -def test_confirmed_tool_error_never_returns_private_provider_text(monkeypatch): +def test_confirmed_tool_error_never_returns_private_provider_text(monkeypatch, operations_service): from services.agent_tool_inner_service import AgentToolInnerService from services.dify_builder.native_testing import agent_operations as module from tests.unit_tests.services.test_agent_tool_inner_service import _request @@ -453,7 +580,8 @@ def test_confirmed_tool_error_never_returns_private_provider_text(monkeypatch): def failed(**_kwargs): raise ValueError("PRIVATE_PROVIDER_TOKEN_CANARY") - monkeypatch.setattr(module, "agent_operations_for_ref", lambda _ref: SimpleNamespace(invoke_tool=failed)) + monkeypatch.setattr(operations_service, "invoke_tool", failed) + monkeypatch.setattr(module, "agent_operations_for_ref", lambda _ref: operations_service) request = _request() request.native_dispatch = envelope() with pytest.raises(BuilderExecutionPolicyError) as error: @@ -463,25 +591,28 @@ def test_confirmed_tool_error_never_returns_private_provider_text(monkeypatch): @pytest.mark.parametrize("phase", ["prepare", "invoke"]) -def test_confirmed_model_exception_never_returns_private_provider_text(monkeypatch, phase): +def test_confirmed_model_exception_never_returns_private_provider_text(monkeypatch, phase, operations_service): from services.agent_llm_inner_service import AgentLLMInnerService from services.dify_builder.native_testing import agent_operations as module def failed(*_args, **_kwargs): raise ValueError("PRIVATE_MODEL_TOKEN_CANARY") - owner = SimpleNamespace(validate_model_request=lambda **_kwargs: None, invoke_model=failed) - monkeypatch.setattr(module, "agent_operations_for_ref", lambda _ref: owner) + from services.agent_llm_inner_service import PreparedAgentLLMInvocation + from tests.unit_tests.core.model_fixtures import make_model_instance + from tests.unit_tests.services.test_agent_llm_inner_service import _request + + monkeypatch.setattr(operations_service, "validate_model_request", lambda **_kwargs: None) + monkeypatch.setattr(operations_service, "invoke_model", failed) + monkeypatch.setattr(module, "agent_operations_for_ref", lambda _ref: operations_service) service = AgentLLMInnerService() monkeypatch.setattr(service, "_validate_app_tenant", failed) - request = SimpleNamespace( - native_dispatch=envelope(), caller=SimpleNamespace(app_id="app", tenant_id="tenant"), target=object() - ) - invoke = ( - (lambda: service.prepare(request)) - if phase == "prepare" - else (lambda: list(service.invoke(SimpleNamespace(request=request)))) + request = _request() + request.native_dispatch = envelope() + prepared = PreparedAgentLLMInvocation( + request=request, model_instance=make_model_instance(provider="openai", model="test-model") ) + invoke = (lambda: service.prepare(request)) if phase == "prepare" else (lambda: list(service.invoke(prepared))) with pytest.raises(BuilderExecutionPolicyError) as error: invoke() assert "PRIVATE_MODEL_TOKEN_CANARY" not in str(error.value) diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_preparation_owner.py b/api/tests/unit_tests/services/dify_builder/test_agent_preparation_owner.py index 158f74e583b..e01948222cb 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_preparation_owner.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_preparation_owner.py @@ -1,7 +1,6 @@ -"""S1 orchestration and real Flask boundary; SQL/storage/backend doubles are explicit.""" +"""S1 boundaries with real owner/entity types, SQLite ACKs and explicit I/O ports.""" from contextlib import contextmanager -from types import SimpleNamespace import pytest from flask import Flask @@ -17,6 +16,7 @@ from runtime_contracts.builder.native.agent_contracts import ( ) from runtime_contracts.builder.native.contracts import NativeTestContextRef from services.dify_builder.native_testing import state_store as state +from tests.unit_tests.services.dify_builder import native_owner_fixtures REF = NativeTestContextRef(request_id="req", invocation_id="inv", context_digest="a" * 64) PARENT = AgentParentNodeRef( @@ -37,49 +37,250 @@ WORKSPACE = AgentTestWorkspaceRef( CONFIG = {"composition": {"schema_version": 2, "layers": []}, "session_snapshot": None} -class SQLDouble: - """One root lock namespace only; this is not a PostgreSQL race proof.""" - - def __init__(self): - self.rows = [] - self.locked = False - - def scalar(self, _query): - return self.rows[0] if self.rows else None - - def add(self, row): - self.rows.append(row) - - def flush(self): - pass - - @contextmanager - def locked_root(self, **_kwargs): - assert not self.locked - self.locked = True - try: - yield self, SimpleNamespace(tenant_id="tenant"), None - finally: - self.locked = False +operations_service = native_owner_fixtures.operations_service +preparation_sql = native_owner_fixtures.preparation_sql -def claim_store(monkeypatch): - sql = SQLDouble() - store = state.NativeExecutionStateStore(SimpleNamespace(locked=sql.locked_root)) - prep = SimpleNamespace( +def resolved_context(**updates): + from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext + + return ResolvedNativeTestContext( + ref=REF, + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + graph={}, + inputs={}, + graph_revision="a" * 64, + input_revision="b" * 64, + resource_manifest=(), + generation=4, + root_native_run_id="workflow", + native_run_id="workflow", + parent_invocation_id=None, + call_depth=0, + budget_ref="budget", + cancellation_ref="cancel", + execution_mode="native", + ).model_copy(update=updates) + + +def read_authority(): + from datetime import UTC, datetime + + from services.dify_builder.native_testing.private_reads import PrivateReadOwner + + return PrivateReadOwner( + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + parent_invocation_id=None, + context_object_id="context", + generation=4, + graph_revision="a" * 64, + root_revision_matches=True, + input_revision="b" * 64, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + cancelled=False, + active=True, + request_state="running", + ) + + +def prepared_state(): + return state._AgentPreparation( parent=PARENT, - context=SimpleNamespace(tenant_id="tenant", native_run_id="workflow", generation=4), + context=resolved_context(), + authority=read_authority(), workspace=WORKSPACE, resource_digest="e" * 64, objects=[], ) + + +def payloads(session): + from sqlalchemy import select + + return session.scalars(select(state.NativePayloadObject)).all() + + +def preparation_owner(): + from datetime import UTC, datetime + + from services.dify_builder.native_testing.repository import OwnedNativeTest + + return OwnedNativeTest( + ref=REF, + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=4, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", + ) + + +def preparation_session(monkeypatch, operations_service, preparation_sql): + from datetime import UTC, datetime + + from services.dify_builder.native_testing.repository import OwnedNativeTest + + loader = operations_service.loader + session = preparation_sql + owned = OwnedNativeTest( + ref=REF, + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=4, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", + ) + from models.dify_builder_native_tests import NativeTestScope + + scope = NativeTestScope( + tenant_id="tenant", + request_id="req", + max_calls=10, + max_dispatches=10, + max_cost_units=10, + generation=4, + task_approved=False, + cancelled=False, + expires_at=owned.expires_at, + budget_limits={}, + budget_spent={}, + profile={}, + ) + session.info["locked"] = False + + @contextmanager + def locked_root(**_kwargs): + assert not session.info["locked"] + session.info["locked"] = True + try: + yield session, owned, scope + session.commit() + except BaseException: + session.rollback() + raise + finally: + session.info["locked"] = False + + monkeypatch.setattr(loader, "locked", locked_root) + return session, owned, scope + + +def node_association(monkeypatch, operations_service, context, data): + from graphon.runtime import InitParams + from services.dify_builder.native_testing.checkpoint_bridge import NativeCheckpointBridge + from services.dify_builder.native_testing.control import marked_native_node_class + from services.dify_builder.native_testing.host_execution import NativeHostLaunch + from services.dify_builder.native_testing.host_lifetime import NativeHostLifetime + from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService + from services.dify_builder.native_testing.operation_journal import NodeOperationJournal + from services.dify_builder.native_testing.runtime_bridge import NativeNodePreparation, NativeRuntimeBridge + from services.workflow_runtime.agent_node import DifyAgentNode + from tests.unit_tests.services.dify_builder.test_effect_mock import _factory + + store = operations_service.state_store + lifecycle = NativeTestLifecycleService(operations_service.loader, state_store=store) + host = NativeHostLaunch( + context=context, + dispatch_id="dispatch", + lifetime=NativeHostLifetime(context=context, dispatch_id="dispatch"), + journal=NodeOperationJournal(state=store, authorization=operations_service.bridge.authorization), + checkpoints=NativeCheckpointBridge(state=store, lifecycle=lifecycle), + fence_pending=lifecycle.fence_pending, + stop_sealer=lifecycle, + authorization=operations_service.bridge.authorization, + http_baseline=(1.0, 1.0, 1.0), + ) + + def unused(**_kwargs): + pytest.fail("Node association attempted effect collaborator construction") + + bridge = NativeRuntimeBridge( + state=store, + collaborators=unused, + boundary_owner=host.boundary_owner, + control_sink=host.control_sink, + node_arguments=unused, + ) + monkeypatch.setattr(bridge, "resolve", lambda _ref: context) + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, AGENT_BACKEND_BASE_URL="http://agent.invalid") + factory = _factory(frozenset()) + node_class = marked_native_node_class(DifyAgentNode, marked=True) + association = NativeNodePreparation( + bridge=bridge, ref=context.ref, node_id="node", node_class=node_class, runtime_state=factory.runtime_state + ) + node = node_class( + node_id="node", + data=data, + init_params=InitParams( + workflow_id="workflow", + graph_config=context.graph, + run_context=factory.init_params.run_context, + call_depth=0, + ), + runtime_state=factory.runtime_state, + native_control_owner=host.boundary_owner, + native_control_sink=host.control_sink, + **factory._build_agent_node_init_kwargs(node_class=DifyAgentNode), + ) + association.attach(node) + node.bind_execution_id(PARENT.execution_id) + return association + + +def workspace_service(monkeypatch, operations_service, prepare): + from shellctl.shared.schemas import NativeScopeLimits + + from services.dify_builder.native_testing.agent_workspace import AgentTestWorkspaceService + + service = AgentTestWorkspaceService( + operations_service.loader, + state_store=operations_service.state_store, + limits=NativeScopeLimits( + cpu_quota=1000, + memory_bytes=64 << 20, + pids=8, + fds=32, + wall_seconds=60, + input_bytes=1024, + output_bytes=4096, + disk_bytes=1 << 20, + ), + ) + monkeypatch.setattr(service, "prepare", prepare) + return service + + +def claim_store(monkeypatch, operations_service, preparation_sql): + sql, _owned, _scope = preparation_session(monkeypatch, operations_service, preparation_sql) + store = operations_service.state_store + prep = prepared_state() monkeypatch.setattr(store, "_agent_prepare", lambda **_kwargs: prep) monkeypatch.setattr(store, "_agent_recheck", lambda *_args, **_kwargs: None) monkeypatch.setattr(store, "_agent_refresh_access", lambda *_args: None, raising=False) monkeypatch.setattr(store, "_agent_object", lambda *_args, **_kwargs: None) def prepare(**_kwargs): - assert not sql.locked, "crypto/storage must precede SQL writer" + assert not sql.info["locked"], "crypto/storage must precede SQL writer" return state.ObjectReceipt( "obj", "tenant", @@ -96,9 +297,9 @@ def claim_store(monkeypatch): return store, sql -def test_claim_observes_first_insertion_under_actual_state_owner(monkeypatch): +def test_claim_observes_first_insertion_under_actual_state_owner(monkeypatch, operations_service, preparation_sql): assert hasattr(state.NativeExecutionStateStore, "claim_agent_create"), "S1 atomic create claim missing" - store, sql = claim_store(monkeypatch) + store, sql = claim_store(monkeypatch, operations_service, preparation_sql) first = store.claim_agent_create( ref=REF, parent_node=PARENT, @@ -116,7 +317,7 @@ def test_claim_observes_first_insertion_under_actual_state_owner(monkeypatch): assert first.kind == "inserted" assert second.kind == "existing" assert first.config_ref == second.config_ref - assert len(sql.rows) == 1 + assert len(payloads(sql)) == 1 assert first.config_digest == canonical_digest(CONFIG) with pytest.raises(BuilderExecutionPolicyError): store.claim_agent_create( @@ -128,9 +329,9 @@ def test_claim_observes_first_insertion_under_actual_state_owner(monkeypatch): ) -def test_storage_mutation_invalidates_final_claim(monkeypatch): +def test_storage_mutation_invalidates_final_claim(monkeypatch, operations_service, preparation_sql): assert hasattr(state.NativeExecutionStateStore, "claim_agent_create"), "S1 atomic create claim missing" - store, sql = claim_store(monkeypatch) + store, sql = claim_store(monkeypatch, operations_service, preparation_sql) def refuse(*_args, **_kwargs): raise BuilderExecutionPolicyError("native_agent_owner_mismatch") @@ -144,7 +345,7 @@ def test_storage_mutation_invalidates_final_claim(monkeypatch): definition_receipt=AgentDefinitionReceipt("node", "s"), config=CONFIG, ) - assert not sql.rows + assert not payloads(sql) @pytest.mark.parametrize("path", ["prepare-run", "prepared-config"]) @@ -171,7 +372,7 @@ def test_actual_flask_service_auth_stops_before_preparation_lookup(monkeypatch, @pytest.mark.parametrize("mutation", ["run", "mode", "parent", "digest", "generation"]) -def test_backend_identity_is_read_outside_writer_before_bind(mutation): +def test_backend_identity_is_read_outside_writer_before_bind(mutation, monkeypatch, operations_service): from dify_agent.protocol.native_testing import ( AgentPrepareRunPayload, AgentRuntimeIdentity, @@ -190,44 +391,49 @@ def test_backend_identity_is_read_outside_writer_before_bind(mutation): ) calls = [] - class Store: - def load_prepared_config(self, **_kwargs): - calls.append("load") - return config + def load_prepared_config(**_kwargs): + calls.append("load") + return config - def prepared_config_ref(self, **_kwargs): - return "cfg" + def bind_agent_run(**_kwargs): + calls.append("bind") + return "association" - def bind_agent_run(self, **_kwargs): - calls.append("bind") - return "association" + def load_context(**_kwargs): + return resolved_context() - def load_context(self, **_kwargs): - return SimpleNamespace(generation=4) + store = operations_service.state_store + monkeypatch.setattr(store, "load_prepared_config", load_prepared_config) + monkeypatch.setattr(store, "bind_agent_run", bind_agent_run) + monkeypatch.setattr(store, "load_context", load_context) - class Backend: - def read_run_identity_sync(self, _run_id, _selection): - calls.append("backend") - raw = { - "run_id": "run", - "execution_mode": "builder_test", - "parent_node": PARENT.model_dump(mode="json"), - "request_digest": canonical_digest(CONFIG), - "agent_generation": 0, - } - if mutation == "run": - raw["run_id"] = "foreign" - if mutation == "mode": - raw["execution_mode"] = "ordinary" - if mutation == "parent": - raw["parent_node"] = PARENT.model_dump(mode="json") | {"input_result_ref": "foreign"} - if mutation == "digest": - raw["request_digest"] = "f" * 64 - if mutation == "generation": - raw["agent_generation"] = 1 - return AgentRuntimeIdentity.model_validate(raw) + def read_run_identity_sync(_run_id, _selection): + calls.append("backend") + raw = { + "run_id": "run", + "execution_mode": "builder_test", + "parent_node": PARENT.model_dump(mode="json"), + "request_digest": canonical_digest(CONFIG), + "agent_generation": 0, + } + if mutation == "run": + raw["run_id"] = "foreign" + if mutation == "mode": + raw["execution_mode"] = "ordinary" + if mutation == "parent": + raw["parent_node"] = PARENT.model_dump(mode="json") | {"input_result_ref": "foreign"} + if mutation == "digest": + raw["request_digest"] = "f" * 64 + if mutation == "generation": + raw["agent_generation"] = 1 + return AgentRuntimeIdentity.model_validate(raw) - service = owner.AgentPreparationService(state_store=Store(), backend=Backend(), workspace=None, config_builder=None) + from dify_agent.client import Client + + backend = Client(base_url="http://agent.invalid") + monkeypatch.setattr(backend, "read_run_identity_sync", read_run_identity_sync) + + service = owner.AgentPreparationService(state_store=store, backend=backend, workspace=None, config_builder=None) with pytest.raises(BuilderExecutionPolicyError): service.bind_created_run( payload=AgentPrepareRunPayload( @@ -241,7 +447,7 @@ def test_backend_identity_is_read_outside_writer_before_bind(mutation): @pytest.mark.parametrize("routes", [False, True]) -def test_inert_composer_uses_real_layer_dtos_without_model_schema_or_session(monkeypatch, routes): +def test_inert_composer_uses_real_layer_dtos_without_model_schema_or_session(monkeypatch, routes, operations_service): from core.app.llm import model_access from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig from services.dify_builder.native_testing.agent_runtime_config import ( @@ -260,11 +466,15 @@ def test_inert_composer_uses_real_layer_dtos_without_model_schema_or_session(mon monkeypatch.setattr(model_access, "resolve_model_context_window", forbidden) - class Declarations: - def prepare(self, **_kwargs): - return PreparedAgentToolDeclarations(None, None, ()) + def prepare(**_kwargs): + return PreparedAgentToolDeclarations(None, None, ()) - builder = NativeAgentRuntimeConfigBuilder(loader=None, declarations=Declarations()) + from services.dify_builder.native_testing.agent_tool_declarations import AgentToolDeclarationService + + declarations = AgentToolDeclarationService() + monkeypatch.setattr(declarations, "prepare", prepare) + + builder = NativeAgentRuntimeConfigBuilder(loader=operations_service.loader, declarations=declarations) soul = AgentSoulConfig.model_validate( { "model": { @@ -291,13 +501,7 @@ def test_inert_composer_uses_real_layer_dtos_without_model_schema_or_session(mon ), "s", ) - context = SimpleNamespace( - tenant_id="tenant", - actor_id="actor", - app_id="app", - workflow_id="workflow-id", - native_run_id="workflow", - ) + context = resolved_context(workflow_id="workflow-id") request = builder.compose( context=context, definition=definition, @@ -331,7 +535,7 @@ def test_inert_composer_uses_real_layer_dtos_without_model_schema_or_session(mon @pytest.mark.parametrize("kind", ["valid", "duplicate", "lost-ack", "changed-run", "during-backend"]) -def test_callback_idempotency_and_mutation_recheck(kind): +def test_callback_idempotency_and_mutation_recheck(kind, monkeypatch, operations_service): from dify_agent.protocol.native_testing import ( AgentPrepareRunPayload, AgentRuntimeIdentity, @@ -354,37 +558,45 @@ def test_callback_idempotency_and_mutation_recheck(kind): loads = [] mutated = [] - class Store: - def load_context(self, **_kwargs): - return SimpleNamespace(generation=4) + def load_context(**_kwargs): + return resolved_context() - def load_prepared_config(self, **_kwargs): - loads.append(_kwargs) - if mutated: - return config.model_copy(update={"config_digest": "d" * 64}) - return config + def load_prepared_config(**_kwargs): + loads.append(_kwargs) + if mutated: + return config.model_copy(update={"config_digest": "d" * 64}) + return config - def bind_agent_run(self, **_kwargs): - association = _kwargs["association"] - if rows and rows[0] != association: - raise BuilderExecutionPolicyError("native_payload_conflict") - if not rows: - rows.append(association) - return "association" + def bind_agent_run(**_kwargs): + association = _kwargs["association"] + if rows and rows[0] != association: + raise BuilderExecutionPolicyError("native_payload_conflict") + if not rows: + rows.append(association) + return "association" - class Backend: - def read_run_identity_sync(self, run_id, selection): - if kind == "during-backend": - mutated.append(True) - return AgentRuntimeIdentity( - run_id=run_id, - execution_mode="builder_test", - parent_node=selection.parent_node, - request_digest=selection.request_digest, - agent_generation=0, - ) + store = operations_service.state_store + monkeypatch.setattr(store, "load_context", load_context) + monkeypatch.setattr(store, "load_prepared_config", load_prepared_config) + monkeypatch.setattr(store, "bind_agent_run", bind_agent_run) - service = AgentPreparationService(state_store=Store(), backend=Backend(), workspace=None, config_builder=None) + def read_run_identity_sync(run_id, selection): + if kind == "during-backend": + mutated.append(True) + return AgentRuntimeIdentity( + run_id=run_id, + execution_mode="builder_test", + parent_node=selection.parent_node, + request_digest=selection.request_digest, + agent_generation=0, + ) + + from dify_agent.client import Client + + backend = Client(base_url="http://agent.invalid") + monkeypatch.setattr(backend, "read_run_identity_sync", read_run_identity_sync) + + service = AgentPreparationService(state_store=store, backend=backend, workspace=None, config_builder=None) payload = AgentPrepareRunPayload( parent_node=PARENT.model_dump(mode="json"), agent_run_id="run", @@ -409,6 +621,8 @@ def test_callback_idempotency_and_mutation_recheck(kind): @pytest.mark.parametrize("mutation", ["none", "input", "context", "bound-node", "config"]) def test_initial_preparation_uses_bound_uuid_typed_inputs_and_rechecks_every_io( mutation, + monkeypatch, + operations_service, ): from dify_agent.protocol.schemas import CreateRunRequest @@ -429,63 +643,71 @@ def test_initial_preparation_uses_bound_uuid_typed_inputs_and_rechecks_every_io( } ) graph = {"nodes": [{"id": "node", "data": data.model_dump(mode="json")}]} - context = SimpleNamespace(ref=REF, generation=4, graph=graph) - definition = SimpleNamespace(snapshot_id="pin", stamp="s") + from dataclasses import replace + + from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig + from services.dify_builder.native_testing.agent_runtime_config import ( + NativeAgentDefinition, + NativeAgentRuntimeConfigBuilder, + ) + + context = resolved_context(graph=graph) + definition = NativeAgentDefinition("node", "agent", "pin", AgentSoulConfig(), WorkflowNodeJobConfig(), "s") values = {"query": False, "values": {"start.rows": [1, True, "1"]}} current = [] claims = [] stored = [] + association = node_association(monkeypatch, operations_service, context, data) + monkeypatch.setattr( + association, + "resolve", + lambda: context.model_copy(update={"generation": 5}) if "context" in current else context, + ) + builder = NativeAgentRuntimeConfigBuilder(loader=operations_service.loader) + monkeypatch.setattr( + builder, + "load_definition", + lambda **_kwargs: replace(definition, snapshot_id="other") if "config" in current else definition, + ) + monkeypatch.setattr( + builder, "typed_inputs", lambda **_kwargs: {"query": "changed", "values": {}} if "input" in current else values + ) - class Association: - node_id = "node" - execution_id = PARENT.execution_id - bound_node = SimpleNamespace(node_data=data) + def compose(**kwargs): + return CreateRunRequest( + composition={"layers": []}, + execution_mode="builder_test", + native_test_ref=REF.model_dump(mode="json"), + native_parent_node=kwargs["parent_node"].model_dump(mode="json"), + native_test_protocol_version=1, + ) - def resolve(self): - return SimpleNamespace(ref=REF, generation=5, graph=graph) if "context" in current else context + monkeypatch.setattr(builder, "compose", compose) - class Builder: - def load_definition(self, **_kwargs): - return SimpleNamespace(snapshot_id="other") if "config" in current else definition + def prepare(**_kwargs): + current.append(mutation) + if mutation == "bound-node": + association.bound_node.node_data.agent_task = "mutated" + return WORKSPACE - def typed_inputs(self, **_kwargs): - return {"query": "changed", "values": {}} if "input" in current else values - - def compose(self, **_kwargs): - return CreateRunRequest( - composition={"layers": []}, - execution_mode="builder_test", - native_test_ref=REF.model_dump(mode="json"), - native_parent_node=_kwargs["parent_node"].model_dump(mode="json"), - native_test_protocol_version=1, - ) - - class Workspace: - def prepare(self, **_kwargs): - current.append(mutation) - if mutation == "bound-node": - data.agent_task = "mutated" - return WORKSPACE - - class Store: - def store_node_inputs(self, **_kwargs): - stored.append(_kwargs) - return "input" - - def claim_agent_create(self, **_kwargs): - claims.append(_kwargs) - return AgentCreateInserted("cfg", canonical_digest(CONFIG)) - - service = AgentPreparationService(Store(), None, Workspace(), Builder()) + workspace = workspace_service(monkeypatch, operations_service, prepare) + store = operations_service.state_store + monkeypatch.setattr(store, "store_node_inputs", lambda **kwargs: stored.append(kwargs) or "input") + monkeypatch.setattr( + store, + "claim_agent_create", + lambda **kwargs: claims.append(kwargs) or AgentCreateInserted("cfg", canonical_digest(CONFIG)), + ) + service = AgentPreparationService(store, None, workspace, builder) if mutation == "none": - result = service.prepare_initial(association=Association()) + result = service.prepare_initial(association=association) assert result.parent_node.execution_id == PARENT.execution_id assert result.parent_node.input_digest == canonical_digest(values) assert result.claim.kind == "inserted" assert len(claims) == 1 else: with pytest.raises(BuilderExecutionPolicyError): - service.prepare_initial(association=Association()) + service.prepare_initial(association=association) assert not claims assert stored[0]["generation"] == 4 assert stored[0]["inputs"] == values @@ -496,22 +718,15 @@ def test_initial_preparation_uses_bound_uuid_typed_inputs_and_rechecks_every_io( "mutation", ["none", "parent", "workspace", "run", "owner", "config-ref", "resource"], ) -def test_private_config_read_rechecks_exact_stored_owner_and_resource_relations(monkeypatch, mutation): - sql = SQLDouble() - - @contextmanager - def session_factory(): - yield sql - - loader = SimpleNamespace(locked=sql.locked_root, session_factory=session_factory) - store = state.NativeExecutionStateStore(loader) - prep = SimpleNamespace( - parent=PARENT, - context=SimpleNamespace(tenant_id="tenant", native_run_id="workflow", generation=4), - workspace=WORKSPACE, - resource_digest="e" * 64, - objects=[], - ) +def test_private_config_read_rechecks_exact_stored_owner_and_resource_relations( + monkeypatch, + mutation, + operations_service, + preparation_sql, +): + sql, _owned, _scope = preparation_session(monkeypatch, operations_service, preparation_sql) + store = operations_service.state_store + prep = prepared_state() value = { "schema": "agent-config-v2", "parent_node": PARENT.model_dump(mode="json"), @@ -539,7 +754,9 @@ def test_private_config_read_rechecks_exact_stored_owner_and_resource_relations( "a" * 64, canonical_digest(value), ) - sql.rows = [SimpleNamespace(**vars(receipt))] + sql.add(state.NativePayloadObject(**vars(receipt))) + sql.commit() + monkeypatch.setattr(store.loader, "session_factory", lambda: sql) monkeypatch.setattr(store, "_agent_prepare", lambda **_kwargs: prep) monkeypatch.setattr(store, "_agent_refresh_access", lambda *_args: None) checked = [] @@ -610,8 +827,9 @@ def test_actual_flask_control_caps_and_validation_before_factory(monkeypatch, pa def test_authenticated_flask_callback_through_real_sdk_readback_is_idempotent_and_conflict_safe( monkeypatch, + operations_service, ): - """Real Flask→HTTPX SDK wire; SQL/config/storage are explicitly bounded doubles.""" + """Real Flask→HTTPX SDK wire with explicit identity and storage method ports.""" import httpx from dify_agent.client import Client from dify_agent.protocol.native_testing import ( @@ -641,29 +859,28 @@ def test_authenticated_flask_callback_through_real_sdk_readback_is_idempotent_an bindings = [] reads = [] - class Store: - def load_context(self, **_kwargs): - return SimpleNamespace( - generation=4, - tenant_id="tenant", - actor_id="actor", - app_id="app", - native_run_id="workflow", - ) + def load_context(**_kwargs): + return resolved_context() - def load_prepared_config(self, **_kwargs): - return owned + def load_prepared_config(**_kwargs): + return owned - def discover_agent_run(self, **_kwargs): - return bindings[0] if bindings else None + def discover_agent_run(**_kwargs): + return bindings[0] if bindings else None - def bind_agent_run(self, **_kwargs): - selected = _kwargs["association"] - if bindings and selected != bindings[0]: - raise BuilderExecutionPolicyError("private-canary") - if not bindings: - bindings.append(selected) - return "association" + def bind_agent_run(**_kwargs): + selected = _kwargs["association"] + if bindings and selected != bindings[0]: + raise BuilderExecutionPolicyError("private-canary") + if not bindings: + bindings.append(selected) + return "association" + + store = operations_service.state_store + monkeypatch.setattr(store, "load_context", load_context) + monkeypatch.setattr(store, "load_prepared_config", load_prepared_config) + monkeypatch.setattr(store, "discover_agent_run", discover_agent_run) + monkeypatch.setattr(store, "bind_agent_run", bind_agent_run) def handle(request): reads.append(request) @@ -691,7 +908,7 @@ def test_authenticated_flask_callback_through_real_sdk_readback_is_idempotent_an sync_http_client=http, ) as sdk, ): - service = AgentPreparationService(Store(), sdk, None, None) + service = AgentPreparationService(store, sdk, None, None) from services.dify_builder.native_testing.agent_lineage import ( NativeAgentEndpointLineage, ) @@ -764,7 +981,7 @@ def test_authenticated_flask_callback_through_real_sdk_readback_is_idempotent_an assert len(reads) == 5 -def test_config_creation_readback_cap_precedes_decryption_and_sql(monkeypatch): +def test_config_creation_readback_cap_precedes_decryption_and_sql(monkeypatch, operations_service): import inspect assert "max_bytes" in inspect.signature(state.NativeExecutionStateStore.prepare_object).parameters, ( @@ -776,7 +993,7 @@ def test_config_creation_readback_cap_precedes_decryption_and_sql(monkeypatch): monkeypatch.setattr(state.storage, "load", lambda *_args: b"x" * (4096 + 1)) monkeypatch.setattr(state.encrypter, "decrypt_token", lambda *_args: calls.append("decrypt")) with pytest.raises(BuilderExecutionPolicyError): - state.NativeExecutionStateStore(None).prepare_object( + operations_service.state_store.prepare_object( ref=REF, tenant_id="tenant", kind="result", @@ -788,10 +1005,9 @@ def test_config_creation_readback_cap_precedes_decryption_and_sql(monkeypatch): @pytest.mark.parametrize("mutation", ["none", "outputs", "routes", "binding-kind"]) -def test_current_published_binding_must_match_graph_outputs_and_routes(monkeypatch, mutation): - """Real definition loader and graph renderer, explicit SQL/access stand-ins.""" +def test_current_published_binding_must_match_graph_outputs_and_routes(monkeypatch, mutation, operations_service): + """Real definition loader, graph renderer, ORM rows and Session query boundaries.""" import services.dify_builder.native_testing.agent_runtime_config as config_module - from core.dify_builder.models import Actor from models.agent import ( AgentConfigSnapshot, WorkflowAgentBindingType, @@ -812,42 +1028,40 @@ def test_current_published_binding_must_match_graph_outputs_and_routes(monkeypat soul = AgentSoulConfig.model_validate( {"model": {"plugin_id": "langgenius/openai", "model_provider": "openai", "model": "test"}} ) - agent = SimpleNamespace(tenant_id="tenant", active_config_snapshot_id="pin") - snapshot = SimpleNamespace(tenant_id="tenant", agent_id="agent", config_snapshot_dict=soul.model_dump(mode="json")) - binding = SimpleNamespace( + agent = state.Agent(id="agent", tenant_id="tenant", active_config_snapshot_id="pin") + snapshot = AgentConfigSnapshot(id="pin", tenant_id="tenant", agent_id="agent", config_snapshot=soul) + binding = WorkflowAgentNodeBinding( + id="binding", + tenant_id="tenant", + app_id="app", + workflow_id="workflow", + node_id="node", agent_id="agent", current_snapshot_id="pin", - node_job_config_dict=job.model_dump(mode="json"), + node_job_config=job, binding_type="foreign" if mutation == "binding-kind" else WorkflowAgentBindingType.ROSTER_AGENT, ) - class Session: - def get(self, model, _identity): - return snapshot if model is AgentConfigSnapshot else agent - - def scalar(self, query): - return binding if query.column_descriptions[0]["entity"] is WorkflowAgentNodeBinding else agent - - @contextmanager - def sessions(): - yield Session() - + loader = operations_service.loader + session = loader.session_factory() + monkeypatch.setattr(session, "get", lambda model, _identity: snapshot if model is AgentConfigSnapshot else agent) + monkeypatch.setattr( + session, + "scalar", + lambda query: binding if query.column_descriptions[0]["entity"] is WorkflowAgentNodeBinding else agent, + ) + monkeypatch.setattr(loader, "session_factory", lambda: session) monkeypatch.setattr(AgentBindingService, "authorize", lambda **_kwargs: None) monkeypatch.setattr(AgentBindingService, "recheck_access", lambda *_args, **_kwargs: None) monkeypatch.setattr( config_module, "validate_invocation_workflow", - lambda *_args, **_kwargs: SimpleNamespace(version="draft", _environment_variables="[]"), + lambda *_args, **_kwargs: Workflow(version="draft", environment_variables=[]), ) - loader = SimpleNamespace(actor=Actor(account_id="actor", tenant_id="tenant"), session_factory=sessions) + from models.workflow import Workflow + builder = config_module.NativeAgentRuntimeConfigBuilder(loader=loader) - context = SimpleNamespace( - tenant_id="tenant", - actor_id="actor", - app_id="app", - workflow_id="workflow", - graph={"nodes": [{"id": "node", "data": data}]}, - ) + context = resolved_context(graph={"nodes": [{"id": "node", "data": data}]}) if mutation == "none": assert builder.load_definition(context=context, node_id="node").snapshot_id == "pin" else: @@ -868,7 +1082,9 @@ def test_current_published_binding_must_match_graph_outputs_and_routes(monkeypat ("secret_refs", "ref", "opaque-secret-reference"), ], ) -def test_composer_to_claim_shell_env_is_reference_only(monkeypatch, owner, kind, field, value): +def test_composer_to_claim_shell_env_is_reference_only( + monkeypatch, owner, kind, field, value, operations_service, preparation_sql +): """Actual composer + claim owner; only SQL/storage and declaration I/O are doubles.""" from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig from services.dify_builder.native_testing.agent_runtime_config import ( @@ -881,10 +1097,14 @@ def test_composer_to_claim_shell_env_is_reference_only(monkeypatch, owner, kind, calls = [] - class Declarations: - def prepare(self, **_kwargs): - calls.append("declarations") - return PreparedAgentToolDeclarations(None, None, ()) + def prepare(**_kwargs): + calls.append("declarations") + return PreparedAgentToolDeclarations(None, None, ()) + + from services.dify_builder.native_testing.agent_tool_declarations import AgentToolDeclarationService + + declarations = AgentToolDeclarationService() + monkeypatch.setattr(declarations, "prepare", prepare) env = {kind: [{"name": "SERVICE_TOKEN", field: value}]} soul = AgentSoulConfig.model_validate( @@ -897,7 +1117,7 @@ def test_composer_to_claim_shell_env_is_reference_only(monkeypatch, owner, kind, **({"env": env} if owner == "soul" else {"tools": {"cli_tools": [{"name": "tool", "env": env}]}}), } ) - builder = NativeAgentRuntimeConfigBuilder(loader=None, declarations=Declarations()) + builder = NativeAgentRuntimeConfigBuilder(loader=operations_service.loader, declarations=declarations) definition = NativeAgentDefinition( "node", "agent", @@ -906,14 +1126,8 @@ def test_composer_to_claim_shell_env_is_reference_only(monkeypatch, owner, kind, WorkflowNodeJobConfig(workflow_prompt="Review"), "s", ) - context = SimpleNamespace( - tenant_id="tenant", - actor_id="actor", - app_id="app", - workflow_id="workflow", - native_run_id="workflow", - ) - store, sql = claim_store(monkeypatch) + context = resolved_context() + store, sql = claim_store(monkeypatch, operations_service, preparation_sql) original = store.prepare_object persisted = [] @@ -943,7 +1157,7 @@ def test_composer_to_claim_shell_env_is_reference_only(monkeypatch, owner, kind, if field == "ref": assert prepare().kind == "inserted" assert "opaque-secret-reference" in str(persisted) - assert len(sql.rows) == 1 + assert len(payloads(sql)) == 1 else: expected = ( "native_agent_config_credentials" if kind == "secret_refs" else "native_agent_environment_requires_owner" @@ -951,7 +1165,7 @@ def test_composer_to_claim_shell_env_is_reference_only(monkeypatch, owner, kind, with pytest.raises(BuilderExecutionPolicyError, match=expected): prepare() assert not persisted - assert not sql.rows + assert not payloads(sql) assert not calls, "native env refusal must precede ordinary mapping/declaration helpers" @@ -968,7 +1182,9 @@ def test_composer_to_claim_shell_env_is_reference_only(monkeypatch, owner, kind, "final-access", ], ) -def test_composed_definition_receipt_survives_initial_claim_io(monkeypatch, resource, boundary): +def test_composed_definition_receipt_survives_initial_claim_io( + monkeypatch, resource, boundary, operations_service, preparation_sql +): """Actual initial service/composer/claim/resource checks; SQL/access/storage are doubles. Removing the composition-owned receipt comparison must admit config A after @@ -1017,16 +1233,7 @@ def test_composed_definition_receipt_survives_initial_claim_io(monkeypatch, reso } ] } - context = SimpleNamespace( - ref=REF, - generation=4, - tenant_id="tenant", - actor_id="actor", - app_id="app", - workflow_id="workflow", - native_run_id="workflow", - graph=graph, - ) + context = resolved_context(graph=graph) scope = NativeScopeSpec( scope_id="binding", generation=9, @@ -1060,30 +1267,35 @@ def test_composed_definition_receipt_survives_initial_claim_io(monkeypatch, reso phase="ready", ) workspace = _ref(allocation) - snapshot = SimpleNamespace( + snapshot = state.AgentConfigSnapshot( id="pin", tenant_id="tenant", agent_id="agent", home_snapshot_id="home", - config_snapshot_dict=soul.model_dump(mode="json"), + config_snapshot=soul, ) - published = SimpleNamespace( + published = state.WorkflowAgentNodeBinding( + id="published", + tenant_id="tenant", + app_id="app", + workflow_id="workflow", + node_id="node", agent_id="agent", current_snapshot_id="pin", binding_type=state.WorkflowAgentBindingType.ROSTER_AGENT, - node_job_config_dict=job.model_dump(mode="json"), + node_job_config=job, ) rows = { state.AgentConfigSnapshot: snapshot, - state.Agent: SimpleNamespace(tenant_id="tenant", active_config_snapshot_id="pin"), - state.AgentHomeSnapshot: SimpleNamespace( + state.Agent: state.Agent(id="agent", tenant_id="tenant", active_config_snapshot_id="pin"), + state.AgentHomeSnapshot: state.AgentHomeSnapshot( id="home", tenant_id="tenant", agent_id="agent", status=state.AgentWorkingResourceStatus.ACTIVE, snapshot_ref="published-home", ), - state.AgentWorkspace: SimpleNamespace( + state.AgentWorkspace: state.AgentWorkspace( id="workspace", tenant_id="tenant", app_id="app", @@ -1093,7 +1305,7 @@ def test_composed_definition_receipt_survives_initial_claim_io(monkeypatch, reso status=state.AgentWorkingResourceStatus.ACTIVE, backend_workspace_ref=workspace.backend_workspace_ref, ), - state.AgentWorkspaceBinding: SimpleNamespace( + state.AgentWorkspaceBinding: state.AgentWorkspaceBinding( tenant_id="tenant", app_id="app", workspace_id="workspace", @@ -1104,7 +1316,7 @@ def test_composed_definition_receipt_survives_initial_claim_io(monkeypatch, reso backend_binding_ref=workspace.backend_binding_ref, status=state.AgentWorkingResourceStatus.ACTIVE, ), - state.NativeTestInvocation: SimpleNamespace( + state.NativeTestInvocation: state.NativeTestInvocation( active=True, tenant_id="tenant", request_id="req", @@ -1120,44 +1332,38 @@ def test_composed_definition_receipt_survives_initial_claim_io(monkeypatch, reso return changed.append(stage) if resource == "snapshot": - snapshot.config_snapshot_dict = { - **snapshot.config_snapshot_dict, - "model": {**snapshot.config_snapshot_dict["model"], "model": "model-B"}, - } + snapshot.config_snapshot = AgentSoulConfig.model_validate( + { + **snapshot.config_snapshot_dict, + "model": {**snapshot.config_snapshot_dict["model"], "model": "model-B"}, + } + ) else: - published.node_job_config_dict = { - **published.node_job_config_dict, - "workflow_prompt": "Task B", - } + published.node_job_config = WorkflowNodeJobConfig.model_validate( + { + **published.node_job_config_dict, + "workflow_prompt": "Task B", + } + ) - class ResourceSQL(SQLDouble): - def get(self, model, _key): - return rows[model] + sql, owned, scope_row = preparation_session(monkeypatch, operations_service, preparation_sql) + original_scalar = sql.scalar + monkeypatch.setattr(sql, "get", lambda model, _key: rows[model]) - def scalar(self, query): - model = query.column_descriptions[0]["entity"] - if model is state.NativePayloadObject: - return super().scalar(query) - return rows[model] + def scalar(query): + model = query.column_descriptions[0]["entity"] + return original_scalar(query) if model is state.NativePayloadObject else rows[model] - sql = ResourceSQL() - - @contextmanager - def sessions(): - yield sql - - loader = SimpleNamespace( - actor=SimpleNamespace(account_id="actor", tenant_id="tenant"), - load=lambda **_kwargs: mutate("claim-loader"), - session_factory=sessions, - locked=sql.locked_root, - _load=lambda *_args, **_kwargs: (SimpleNamespace(tenant_id="tenant"), None), - ) - store = state.NativeExecutionStateStore(loader) + monkeypatch.setattr(sql, "scalar", scalar) + loader = operations_service.loader + monkeypatch.setattr(loader, "load", lambda **_kwargs: mutate("claim-loader")) + monkeypatch.setattr(loader, "session_factory", lambda: sql) + monkeypatch.setattr(loader, "_load", lambda *_args, **_kwargs: (owned, scope_row)) + store = operations_service.state_store def private_read(*_args): mutate("claim-rbac") - return "authority" + return read_authority() monkeypatch.setattr(state, "validate_private_read", private_read) monkeypatch.setattr(store, "_validate_node_inputs_owner", lambda *_args, **_kwargs: None) @@ -1179,7 +1385,7 @@ def test_composed_definition_receipt_survives_initial_claim_io(monkeypatch, reso persisted = [] def prepare_object(**kwargs): - assert not sql.locked + assert not sql.info["locked"] persisted.append(kwargs["payload"]) mutate("crypto-storage") return state.ObjectReceipt( @@ -1200,7 +1406,9 @@ def test_composed_definition_receipt_survives_initial_claim_io(monkeypatch, reso mutate("final-access") monkeypatch.setattr(store, "_agent_refresh_access", refresh) - workflow = SimpleNamespace(version="published", _environment_variables="[]") + from models.workflow import Workflow + + workflow = Workflow(version="published", environment_variables=[]) monkeypatch.setattr(state, "validate_invocation_workflow", lambda *_args, **_kwargs: workflow) monkeypatch.setattr( config_module, @@ -1211,27 +1419,29 @@ def test_composed_definition_receipt_survives_initial_claim_io(monkeypatch, reso monkeypatch.setattr(AgentBindingService, "recheck_access", lambda *_args, **_kwargs: None) monkeypatch.setattr(access_service, "require_builder_agent_access", lambda **_kwargs: None) - class Declarations: - def prepare(self, **_kwargs): - return PreparedAgentToolDeclarations(None, None, ()) + def prepare(**_kwargs): + return PreparedAgentToolDeclarations(None, None, ()) - builder = config_module.NativeAgentRuntimeConfigBuilder(loader=loader, declarations=Declarations()) + from services.dify_builder.native_testing.agent_tool_declarations import AgentToolDeclarationService + + declarations = AgentToolDeclarationService() + monkeypatch.setattr(declarations, "prepare", prepare) + + builder = config_module.NativeAgentRuntimeConfigBuilder(loader=loader, declarations=declarations) monkeypatch.setattr(builder, "typed_inputs", lambda **_kwargs: inputs) - association = SimpleNamespace( - node_id="node", - execution_id=PARENT.execution_id, - bound_node=SimpleNamespace(node_data=DifyAgentNodeData.model_validate(graph["nodes"][0]["data"])), - resolve=lambda: context, + association = node_association( + monkeypatch, operations_service, context, DifyAgentNodeData.model_validate(graph["nodes"][0]["data"]) ) - service = AgentPreparationService(store, None, SimpleNamespace(prepare=lambda **_kwargs: workspace), builder) + workspace_owner = workspace_service(monkeypatch, operations_service, lambda **_kwargs: workspace) + service = AgentPreparationService(store, None, workspace_owner, builder) if boundary == "none": result = service.prepare_initial(association=association) assert result.claim.kind == "inserted" assert "model-A" in str(persisted) assert "model-B" not in str(persisted) - assert len(sql.rows) == 1 + assert len(payloads(sql)) == 1 else: with pytest.raises(BuilderExecutionPolicyError, match="native_agent_owner_mismatch"): service.prepare_initial(association=association) assert changed == [boundary] - assert not sql.rows, "stale composed bytes must never receive a SQL ACK" + assert not payloads(sql), "stale composed bytes must never receive a SQL ACK" diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_test_workspace.py b/api/tests/unit_tests/services/dify_builder/test_agent_test_workspace.py index e802a823869..c4bc0311d50 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_test_workspace.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_test_workspace.py @@ -2,34 +2,45 @@ from contextlib import contextmanager from datetime import UTC, datetime, timedelta -from types import SimpleNamespace -from unittest.mock import Mock import pytest +from sqlalchemy import event from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import NativeTestContextRef +from tests.unit_tests.services.dify_builder import native_owner_fixtures + +operations_service = native_owner_fixtures.operations_service +preparation_sql = native_owner_fixtures.preparation_sql -def test_resource_metadata_cannot_authorize_an_operation(): +def test_resource_metadata_cannot_authorize_an_operation(operations_service, sqlite_session): from services.dify_builder.native_testing.authorization import EffectAuthorizationService from tests.unit_tests.services.dify_builder.native_testing.test_authorization import binding, intent effect = intent() - owner = SimpleNamespace( - ref=SimpleNamespace(invocation_id="inv"), - bindings={"_agent_workspace_v1": binding(effect).model_dump(mode="json")}, - ) + from dataclasses import replace + + from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import preparation_owner + + owner = replace(preparation_owner(), bindings={"_agent_workspace_v1": binding(effect).model_dump(mode="json")}) with pytest.raises(BuilderExecutionPolicyError, match="native_cursor_mismatch"): - EffectAuthorizationService(Mock())._binding(Mock(), owner, effect) + EffectAuthorizationService(operations_service.loader)._binding(sqlite_session, owner, effect) def test_native_allocation_reservation_preserves_effect_entries(): from shellctl.shared.schemas import NativeScopeLimits, NativeScopeSpec + from models.dify_builder_native_tests import NativeTestInvocation from services.dify_builder.native_testing.agent_workspace import AgentWorkspaceAllocation, store_allocation - row = SimpleNamespace(bindings={"a" * 64: {"intent_digest": "protected"}}) + row = NativeTestInvocation( + tenant_id="tenant", + request_id="request", + invocation_id="inv", + active=True, + bindings={"a" * 64: {"intent_digest": "protected"}}, + ) scope = NativeScopeSpec( scope_id="binding", generation=1, @@ -67,47 +78,82 @@ def test_native_allocation_reservation_preserves_effect_entries(): assert row.bindings["_agent_workspace_v1"]["binding_id"] == "binding" -def service_fixture(monkeypatch, invocation_id="inv"): +def service_fixture(monkeypatch, sqlite_session, operations_service, invocation_id="inv"): from shellctl.shared.schemas import NativeScopeLimits from services.agent.workspace_service import AgentWorkspaceService from services.dify_builder.native_testing.agent_workspace import AgentTestWorkspaceService ref = NativeTestContextRef(request_id="request", invocation_id=invocation_id, context_digest="c" * 64) - row = SimpleNamespace(bindings={"a" * 64: {"protected": True}}) - snapshot = SimpleNamespace(id="snapshot", agent_id="agent", home_snapshot_id="home") - home = SimpleNamespace(id="home", snapshot_ref="home-published") - owned = SimpleNamespace( - ref=ref, tenant_id="tenant", app_id="app", expires_at=datetime.now(UTC) + timedelta(minutes=5) + from models.agent import AgentConfigSnapshot, AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding + from models.agent_config_entities import AgentSoulConfig + from models.dify_builder_native_tests import NativeTestInvocation + + session = sqlite_session + row = NativeTestInvocation( + tenant_id="tenant", + request_id=ref.request_id, + invocation_id=invocation_id, + active=True, + bindings={"a" * 64: {"protected": True}}, ) - session = Mock() + if session.get(AgentConfigSnapshot, "snapshot") is None: + session.add( + AgentConfigSnapshot( + id="snapshot", + tenant_id="tenant", + agent_id="agent", + version=1, + config_snapshot=AgentSoulConfig(), + home_snapshot_id="home", + ) + ) + session.add( + AgentHomeSnapshot( + id="home", + tenant_id="tenant", + agent_id="agent", + snapshot_ref="home-published", + ) + ) + session.add(row) + session.commit() + session.info["workspace_additions"] = [] + if not session.info.get("observe_workspace_additions"): + + def record_workspace_addition(_session, instance): + if isinstance(instance, AgentWorkspace | AgentWorkspaceBinding): + session.info["workspace_additions"].append(instance) + + event.listen(session, "after_attach", record_workspace_addition) + session.info["observe_workspace_additions"] = True + from dataclasses import replace + + from tests.unit_tests.services.dify_builder.test_agent_preparation_owner import preparation_owner, resolved_context + + owned = replace(preparation_owner(), ref=ref, expires_at=datetime.now(UTC) + timedelta(minutes=5)) transactions = [] - - def scalar(query): - entity = query.column_descriptions[0]["entity"].__name__ - return {"NativeTestInvocation": row, "AgentConfigSnapshot": snapshot, "AgentHomeSnapshot": home}.get(entity) - - session.scalar.side_effect = scalar - loader = Mock() + loader = operations_service.loader @contextmanager def locked(**kwargs): del kwargs transactions.append(True) try: - yield session, owned, None + with session.begin(): + yield session, owned, None finally: transactions.pop() - loader.locked.side_effect = locked - state = Mock() - state.load_context.return_value = SimpleNamespace( + monkeypatch.setattr(loader, "locked", locked) + monkeypatch.setattr(loader, "load", lambda **_kwargs: owned) + state = operations_service.state_store + context = resolved_context( ref=ref, - tenant_id="tenant", - app_id="app", native_run_id="run", graph={"nodes": [{"data": {"agent_binding": {"agent_id": "agent", "current_snapshot_id": "snapshot"}}}]}, ) + monkeypatch.setattr(state, "load_context", lambda **_kwargs: context) limits = NativeScopeLimits( cpu_quota=1000, memory_bytes=64 << 20, @@ -120,7 +166,13 @@ def service_fixture(monkeypatch, invocation_id="inv"): ) service = AgentTestWorkspaceService(loader, state_store=state, limits=limits) requests = [] - client = Mock() + from dify_agent.client import Client + + client = Client(base_url="http://agent.invalid") + session.info["destroy_requests"] = [] + monkeypatch.setattr( + client, "destroy_execution_binding_sync", lambda request: session.info["destroy_requests"].append(request) + ) def create(request): from dify_agent.protocol.execution_binding import CreateExecutionBindingResponse @@ -131,7 +183,7 @@ def service_fixture(monkeypatch, invocation_id="inv"): backend_ref = f"native:{request.binding_id}:1" return CreateExecutionBindingResponse(binding_ref=backend_ref, workspace_ref=backend_ref) - client.create_execution_binding_sync.side_effect = create + monkeypatch.setattr(client, "create_execution_binding_sync", create) @contextmanager def backend_client(): @@ -141,15 +193,17 @@ def service_fixture(monkeypatch, invocation_id="inv"): return service, ref, row, session, requests, client -def test_prepare_pins_home_and_never_allocates_conversation_or_updates_soul(monkeypatch): +def test_prepare_pins_home_and_never_allocates_conversation_or_updates_soul( + monkeypatch, sqlite_session, operations_service +): from models.agent import AgentWorkspace, AgentWorkspaceBinding, AgentWorkspaceOwnerType - service, ref, row, session, requests, client = service_fixture(monkeypatch) + service, ref, row, session, requests, client = service_fixture(monkeypatch, sqlite_session, operations_service) result = service.prepare(ref=ref, pinned_snapshot_id="snapshot", input_file_refs=()) assert requests[0].home_snapshot_ref == "home-published" assert requests[0].native_test_scope.network_targets == () assert row.bindings["a" * 64] == {"protected": True} - workspace, binding = [call.args[0] for call in session.add.call_args_list] + workspace, binding = session.info["workspace_additions"] assert isinstance(workspace, AgentWorkspace) assert workspace.owner_type == AgentWorkspaceOwnerType.WORKFLOW_RUN assert workspace.owner_scope_key == "native:inv" @@ -159,19 +213,19 @@ def test_prepare_pins_home_and_never_allocates_conversation_or_updates_soul(monk assert len(requests) == 1 -def test_ambiguous_allocation_reconciles_exact_ids_without_new_create(monkeypatch): - service, ref, row, session, requests, client = service_fixture(monkeypatch) - create = client.create_execution_binding_sync.side_effect +def test_ambiguous_allocation_reconciles_exact_ids_without_new_create(monkeypatch, sqlite_session, operations_service): + service, ref, row, session, requests, client = service_fixture(monkeypatch, sqlite_session, operations_service) + create = client.create_execution_binding_sync def lost(request): create(request) raise TimeoutError("allocation response lost") - client.create_execution_binding_sync.side_effect = lost + monkeypatch.setattr(client, "create_execution_binding_sync", lost) with pytest.raises(TimeoutError): service.prepare(ref=ref, pinned_snapshot_id="snapshot", input_file_refs=()) - assert not session.add.called - client.create_execution_binding_sync.side_effect = create + assert session.info["workspace_additions"] == [] + monkeypatch.setattr(client, "create_execution_binding_sync", create) service.prepare(ref=ref, pinned_snapshot_id="snapshot", input_file_refs=()) assert requests[0].binding_id == requests[1].binding_id assert requests[0].workspace_id == requests[1].workspace_id @@ -180,23 +234,29 @@ def test_ambiguous_allocation_reconciles_exact_ids_without_new_create(monkeypatc assert requests[1].native_scope_reconcile is True -def test_cross_tenant_or_changed_pin_and_input_import_refuse_before_io(monkeypatch): - service, ref, row, session, requests, client = service_fixture(monkeypatch) +def test_cross_tenant_or_changed_pin_and_input_import_refuse_before_io(monkeypatch, sqlite_session, operations_service): + service, ref, row, session, requests, client = service_fixture(monkeypatch, sqlite_session, operations_service) with pytest.raises(BuilderExecutionPolicyError, match="native_input_import_requires_capability"): service.prepare(ref=ref, pinned_snapshot_id="snapshot", input_file_refs=("file",)) assert not requests service.prepare(ref=ref, pinned_snapshot_id="snapshot", input_file_refs=()) with pytest.raises(BuilderExecutionPolicyError, match="native_workspace_generation_mismatch"): service.prepare(ref=ref, pinned_snapshot_id="other", input_file_refs=()) - row.bindings["_agent_workspace_v1"]["tenant_id"] = "other" + row.bindings = { + **row.bindings, + "_agent_workspace_v1": {**row.bindings["_agent_workspace_v1"], "tenant_id": "other"}, + } + session.commit() with pytest.raises(BuilderExecutionPolicyError, match="native_workspace_owner_mismatch"): service.prepare(ref=ref, pinned_snapshot_id="snapshot", input_file_refs=()) assert len(requests) == 1 -def test_cancel_between_physical_allocation_and_finalize_cleans_same_scope(monkeypatch): - service, ref, row, session, requests, client = service_fixture(monkeypatch) - original = service.loader.locked.side_effect +def test_cancel_between_physical_allocation_and_finalize_cleans_same_scope( + monkeypatch, sqlite_session, operations_service +): + service, ref, row, session, requests, client = service_fixture(monkeypatch, sqlite_session, operations_service) + original = service.loader.locked calls = 0 @contextmanager @@ -208,20 +268,24 @@ def test_cancel_between_physical_allocation_and_finalize_cleans_same_scope(monke with original(**kwargs) as value: yield value - service.loader.locked.side_effect = cancelled + monkeypatch.setattr(service.loader, "locked", cancelled) with pytest.raises(BuilderExecutionPolicyError, match="native_cancelled"): service.prepare(ref=ref, pinned_snapshot_id="snapshot", input_file_refs=()) - assert client.destroy_execution_binding_sync.call_count == 1 - cleanup = client.destroy_execution_binding_sync.call_args.args[0] + assert len(session.info["destroy_requests"]) == 1 + cleanup = session.info["destroy_requests"][0] assert cleanup.binding_ref == f"native:{requests[0].binding_id}:1" assert row.bindings["_agent_workspace_v1"]["phase"] == "retired" - assert not session.add.called + assert session.info["workspace_additions"] == [] -def test_separate_invocations_reserve_distinct_mutable_home_and_workspace_ids(monkeypatch): - first, ref1, _, _, requests1, _ = service_fixture(monkeypatch) +def test_separate_invocations_reserve_distinct_mutable_home_and_workspace_ids( + monkeypatch, sqlite_session, operations_service +): + first, ref1, _, _, requests1, _ = service_fixture(monkeypatch, sqlite_session, operations_service) home1 = first.prepare(ref=ref1, pinned_snapshot_id="snapshot", input_file_refs=()) - second, ref2, _, _, requests2, _ = service_fixture(monkeypatch, invocation_id="inv-2") + second, ref2, _, _, requests2, _ = service_fixture( + monkeypatch, sqlite_session, operations_service, invocation_id="inv-2" + ) home2 = second.prepare(ref=ref2, pinned_snapshot_id="snapshot", input_file_refs=()) assert home1.binding_id != home2.binding_id assert home1.workspace_id != home2.workspace_id @@ -229,9 +293,16 @@ def test_separate_invocations_reserve_distinct_mutable_home_and_workspace_ids(mo assert requests1[0].home_snapshot_ref == requests2[0].home_snapshot_ref == "home-published" -def test_known_native_binding_requires_actual_owned_workspace_rows(monkeypatch): - service, ref, _, session, requests, _ = service_fixture(monkeypatch) +def test_known_native_binding_requires_actual_owned_workspace_rows(monkeypatch, sqlite_session, operations_service): + service, ref, _, session, requests, _ = service_fixture(monkeypatch, sqlite_session, operations_service) result = service.prepare(ref=ref, pinned_snapshot_id="snapshot", input_file_refs=()) + assert service.validate_binding(ref=ref, binding_ref=result.backend_binding_ref) == result + from sqlalchemy import delete + + from models.agent import AgentWorkspaceBinding + + session.execute(delete(AgentWorkspaceBinding).where(AgentWorkspaceBinding.id == result.binding_id)) + session.commit() with pytest.raises(BuilderExecutionPolicyError, match="native_workspace_owner_mismatch"): service.validate_binding(ref=ref, binding_ref=result.backend_binding_ref) with pytest.raises(BuilderExecutionPolicyError, match="native_workspace_owner_mismatch"): diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_tool_declarations.py b/api/tests/unit_tests/services/dify_builder/test_agent_tool_declarations.py index 84281ac1d51..4962f4b1691 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_tool_declarations.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_tool_declarations.py @@ -2,13 +2,16 @@ import importlib import importlib.util -from types import SimpleNamespace from typing import cast import pytest from core.tools.entities.tool_entities import ToolEntity from models.agent_config_entities import AgentSoulToolsConfig +from tests.unit_tests.services.dify_builder import native_owner_fixtures + +operations_service = native_owner_fixtures.operations_service +preparation_sql = native_owner_fixtures.preparation_sql def service_module(): @@ -83,12 +86,27 @@ def test_default_plugin_metadata_path_does_not_call_dynamic_or_credential_runtim def forbidden(*_args, **_kwargs): pytest.fail("Inert declarations touched dynamic schema, decryption, or OAuth") - tool = SimpleNamespace( - entity=entity(), get_merged_runtime_parameters=forbidden, get_llm_parameters_json_schema=forbidden - ) - controller = SimpleNamespace( - need_credentials=True, plugin_unique_identifier="test/plugin@sha", get_tools=lambda: [tool] + from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin + from core.tools.plugin_tool.provider import PluginToolProviderController + from core.tools.plugin_tool.tool import PluginTool + + provider = ToolProviderEntityWithPlugin.model_validate( + { + "identity": { + "author": "test", + "name": "provider", + "description": {"en_US": "Provider"}, + "icon": "icon.svg", + "label": {"en_US": "Provider"}, + }, + "credentials_schema": [{"name": "token", "type": "secret-input"}], + "plugin_id": "test/plugin", + "tools": [entity()], + } ) + controller = PluginToolProviderController(provider, "test/plugin", "test/plugin@sha", "tenant") + monkeypatch.setattr(PluginTool, "get_merged_runtime_parameters", forbidden) + monkeypatch.setattr(PluginTool, "get_llm_parameters_json_schema", forbidden) monkeypatch.setattr(ToolManager, "get_plugin_provider", lambda *_args: controller) monkeypatch.setattr(ToolManager, "get_agent_tool_runtime", forbidden) monkeypatch.setattr(OAuthHandler, "refresh_credentials", forbidden) @@ -145,11 +163,10 @@ def test_default_plugin_metadata_path_does_not_call_dynamic_or_credential_runtim "self_fence", ], ) -def test_same_permit_validation_does_not_claim_or_spend(change): +def test_same_permit_validation_does_not_claim_or_spend(change, monkeypatch, operations_service): from contextlib import contextmanager from copy import deepcopy from datetime import UTC, datetime, timedelta - from typing import override from models.dify_builder import DifyBuilderExecutionRequest from models.dify_builder_native_tests import ( @@ -171,14 +188,35 @@ def test_same_permit_validation_does_not_claim_or_spend(change): permit = DispatchPermit( permit_id="permit", attempt_id="attempt", operation_id="operation", expires_at=expiry.isoformat() ) - operation = SimpleNamespace( + operation = NativeEffectOperation( id="operation", + tenant_id="tenant", + request_id=ref.request_id, + invocation_id=ref.invocation_id, operation_digest=operation_digest(effect.key), intent_digest=canonical_digest(effect.model_dump(mode="json")), scope_digest=_scope_digest(effect, trusted), state="claimed", ) - attempt = SimpleNamespace(expires_at=expiry) + from models.dify_builder_native_tests import NativeEffectAttempt + + attempt = NativeEffectAttempt( + id="attempt", + tenant_id="tenant", + request_id=ref.request_id, + invocation_id=ref.invocation_id, + operation_id="operation", + grant_id="grant", + dispatch_index=1, + permit_digest="a" * 64, + expires_at=expiry, + cost_units=1, + budget_reservation_ref="reservation", + execution_id="execution", + invocation_digest="b" * 64, + budget={}, + profile={}, + ) if change == "intent": operation.intent_digest = "f" * 64 if change == "key": @@ -192,23 +230,47 @@ def test_same_permit_validation_does_not_claim_or_spend(change): if change == "permit_expiry": permit = permit.model_copy(update={"expires_at": (expiry + timedelta(seconds=1)).isoformat()}) - request = SimpleNamespace( - id=ref.request_id, tenant_id="tenant", context_digest=ref.context_digest, app_id="app", workflow_id="workflow" + request = DifyBuilderExecutionRequest( + id=ref.request_id, + tenant_id="tenant", + context_digest=ref.context_digest, + app_id="app", + workflow_id="workflow", + actor_id="actor", + session_id="session", + test_input_id="input", + context={}, ) - scope = SimpleNamespace(tenant_id="tenant", request_id=ref.request_id, expires_at=expiry, cancelled=False) - state = SimpleNamespace( + scope = NativeTestScope( + tenant_id="tenant", + request_id=ref.request_id, + expires_at=expiry, + cancelled=False, + max_calls=1, + max_dispatches=1, + max_cost_units=1, + generation=0, + task_approved=False, + budget_limits={}, + budget_spent={}, + profile={}, + ) + state = NativeInvocationState( tenant_id="tenant", request_id=ref.request_id, invocation_id=ref.invocation_id, parent_invocation_id=None, app_id="app", workflow_id="workflow", + context_object_id="context-object", + generation=0, + resolution=[], state="running", pending_ids=["pending"] if change == "self_fence" else [], wait_reason="approval" if change == "self_fence" else None, ) - invocation = SimpleNamespace( - tenant_id="tenant", request_id=ref.request_id, invocation_id=ref.invocation_id, active=True + invocation = NativeTestInvocation( + tenant_id="tenant", request_id=ref.request_id, invocation_id=ref.invocation_id, active=True, bindings={} ) scalar_rows = { NativeEffectOperation: operation, @@ -241,48 +303,55 @@ def test_same_permit_validation_does_not_claim_or_spend(change): def forbidden_write(*_args, **_kwargs): pytest.fail("validation mutated the dispatch ledger") - session = SimpleNamespace( - scalar=scalar, - scalars=scalars, - add=forbidden_write, - add_all=forbidden_write, - execute=forbidden_write, - flush=forbidden_write, - commit=forbidden_write, - delete=forbidden_write, - ) + session = operations_service.loader.session_factory() + monkeypatch.setattr(session, "scalar", scalar) + monkeypatch.setattr(session, "scalars", scalars) + for method in ("add", "add_all", "execute", "flush", "commit", "delete"): + monkeypatch.setattr(session, method, forbidden_write) facts = (operation, attempt, request, scope, state, invocation) - before = deepcopy([vars(fact) for fact in facts]) + + def snapshot_facts(): + return [{key: value for key, value in vars(fact).items() if key != "_sa_instance_state"} for fact in facts] + + before = deepcopy(snapshot_facts()) permit_before = permit.model_dump(mode="json") + from services.dify_builder.native_testing.repository import OwnedNativeTest + + owned = OwnedNativeTest( + ref=ref, + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=0, + task_approved=False, + expires_at=expiry, + state="running", + ) + @contextmanager def locked(**_kwargs): - yield ( - session, - SimpleNamespace(tenant_id="tenant", ref=ref), - scope, - ) + yield session, owned, scope - loader = SimpleNamespace(load=lambda **_kwargs: None, locked=locked) - - class Service(EffectAuthorizationService): - def _permit_ref(self, _permit): - return ref.model_copy(update={"invocation_id": "foreign"}) if change == "owner" else ref - - def _attempt(self, *_args): - return attempt - - def _binding(self, *_args): - return trusted - - def _claim(self, *_args): - pytest.fail("validation spent a second permit") - - @override - def claim_dispatch(self, **_kwargs): - pytest.fail("validation consumed a physical dispatch claim") - - service = Service(loader, clock=lambda: now) + loader = operations_service.loader + monkeypatch.setattr(loader, "load", lambda **_kwargs: owned) + monkeypatch.setattr(loader, "locked", locked) + service = EffectAuthorizationService(loader, clock=lambda: now) + monkeypatch.setattr( + service, + "_permit_ref", + lambda _permit: ref.model_copy(update={"invocation_id": "foreign"}) if change == "owner" else ref, + ) + monkeypatch.setattr(service, "_attempt", lambda *_args: attempt) + monkeypatch.setattr(service, "_binding", lambda *_args: trusted) + monkeypatch.setattr(service, "_claim", lambda *_args: pytest.fail("validation spent a second permit")) + monkeypatch.setattr( + service, "claim_dispatch", lambda **_kwargs: pytest.fail("validation consumed a physical dispatch claim") + ) assert hasattr(service, "validate_dispatch"), "Same-permit validator is missing" if change is None: assert service.validate_dispatch(ref=ref, intent=effect, permit=permit) is None @@ -295,23 +364,60 @@ def test_same_permit_validation_does_not_claim_or_spend(change): else: with pytest.raises(BuilderExecutionPolicyError): service.validate_dispatch(ref=ref, intent=effect, permit=permit) - assert [vars(fact) for fact in facts] == before + assert snapshot_facts() == before + session.close() assert permit.model_dump(mode="json") == permit_before def test_marked_request_builder_stops_missing_ref_before_metadata(): + from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from core.workflow.nodes.agent_v2.runtime_request_builder import ( + WorkflowAgentRuntimeBuildContext, WorkflowAgentRuntimeRequestBuilder, WorkflowAgentRuntimeRequestBuildError, ) + from graphon.runtime import VariablePool + from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding + from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig - context = SimpleNamespace(dify_context=SimpleNamespace(native_test_required=True, native_test_ref=None)) + context = WorkflowAgentRuntimeBuildContext( + dify_context=DifyRunContext( + tenant_id="tenant", + app_id="app", + user_id="actor", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + native_test_required=True, + native_test_ref=None, + ), + workflow_id="workflow", + workflow_run_id="run", + node_id="node", + node_execution_id="execution", + variable_pool=VariablePool(), + binding=WorkflowAgentNodeBinding( + id="binding", + tenant_id="tenant", + app_id="app", + workflow_id="workflow", + node_id="node", + agent_id="agent", + current_snapshot_id="snapshot", + node_job_config=WorkflowNodeJobConfig(), + ), + agent=Agent(id="agent", tenant_id="tenant", name="Agent"), + snapshot=AgentConfigSnapshot( + id="snapshot", tenant_id="tenant", agent_id="agent", version=1, config_snapshot=AgentSoulConfig() + ), + binding_id="binding", + backend_binding_ref="binding-ref", + ) with pytest.raises(WorkflowAgentRuntimeRequestBuildError, match="authority"): WorkflowAgentRuntimeRequestBuilder().build(context) @pytest.mark.parametrize("kind", ["waiting", "stop", "recorded", "dispatch"]) -def test_private_bridge_maps_exact_decisions_without_losing_payload(kind): +def test_private_bridge_maps_exact_decisions_without_losing_payload(kind, monkeypatch, operations_service): from dify_agent.protocol.native_testing import AgentEffectIntent, AgentNativeTestRef from runtime_contracts.builder.native.contracts import ApprovalWait, DispatchPermit, PolicyStop, RecordedResult @@ -339,15 +445,16 @@ def test_private_bridge_maps_exact_decisions_without_losing_payload(kind): seen.append(kwargs) return decisions[kind] + monkeypatch.setattr(operations_service.bridge.authorization, "before_call", before_call) bridge = module.AgentNativeTestingBridge( - authorization=SimpleNamespace(before_call=before_call), readiness=None, materializer=None, fence=None + authorization=operations_service.bridge.authorization, readiness=None, materializer=None, fence=None ) response = bridge.before_call(ref=ref, intent=AgentEffectIntent.model_validate(effect.model_dump(mode="json"))) assert response.model_dump(mode="json") == decisions[kind].model_dump(mode="json") assert seen[0]["intent"] == effect -def test_materialization_rejects_phase_mismatch_and_missing_fence_before_io(): +def test_materialization_rejects_phase_mismatch_and_missing_fence_before_io(monkeypatch, operations_service): from dify_agent.protocol.native_testing import AgentDispatchPermit, AgentEffectIntent, AgentNativeTestRef from runtime_contracts.builder.execution import BuilderExecutionPolicyError @@ -358,11 +465,16 @@ def test_materialization_rejects_phase_mismatch_and_missing_fence_before_io(): ) module = importlib.import_module("services.dify_builder.native_testing.agent_bridge") calls = [] + authorization = operations_service.bridge.authorization + monkeypatch.setattr(authorization, "validate_dispatch", lambda **_kwargs: calls.append("validated")) + materializer = module.NativeAgentCredentialMaterializer( + authorization=authorization, resolve_binding=lambda **_kwargs: pytest.fail("materialized without durable fence") + ) + monkeypatch.setattr( + materializer, "materialize", lambda **_kwargs: pytest.fail("materialized without durable fence") + ) bridge = module.AgentNativeTestingBridge( - authorization=SimpleNamespace(validate_dispatch=lambda **_kwargs: calls.append("validated")), - readiness=None, - materializer=SimpleNamespace(materialize=lambda **_kwargs: pytest.fail("materialized without durable fence")), - fence=None, + authorization=authorization, readiness=None, materializer=materializer, fence=None ) kwargs = { "ref": AgentNativeTestRef( @@ -397,17 +509,11 @@ def test_private_native_routes_require_existing_service_auth(path): @pytest.mark.parametrize("reason", ["approval", "configuration", "interaction"]) -def test_private_before_call_bridge_serialization_preserves_wait_reason(reason): +def test_private_before_call_bridge_serialization_preserves_wait_reason(reason, monkeypatch, operations_service): from dify_agent.adapters.native_testing.client import AgentBeforeCallPayload from controllers.inner_api.agent.native_testing import AgentBeforeCallResponse from runtime_contracts.builder.native.contracts import ApprovalWait - from services.dify_builder.native_testing.agent_bridge import ( - AgentNativeTestingBridge, - ) - from services.dify_builder.native_testing.authorization import ( - EffectAuthorizationService, - ) from tests.unit_tests.services.dify_builder.native_testing.test_authorization import ( intent, ) @@ -429,11 +535,11 @@ def test_private_before_call_bridge_serialization_preserves_wait_reason(reason): calls.append(kwargs) return ApprovalWait(pending_operation_id="pending", wait_reason=reason) + monkeypatch.setattr(operations_service.bridge.authorization, "before_call", before_call) + from services.dify_builder.native_testing.agent_bridge import AgentNativeTestingBridge + bridge = AgentNativeTestingBridge( - authorization=cast(EffectAuthorizationService, SimpleNamespace(before_call=before_call)), - readiness=None, - materializer=None, - fence=None, + authorization=operations_service.bridge.authorization, readiness=None, materializer=None, fence=None ) decision = bridge.before_call(ref=payload.ref, intent=payload.intent) assert AgentBeforeCallResponse(root=decision).model_dump(mode="json") == { @@ -446,7 +552,7 @@ def test_private_before_call_bridge_serialization_preserves_wait_reason(reason): assert calls[0]["intent"].model_dump(mode="json") == payload.intent.model_dump(mode="json") -def test_native_credential_phases_wait_refresh_then_business_without_double_spend(): +def test_native_credential_phases_wait_refresh_then_business_without_double_spend(monkeypatch, operations_service): from runtime_contracts.builder.native.contracts import ApprovalWait, DispatchPermit, RecordedResult from services.dify_builder.native_testing import agent_bridge as module from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent @@ -465,21 +571,30 @@ def test_native_credential_phases_wait_refresh_then_business_without_double_spen permit, RecordedResult(operation_id="operation", result_ref="receipt"), ] - auth = SimpleNamespace( - before_call=lambda **kw: calls.append(("authorize", kw["intent"].kind)) or decisions.pop(0), - record_outcome=lambda **kw: calls.append(("outcome", kw["outcome"])), + from dify_agent.adapters.native_testing.client import AgentMaterialization + + auth = operations_service.bridge.authorization + monkeypatch.setattr( + auth, "before_call", lambda **kw: calls.append(("authorize", kw["intent"].kind)) or decisions.pop(0) ) - owner = module.NativeAgentCredentialPhases( - authorization=auth, - materializer=SimpleNamespace( - materialize=lambda **kw: ( - calls.append(("refresh", kw["phase"])) - or SimpleNamespace(runtime_parameters={"credential_ref": "rotated"}) + monkeypatch.setattr(auth, "record_outcome", lambda **kw: calls.append(("outcome", kw["outcome"]))) + materializer = module.NativeAgentCredentialMaterializer( + authorization=auth, resolve_binding=lambda **_kwargs: pytest.fail("unexpected credential resolution") + ) + monkeypatch.setattr( + materializer, + "materialize", + lambda **kw: ( + calls.append(("refresh", kw["phase"])) + or AgentMaterialization( + credential_type="oauth2", credentials={}, runtime_parameters={"credential_ref": "rotated"} ) ), - state_store=SimpleNamespace( - store_result=lambda **_kw: "receipt", load_result=lambda **_kw: {"credential_ref": "rotated"} - ), + ) + monkeypatch.setattr(operations_service.state_store, "store_result", lambda **_kw: "receipt") + monkeypatch.setattr(operations_service.state_store, "load_result", lambda **_kw: {"credential_ref": "rotated"}) + owner = module.NativeAgentCredentialPhases( + authorization=auth, materializer=materializer, state_store=operations_service.state_store ) assert owner.refresh(ref="ref", intent=refresh).kind == "waiting" assert calls == [("authorize", "oauth")] @@ -490,21 +605,21 @@ def test_native_credential_phases_wait_refresh_then_business_without_double_spen assert calls.count(("refresh", "refresh")) == 1 -def test_native_materializer_phase_rejected_before_credential_selection(): +def test_native_materializer_phase_rejected_before_credential_selection(operations_service): from runtime_contracts.builder.execution import BuilderExecutionPolicyError from services.dify_builder.native_testing import agent_bridge as module from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent assert hasattr(module, "NativeAgentCredentialMaterializer"), "Native credential materializer is missing" owner = module.NativeAgentCredentialMaterializer( - authorization=SimpleNamespace(), + authorization=operations_service.bridge.authorization, resolve_binding=lambda **_kw: pytest.fail("Phase mismatch reached credential selection"), ) with pytest.raises(BuilderExecutionPolicyError, match="native_materialization_phase_mismatch"): owner.materialize(ref=None, intent=intent(), permit=None, tool_ref=intent().resource_ref, phase="refresh") -def test_actual_native_materializer_uses_existing_oauth_owner_then_cas_before_business(monkeypatch): +def test_actual_native_materializer_uses_existing_oauth_owner_then_cas_before_business(monkeypatch, operations_service): from core.dify_builder.models import Actor from core.dify_builder.real_call_consent import CredentialReference from core.plugin.impl.oauth import OAuthHandler @@ -524,26 +639,51 @@ def test_actual_native_materializer_uses_existing_oauth_owner_then_cas_before_bu oauth_client_revision="b" * 64, expires_at=1, ) - snapshot = SimpleNamespace(reference=reference, encrypted_credentials='{"token":"fixture"}', client="client") - selection = SimpleNamespace(snapshot=snapshot, owner_user_id="owner") + from dataclasses import replace + + from services.tools.builder_credential_reference import ( + BuilderCredentialSnapshot, + CredentialAccessSnapshot, + OAuthClientSnapshot, + ) + from services.tools.builtin_tools_manage_service import BuilderCredentialRuntime + + snapshot = BuilderCredentialSnapshot( + reference=reference, + encrypted_credentials='{"token":"fixture"}', + client=OAuthClientSnapshot(source="tenant", row_id="client", encrypted_params="{}", enabled=True), + access=CredentialAccessSnapshot(basis="owner", account_id="actor", tenant_id="tenant", grants=()), + ) + selection = BuilderCredentialRuntime(snapshot=snapshot, owner_user_id="owner") binding = module.AgentCredentialBinding( actor, "resource", "test/plugin/provider", "credential", True, "revision", "configuration-owner" ) events = [] - controller = SimpleNamespace(plugin_unique_identifier="revision", get_credentials_schema_by_type=lambda _: []) + from core.tools.plugin_tool.provider import PluginToolProviderController + from tests.unit_tests.services.dify_builder.test_json_tool_metadata import _provider + + controller = PluginToolProviderController( + entity=_provider().declaration, + plugin_id="test/plugin", + plugin_unique_identifier="revision", + tenant_id="tenant", + ) + monkeypatch.setattr(controller, "get_credentials_schema_by_type", lambda _: []) monkeypatch.setattr(ToolManager, "get_builtin_provider", lambda *_a: controller) monkeypatch.setattr(module.BuiltinToolManageService, "load_builder_credential", lambda **_kw: selection) monkeypatch.setattr( module.BuiltinToolManageService, "decrypt_builder_oauth_client", lambda **_kw: {"client": "fixture"} ) monkeypatch.setattr("core.helper.credential_utils.runtime_check_credential_policy_compliance", lambda **_kw: None) - monkeypatch.setattr( - "core.tools.utils.encryption.create_provider_encrypter", - lambda **_kw: ( - SimpleNamespace(decrypt=lambda value: events.append("decrypt") or value, encrypt=lambda value: value), - None, - ), + from core.helper.provider_cache import NoOpProviderCredentialCache + from core.helper.provider_encryption import ProviderConfigEncrypter + + encrypter = ProviderConfigEncrypter( + tenant_id="tenant", config=[], provider_config_cache=NoOpProviderCredentialCache() ) + monkeypatch.setattr(encrypter, "decrypt", lambda value: events.append("decrypt") or value) + monkeypatch.setattr(encrypter, "encrypt", lambda value: value) + monkeypatch.setattr("core.tools.utils.encryption.create_provider_encrypter", lambda **_kw: (encrypter, None)) monkeypatch.setattr( "core.helper.provider_cache.ToolProviderCredentialsCache.delete", lambda _self: events.append("cache") ) @@ -551,14 +691,21 @@ def test_actual_native_materializer_uses_existing_oauth_owner_then_cas_before_bu def refresh(**_kwargs): assert events[-2:] == ["claim", "decrypt"] events.append("refresh") - return SimpleNamespace(credentials={"token": "rotated-fixture"}, expires_at=4102444800) + from core.plugin.entities.plugin_daemon import PluginOAuthCredentialsResponse + + return PluginOAuthCredentialsResponse(credentials={"token": "rotated-fixture"}, expires_at=4102444800) def persist(**kwargs): + nonlocal selection assert events[-1] == "refresh" events.append("cas") - snapshot.reference = reference.model_copy(update={"revision": "c" * 64, "expires_at": 4102444800}) - snapshot.encrypted_credentials = kwargs["encrypted_credentials"] - return snapshot.reference + updated = replace( + snapshot, + reference=reference.model_copy(update={"revision": "c" * 64, "expires_at": 4102444800}), + encrypted_credentials=kwargs["encrypted_credentials"], + ) + selection = replace(selection, snapshot=updated) + return updated.reference monkeypatch.setattr(OAuthHandler, "refresh_credentials", lambda _self, **kw: refresh(**kw)) monkeypatch.setattr(module.BuiltinToolManageService, "persist_native_agent_refresh", persist) @@ -570,21 +717,17 @@ def test_actual_native_materializer_uses_existing_oauth_owner_then_cas_before_bu permit, RecordedResult(operation_id="operation", result_ref="result"), ] - auth = SimpleNamespace( - loader=SimpleNamespace(actor=actor, load=lambda **_kw: None), - validate_dispatch=lambda **_kw: None, - claim_dispatch=lambda **_kw: events.append("claim"), - before_call=lambda **_kw: decision.pop(0), - record_outcome=lambda **kw: events.append(kw["outcome"]), - ) + auth = operations_service.bridge.authorization + monkeypatch.setattr(auth.loader, "load", lambda **_kw: None) + monkeypatch.setattr(auth, "validate_dispatch", lambda **_kw: None) + monkeypatch.setattr(auth, "claim_dispatch", lambda **_kw: events.append("claim")) + monkeypatch.setattr(auth, "before_call", lambda **_kw: decision.pop(0)) + monkeypatch.setattr(auth, "record_outcome", lambda **kw: events.append(kw["outcome"])) + state_store = operations_service.state_store + monkeypatch.setattr(state_store, "store_result", lambda **_kw: "result") + monkeypatch.setattr(state_store, "load_result", lambda **_kw: {"credential_ref": "opaque"}) materializer = module.NativeAgentCredentialMaterializer(authorization=auth, resolve_binding=lambda **_kw: binding) - phases = module.NativeAgentCredentialPhases( - authorization=auth, - materializer=materializer, - state_store=SimpleNamespace( - store_result=lambda **_kw: "result", load_result=lambda **_kw: {"credential_ref": "opaque"} - ), - ) + phases = module.NativeAgentCredentialPhases(authorization=auth, materializer=materializer, state_store=state_store) effect = intent().model_copy( update={ "kind": "oauth", @@ -605,7 +748,7 @@ def test_actual_native_materializer_uses_existing_oauth_owner_then_cas_before_bu assert events.count("refresh") == events.count("cas") == events.count("claim") == 1 -def test_missing_credential_configuration_wait_precedes_business_intent(monkeypatch): +def test_missing_credential_configuration_wait_precedes_business_intent(monkeypatch, operations_service): from runtime_contracts.builder.native.contracts import ApprovalWait from services.dify_builder.native_testing import agent_bridge as module from services.dify_builder.native_testing.pending_actions import NativePendingActionService @@ -619,16 +762,35 @@ def test_missing_credential_configuration_wait_precedes_business_intent(monkeypa calls.append(kw) or ApprovalWait(pending_operation_id="configuration-id", wait_reason="configuration") ), ) - auth = SimpleNamespace( - loader=SimpleNamespace(load=lambda **_kw: SimpleNamespace(generation=3)), - before_call=lambda **_kw: pytest.fail("Configuration froze an account effect"), + from datetime import UTC, datetime + + from runtime_contracts.builder.native.contracts import NativeTestContextRef + from services.dify_builder.native_testing.repository import OwnedNativeTest + + auth = operations_service.bridge.authorization + owned = OwnedNativeTest( + ref=NativeTestContextRef(request_id="request", invocation_id="inv", context_digest="a" * 64), + tenant_id="tenant", + actor_id="actor", + app_id="app", + workflow_id="workflow", + session_id="session", + test_input_id="input", + bindings={}, + generation=3, + task_approved=False, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + state="running", ) + monkeypatch.setattr(auth.loader, "load", lambda **_kw: owned) + monkeypatch.setattr(auth, "before_call", lambda **_kw: pytest.fail("Configuration froze an account effect")) + binding = module.AgentCredentialBinding( + auth.loader.actor, "mail.read", "test/plugin/provider", None, True, "revision", "actual-config-owner" + ) + materializer = module.NativeAgentCredentialMaterializer(authorization=auth, resolve_binding=lambda **_kw: binding) + monkeypatch.setattr(materializer, "inspect", lambda **_kw: (binding, None)) owner = module.NativeAgentCredentialPhases( - authorization=auth, - materializer=SimpleNamespace( - inspect=lambda **_kw: (SimpleNamespace(requirements_ref="actual-config-owner"), None) - ), - state_store=None, + authorization=auth, materializer=materializer, state_store=operations_service.state_store ) assert hasattr(owner, "prepare_business"), "Readiness must precede business intent freeze" effect = intent() @@ -718,8 +880,6 @@ def test_endpoint_lineage_requires_exact_envelope_before_physical_owner_refusal( def test_actual_llm_endpoint_rejects_known_native_omission_before_prepare(monkeypatch): - from unittest.mock import Mock - from services.agent_llm_inner_service import AgentLLMInnerService from tests.unit_tests.config_override import config_overrides_context from tests.unit_tests.controllers.inner_api.test_agent_llm import _flask_app @@ -731,7 +891,12 @@ def test_actual_llm_endpoint_rejects_known_native_omission_before_prepare(monkey app = _flask_app() lineage, _principal, selection, phase = owner() app.extensions["native_agent_endpoint_lineage"] = lineage - prepare = Mock(side_effect=AssertionError("Known native omission reached model preparation")) + preparations = [] + + def prepare(*args, **kwargs): + preparations.append((args, kwargs)) + raise AssertionError("Known native omission reached model preparation") + monkeypatch.setattr(AgentLLMInnerService, "prepare", prepare) with config_overrides_context( AGENT_BACKEND_API_TOKEN="agent-token", @@ -748,11 +913,12 @@ def test_actual_llm_endpoint_rejects_known_native_omission_before_prepare(monkey ) assert response.status_code == 409 assert phase == ["backend"] - prepare.assert_not_called() + assert preparations == [] def test_private_claim_route_refuses_repeated_requests_without_consuming_permit( monkeypatch, + operations_service, ): from flask import Flask @@ -781,11 +947,8 @@ def test_private_claim_route_refuses_repeated_requests_without_consuming_permit( raise BuilderExecutionPolicyError("native_dispatch_already_claimed") claims.append(kw) - monkeypatch.setattr( - controller, - "agent_bridge_for_ref", - lambda _ref: SimpleNamespace(authorization=SimpleNamespace(claim_dispatch=claim)), - ) + monkeypatch.setattr(operations_service.bridge.authorization, "claim_dispatch", claim) + monkeypatch.setattr(controller, "agent_bridge_for_ref", lambda _ref: operations_service.bridge) app = Flask(__name__) app.config["TESTING"] = True app.register_blueprint(bp) @@ -845,37 +1008,70 @@ def test_private_claim_route_refuses_repeated_requests_without_consuming_permit( @pytest.mark.parametrize("drift", [None, "access", "client", "encrypted_credentials"]) -def test_native_refresh_cas_rechecks_detached_snapshot_before_write(monkeypatch, drift): +def test_native_refresh_cas_rechecks_detached_snapshot_before_write(monkeypatch, drift, operations_service): from contextlib import contextmanager - from copy import copy + from dataclasses import replace from core.dify_builder.models import Actor from runtime_contracts.builder.execution import BuilderExecutionPolicyError from services.tools import builtin_tools_manage_service as module actor = Actor(account_id="actor", tenant_id="tenant") - snapshot = SimpleNamespace( - reference=SimpleNamespace(credential_type="oauth2", credential_id="credential", expires_at=1), - access=SimpleNamespace(grant_ids=()), - client=SimpleNamespace(source="tenant", row_id="client"), + from core.dify_builder.real_call_consent import CredentialReference + from models.tools import BuiltinToolProvider, ToolOAuthTenantClient + from services.tools.builder_credential_reference import ( + BuilderCredentialSnapshot, + CredentialAccessSnapshot, + OAuthClientSnapshot, + ) + from tests.unit_tests.config_override import apply_config_overrides + + snapshot = BuilderCredentialSnapshot( + reference=CredentialReference( + credential_id="credential", + provider_id="test/plugin/provider", + alias="test", + credential_type="oauth2", + visibility="only_me", + revision="a" * 64, + oauth_client_revision="b" * 64, + expires_at=1, + ), + access=CredentialAccessSnapshot(basis="owner", account_id="actor", tenant_id="tenant", grants=()), + client=OAuthClientSnapshot(source="tenant", row_id="client", encrypted_params="{}", enabled=True), encrypted_credentials="old-ciphertext", ) - selected = SimpleNamespace(snapshot=snapshot, owner_user_id="owner") - current = copy(snapshot) - if drift: - setattr(current, drift, "changed") + selected = module.BuilderCredentialRuntime(snapshot=snapshot, owner_user_id="owner") + current = replace(snapshot) + if drift == "access": + current = replace(current, access=replace(current.access, account_id="other")) + elif drift == "client": + current = replace(current, client=replace(current.client, row_id="other")) + elif drift == "encrypted_credentials": + current = replace(current, encrypted_credentials="changed") reads = [] + row = BuiltinToolProvider(name="test", tenant_id="tenant", user_id="owner", provider="test/plugin/provider") + row.id = "credential" + client = ToolOAuthTenantClient(tenant_id="tenant", plugin_id="test/plugin", provider="provider") + client.id = "client" + sessions = operations_service.loader.session_factory + session = sessions() def scalar(statement): reads.append(statement) - return [SimpleNamespace(id="credential", user_id="owner"), object(), "credential"][len(reads) - 1] + return [row, client, "credential"][len(reads) - 1] + + monkeypatch.setattr(session, "scalar", scalar) + monkeypatch.setattr(session, "expire", lambda _row: None) @contextmanager def transaction(): - yield SimpleNamespace(scalar=scalar, expire=lambda _row: None) + with session: + yield session - monkeypatch.setattr(module.session_factory, "get_session_maker", lambda: SimpleNamespace(begin=transaction)) - monkeypatch.setattr(module.dify_config, "SECRET_KEY", "fixture-only") + monkeypatch.setattr(sessions, "begin", transaction) + monkeypatch.setattr(module.session_factory, "get_session_maker", lambda: sessions) + apply_config_overrides(monkeypatch, SECRET_KEY="fixture-only") monkeypatch.setattr(module, "select_builder_credential", lambda *_args, **_kw: current) kwargs = { "actor": actor, @@ -986,9 +1182,26 @@ def test_workflow_factory_injects_native_declaration_preparation(monkeypatch): from services.workflow_runtime.node_factory import DifyNodeFactory module = service_module() - monkeypatch.setattr(backend_factory, "create_agent_backend_run_client", lambda **_: object()) - factory = object.__new__(DifyNodeFactory) - monkeypatch.setattr(factory, "_dify_context", SimpleNamespace(builder_agent_execution=None), raising=False) + from core.app.entities.app_invoke_entities import UserFrom, build_dify_run_context + from graphon.runtime import InitParams, RuntimeState, VariablePool + + client = backend_factory.create_agent_backend_run_client(base_url="http://agent.invalid") + monkeypatch.setattr(backend_factory, "create_agent_backend_run_client", lambda **_: client) + factory = DifyNodeFactory( + init_params=InitParams( + run_context=build_dify_run_context( + tenant_id="tenant", + app_id="app", + user_id="actor", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ), + workflow_id="workflow", + graph_config={"nodes": [], "edges": []}, + call_depth=0, + ), + runtime_state=RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0), + ) kwargs = factory._build_agent_node_init_kwargs(node_class=DifyAgentNode) builder = kwargs["runtime_request_builder"]._dify_tools_builder diff --git a/api/tests/unit_tests/services/dify_builder/test_app_naming.py b/api/tests/unit_tests/services/dify_builder/test_app_naming.py index 71870edf3b1..8126ec00c18 100644 --- a/api/tests/unit_tests/services/dify_builder/test_app_naming.py +++ b/api/tests/unit_tests/services/dify_builder/test_app_naming.py @@ -1,115 +1,105 @@ """Builder-driven app rename: usable proposals only, and never fatal.""" -import contextlib -from collections.abc import Iterator -from unittest.mock import MagicMock, patch +from dataclasses import dataclass +from unittest.mock import patch import pytest +from sqlalchemy import Engine +from models.model import App, AppMode from services.dify_builder import app_naming +from tests.unit_tests.model_factories import make_app + + +@dataclass(frozen=True) +class NamingDatabase: + engine: Engine @pytest.fixture -def db_stubbed() -> Iterator[MagicMock]: - """Stub the engine so nothing here needs a Flask app context. - - Without it the real ``db.engine`` raises, ``rename_app_from_proposal`` - swallows that, and every assertion below passes for the wrong reason. - """ - with ( - patch.object(app_naming, "db", MagicMock()), - patch.object(app_naming, "sessionmaker") as factory, - ): - factory.return_value.return_value = contextlib.nullcontext(MagicMock(name="session")) - yield factory +def db_stubbed(monkeypatch, sqlite_engine, sqlite_session_factory, mocker): + """Use the actual database owner so swallowed setup errors cannot make tests pass.""" + monkeypatch.setattr(app_naming, "db", NamingDatabase(sqlite_engine)) + with sqlite_session_factory.begin() as session: + session.add(make_app(app_id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW, name="Set up an expense")) + return mocker.spy(app_naming, "sessionmaker") -def test_an_unusable_proposal_never_touches_the_database(db_stubbed: MagicMock) -> None: - # No session is opened at all -- the derived name simply stands. +def test_an_unusable_proposal_never_touches_the_database(db_stubbed) -> None: stored = app_naming.rename_app_from_proposal(app_id="a", tenant_id="t", account_id="acc", proposed=' "" ') - assert stored == "" db_stubbed.assert_not_called() @pytest.mark.usefixtures("db_stubbed") -def test_a_usable_proposal_is_normalized_before_it_is_stored() -> None: - app = MagicMock() - app.name = "Set up an expense" - with ( - patch.object(app_naming, "AppService") as app_service, - patch.object(app_naming, "_load_app", return_value=app), - ): - stored = app_naming.rename_app_from_proposal( - app_id="app-1", tenant_id="tenant-1", account_id="acc-1", proposed=' "Expense approval"\n' - ) - app_service.return_value.rename_app.assert_called_once() - args, kwargs = app_service.return_value.rename_app.call_args - assert args[1] == "Expense approval" +def test_a_usable_proposal_is_normalized_before_it_is_stored(mocker, sqlite_session_factory) -> None: + # Only the notification boundary is inert; native rename and SQL commit execute. + import services.app_service as owner + + mocker.patch.object(owner.app_was_updated, "send", return_value=[]) + rename = mocker.spy(app_naming.AppService, "rename_app") + stored = app_naming.rename_app_from_proposal( + app_id="app-1", tenant_id="tenant-1", account_id="acc-1", proposed=' "Expense approval"\n' + ) + rename.assert_called_once() + args, kwargs = rename.call_args + assert args[2] == "Expense approval" assert kwargs["account_id"] == "acc-1" - # Reported back so the engine records what the database actually took. + assert stored == "Expense approval" + with sqlite_session_factory() as session: + row = session.get(App, "app-1") + assert row is not None + assert (row.name, row.updated_by) == (stored, "acc-1") + + +@pytest.mark.usefixtures("db_stubbed") +def test_a_proposal_equal_to_the_current_name_is_not_written(mocker, sqlite_session_factory) -> None: + with sqlite_session_factory.begin() as session: + app = session.get(App, "app-1") + assert app is not None + app.name = "Expense approval" + rename = mocker.spy(app_naming.AppService, "rename_app") + stored = app_naming.rename_app_from_proposal( + app_id="app-1", tenant_id="tenant-1", account_id="acc-1", proposed="Expense approval" + ) + rename.assert_not_called() assert stored == "Expense approval" @pytest.mark.usefixtures("db_stubbed") -def test_a_proposal_equal_to_the_current_name_is_not_written() -> None: - app = MagicMock() - app.name = "Expense approval" - with ( - patch.object(app_naming, "AppService") as app_service, - patch.object(app_naming, "_load_app", return_value=app), - ): - stored = app_naming.rename_app_from_proposal( +def test_a_missing_app_is_not_an_error(mocker) -> None: + rename = mocker.spy(app_naming.AppService, "rename_app") + stored = app_naming.rename_app_from_proposal(app_id="gone", tenant_id="t", account_id="acc", proposed="Anything") + rename.assert_not_called() + assert stored == "" + + +@pytest.mark.usefixtures("db_stubbed") +def test_a_failing_rename_is_swallowed(mocker) -> None: + mocker.patch.object(app_naming.AppService, "__init__", side_effect=RuntimeError("boom")) + assert ( + app_naming.rename_app_from_proposal( app_id="app-1", tenant_id="tenant-1", account_id="acc-1", proposed="Expense approval" ) - app_service.return_value.rename_app.assert_not_called() - # Already correct is still the name to record. - assert stored == "Expense approval" + == "" + ) @pytest.mark.usefixtures("db_stubbed") -def test_a_missing_app_is_not_an_error() -> None: - with ( - patch.object(app_naming, "AppService") as app_service, - patch.object(app_naming, "_load_app", return_value=None), - ): - stored = app_naming.rename_app_from_proposal( - app_id="gone", tenant_id="t", account_id="acc", proposed="Anything" - ) - app_service.return_value.rename_app.assert_not_called() - assert stored == "" - - -@pytest.mark.usefixtures("db_stubbed") -def test_a_failing_rename_is_swallowed() -> None: - # A cosmetic rename must never take the build down with it. - with ( - patch.object(app_naming, "AppService", side_effect=RuntimeError("boom")), - patch.object(app_naming, "_load_app", return_value=MagicMock()), - ): - stored = app_naming.rename_app_from_proposal( - app_id="a", tenant_id="t", account_id="acc", proposed="Expense approval" - ) - - assert stored == "" - - -@pytest.mark.usefixtures("db_stubbed") -def test_current_app_name_reads_the_stored_name() -> None: - app = MagicMock() - app.name = "Refund approval" - with patch.object(app_naming, "_load_app", return_value=app): - assert app_naming.current_app_name(app_id="app-1", tenant_id="tenant-1") == "Refund approval" +def test_current_app_name_reads_the_stored_name(sqlite_session_factory) -> None: + with sqlite_session_factory.begin() as session: + app = session.get(App, "app-1") + assert app is not None + app.name = "Refund approval" + assert app_naming.current_app_name(app_id="app-1", tenant_id="tenant-1") == "Refund approval" @pytest.mark.usefixtures("db_stubbed") def test_current_app_name_is_empty_for_an_app_it_cannot_read() -> None: - with patch.object(app_naming, "_load_app", return_value=None): - assert app_naming.current_app_name(app_id="gone", tenant_id="t") == "" + assert app_naming.current_app_name(app_id="gone", tenant_id="t") == "" @pytest.mark.usefixtures("db_stubbed") def test_current_app_name_swallows_a_failing_read() -> None: - # An unnamed card beats a failed session start. with patch.object(app_naming, "_load_app", side_effect=RuntimeError("boom")): assert app_naming.current_app_name(app_id="a", tenant_id="t") == "" diff --git a/api/tests/unit_tests/services/dify_builder/test_build_cognition.py b/api/tests/unit_tests/services/dify_builder/test_build_cognition.py index 5eccf8d28d4..9309ff8ebff 100644 --- a/api/tests/unit_tests/services/dify_builder/test_build_cognition.py +++ b/api/tests/unit_tests/services/dify_builder/test_build_cognition.py @@ -12,6 +12,7 @@ from core.dify_builder.models import BuildNodesResult, Graph, Inputs from core.workflow.generator.tool_catalogue import ToolCatalogueEntry from graphon.model_runtime.entities.message_entities import PromptMessage from services.dify_builder.agent import build, resources +from tests.unit_tests.config_override import apply_config_overrides from tests.unit_tests.services.dify_builder.installed_tool_inventory import ( ACCOUNT_A, ACCOUNT_B, @@ -25,6 +26,7 @@ from tests.unit_tests.services.dify_builder.installed_tool_inventory import ( install_fake_daemon, provider_payload, ) +from tests.unit_tests.services.dify_builder.test_dify_port import PortDatabase class _Msg: @@ -1115,14 +1117,12 @@ def test_plan_prompt_names_the_ready_tools_it_may_use(monkeypatch: pytest.Monkey def test_initial_plan_uses_only_current_actor_eligible_published_agent_metadata( sqlite_session, monkeypatch, with_actor ): - from types import SimpleNamespace - from configs import dify_config from services.agent import access_service from services.dify_builder.agent.llm_agent import LlmBuilderAgent from tests.builder_agent_fixtures import add_agent, make_case - monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) case = make_case(sqlite_session) case.snapshot.summary = "Review report evidence" unpublished, _ = add_agent(sqlite_session, tenant_id=case.actor.tenant_id, name="Unpublished", published=False) @@ -1133,7 +1133,7 @@ def test_initial_plan_uses_only_current_actor_eligible_published_agent_metadata( "resolve_current_agent_access_filter", lambda **_kwargs: access_service.AgentAccessFilter(accessible_agent_ids={case.agent.id, unpublished.id}), ) - monkeypatch.setattr(resources, "db", SimpleNamespace(engine=sqlite_session.get_bind())) + monkeypatch.setattr(resources, "db", PortDatabase(sqlite_session.get_bind())) monkeypatch.setattr(resources, "_list_models", lambda _tenant_id: []) monkeypatch.setattr(resources, "_list_datasets", lambda _tenant_id: []) monkeypatch.setattr(resources, "_list_tools", lambda _tenant_id: []) diff --git a/api/tests/unit_tests/services/dify_builder/test_code_risk_assessment.py b/api/tests/unit_tests/services/dify_builder/test_code_risk_assessment.py index 13463f9a507..262ca582a22 100644 --- a/api/tests/unit_tests/services/dify_builder/test_code_risk_assessment.py +++ b/api/tests/unit_tests/services/dify_builder/test_code_risk_assessment.py @@ -239,21 +239,29 @@ def test_code_upstream_confirmation_survives_canvas_title_and_node_order_only(): def test_selected_model_fenced_json_is_assessed_in_one_attempt(monkeypatch: pytest.MonkeyPatch) -> None: - from types import SimpleNamespace - + from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage + from graphon.model_runtime.entities.message_entities import AssistantPromptMessage from services.dify_builder.agent import model_resolver from services.dify_builder.code_risk_assessment import assess_code, builder_code_reasoner + from tests.unit_tests.core.model_fixtures import make_model_instance calls = [] - def invoke(**kwargs: object) -> SimpleNamespace: + def invoke(**kwargs: object) -> LLMResult: calls.append(kwargs) content = ( '```json\n{"classification":"pure","risk_notice":"Parses the supplied JSON without external effects."}\n```' ) - return SimpleNamespace(message=SimpleNamespace(get_text_content=lambda: content)) + return LLMResult( + model="gpt-4o", + prompt_messages=[], + message=AssistantPromptMessage(content=content), + usage=LLMUsage.empty_usage(), + ) - monkeypatch.setattr(model_resolver, "resolve_model_instance", lambda *_: SimpleNamespace(invoke_llm=invoke)) + instance = make_model_instance(provider="openai", model="gpt-4o") + monkeypatch.setattr(instance, "invoke_llm", invoke) + monkeypatch.setattr(model_resolver, "resolve_model_instance", lambda *_: instance) risk = assess_code( prepare(), reason=builder_code_reasoner(tenant_id="owned-tenant", model_config={"name": "selected"}) ) @@ -276,19 +284,27 @@ def test_selected_model_fenced_json_is_assessed_in_one_attempt(monkeypatch: pyte def test_selected_model_malformed_json_is_not_automatically_retried( monkeypatch: pytest.MonkeyPatch, response: str ) -> None: - from types import SimpleNamespace - + from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage + from graphon.model_runtime.entities.message_entities import AssistantPromptMessage from services.dify_builder.agent import model_resolver from services.dify_builder.code_risk_assessment import assess_code, builder_code_reasoner + from tests.unit_tests.core.model_fixtures import make_model_instance calls = [] - def invoke(**kwargs: object) -> SimpleNamespace: + def invoke(**kwargs: object) -> LLMResult: calls.append(kwargs) content = response if len(calls) == 1 else '{"classification":"pure","risk_notice":"Parser"}' - return SimpleNamespace(message=SimpleNamespace(get_text_content=lambda: content)) + return LLMResult( + model="gpt-4o", + prompt_messages=[], + message=AssistantPromptMessage(content=content), + usage=LLMUsage.empty_usage(), + ) - monkeypatch.setattr(model_resolver, "resolve_model_instance", lambda *_: SimpleNamespace(invoke_llm=invoke)) + instance = make_model_instance(provider="openai", model="gpt-4o") + monkeypatch.setattr(instance, "invoke_llm", invoke) + monkeypatch.setattr(model_resolver, "resolve_model_instance", lambda *_: instance) risk = assess_code( prepare(), reason=builder_code_reasoner(tenant_id="owned-tenant", model_config={"name": "selected"}) ) diff --git a/api/tests/unit_tests/services/dify_builder/test_code_risk_preparation.py b/api/tests/unit_tests/services/dify_builder/test_code_risk_preparation.py index 31f9fef7bc4..e6d1ed815f4 100644 --- a/api/tests/unit_tests/services/dify_builder/test_code_risk_preparation.py +++ b/api/tests/unit_tests/services/dify_builder/test_code_risk_preparation.py @@ -160,8 +160,7 @@ def test_changed_code_input_redisplays_before_accepting_checkbox_and_reuses_show @pytest.mark.parametrize("flow_index", [0, 1, 2]) -def test_stale_worker_risk_returns_existing_input_interaction_without_repair(flow_index): - from unittest.mock import Mock +def test_stale_worker_risk_returns_existing_input_interaction_without_repair(mocker, flow_index): from runtime_contracts.builder.execution import BuilderExecutionPolicyError from tests.unit_tests.core.dify_builder.fakes import saved_test_context @@ -173,8 +172,10 @@ def test_stale_worker_risk_returns_existing_input_interaction_without_repair(flo port.run_draft = lambda *_, **__: dify_port._unsupported_run( BuilderExecutionPolicyError("code_risk_assessment_stale") ) - env.agent.diagnose = Mock(side_effect=AssertionError("Stale disclosure is not a failed Code execution")) - result = getattr(module, RUN_HANDLERS[module])(env, turn, session, fc) + mocker.patch.object( + env.agent, "diagnose", side_effect=AssertionError("Stale disclosure is not a failed Code execution") + ) + result = RUN_HANDLERS[module](env, turn, session, fc) assert result.next == flow[5] assert any(item.kind == "form" for item in result.items) assert not result.context.test_input_ref diff --git a/api/tests/unit_tests/services/dify_builder/test_condition_evidence.py b/api/tests/unit_tests/services/dify_builder/test_condition_evidence.py index d333d3f631c..447e79ec6e5 100644 --- a/api/tests/unit_tests/services/dify_builder/test_condition_evidence.py +++ b/api/tests/unit_tests/services/dify_builder/test_condition_evidence.py @@ -3,8 +3,8 @@ import json from collections.abc import Iterator from copy import deepcopy +from dataclasses import dataclass from datetime import datetime -from types import SimpleNamespace from typing import Never, override from unittest.mock import MagicMock, patch @@ -12,6 +12,8 @@ import pytest from core.dify_builder.errors import NotFoundError from core.dify_builder.models import Actor, ApplyResult, Diagnosis, Graph, MutationIntent, NodeOutput, Risk, Run +from models.enums import ExecutionOffLoadType +from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload, WorkflowRun from services.dify_builder.agent import fix from services.dify_builder.application.runner import Env from services.dify_builder.condition_evidence import build_evidence @@ -19,10 +21,28 @@ from services.dify_builder.dify_port import WorkflowServiceDifyPort from services.dify_builder.errors import PreflightError from services.dify_builder.revision import execution_revision from tests.unit_tests.core.dify_builder.fakes import FakeEditDifyPort, InMemoryRepository -from tests.unit_tests.services.dify_builder.test_dify_port import _actor, _configure_session_get, _workflow +from tests.unit_tests.services.dify_builder.test_dify_port import ( + PortDatabase, + TrackedPortSession, + _account, + _actor, + _app, + _configure_session_get, + _execution, + _run, + _workflow, +) from tests.unit_tests.services.dify_builder.test_fix_cognition import _RecordingInstance +@pytest.fixture +def mock_session(sqlite_engine, mocker) -> Iterator[TrackedPortSession]: + with TrackedPortSession(sqlite_engine, expire_on_commit=False) as session: + for name in ("get", "refresh", "commit"): + mocker.spy(session, name) + yield session + + def evidence_graph(operator: str = "is") -> Graph: return { "nodes": [ @@ -239,50 +259,47 @@ def test_projection_budget_discards_whole_conditions_and_marks_unknown() -> None assert len(json.dumps(evidence, ensure_ascii=True).encode()) <= MAX_CONTEXT_BYTES -def native_row(node_id: str, *, value: object = 0.69) -> SimpleNamespace: - return SimpleNamespace( - id="row-" + node_id, - node_execution_id="exec-" + node_id, - tenant_id="tenant-1", - app_id="app-1", - workflow_id="workflow-1", - workflow_run_id="native-run", +@dataclass +class NativeEvidenceCase: + port: WorkflowServiceDifyPort + graph: Graph + workflow: Workflow + run: WorkflowRun + rows: list[WorkflowNodeExecutionModel] + sync: MagicMock + repositories: MagicMock + + +def native_row(node_id: str, *, value: object = 0.69) -> WorkflowNodeExecutionModel: + row = _execution( node_id=node_id, + node_type="code" if node_id == "producer" else "if-else", title=node_id, status="succeeded" if node_id == "producer" else "failed", error="" if node_id == "producer" else "Invalid actual value type: string or boolean", inputs_dict={}, outputs_dict={"structured_output": {"confidence": value}} if node_id == "producer" else {}, - execution_metadata_dict={}, - outputs_truncated=False, - finished_at=datetime.min, ) + row.id = "row-" + node_id + row.node_execution_id = "exec-" + node_id + row.workflow_run_id = "native-run" + row.execution_metadata = json.dumps({}) + row.finished_at = datetime.min + return row @pytest.fixture -def native_adapter() -> Iterator[SimpleNamespace]: - session = MagicMock() - session.__enter__.return_value = session - session.__exit__.return_value = False - _configure_session_get( - session, account=SimpleNamespace(id="acc-1"), app=SimpleNamespace(id="app-1", tenant_id="tenant-1") - ) +def native_adapter(mock_session, sqlite_engine, monkeypatch) -> Iterator[NativeEvidenceCase]: + session = mock_session + monkeypatch.setattr("services.dify_builder.dify_port.db", PortDatabase(sqlite_engine)) + monkeypatch.setattr("services.dify_builder.dify_port._session_factory", lambda: lambda: session) + _configure_session_get(session, account=_account(), app=_app()) graph = evidence_graph() - workflow = _workflow(graph_dict=graph) - run = SimpleNamespace( - id="native-run", - tenant_id="tenant-1", - app_id="app-1", - workflow_id="workflow-1", - graph_dict=deepcopy(graph), - status="failed", - error="", - ) + workflow = _workflow(session=session, graph_dict=graph) + run = _run(id="native-run", graph_dict=deepcopy(graph), status="failed", error="") rows = [native_row("producer"), native_row("branch")] with ( - patch("services.dify_builder.dify_port.db"), patch("services.dify_builder.dify_port.set_login_user"), - patch("services.dify_builder.dify_port.sessionmaker", return_value=MagicMock(return_value=session)), patch("services.dify_builder.dify_port.DifyAPIRepositoryFactory") as repositories, patch("services.dify_builder.dify_port.WorkflowService") as workflow_service, patch("services.dify_builder.dify_port.notify_workflow_draft_changed"), @@ -291,9 +308,11 @@ def native_adapter() -> Iterator[SimpleNamespace]: repositories.create_api_workflow_run_repository.return_value.get_workflow_run_by_id.return_value = run node_repository = repositories.create_api_workflow_node_execution_repository.return_value node_repository.get_executions_by_workflow_run.return_value = rows - workflow_service.return_value.get_draft_workflow.return_value = workflow + workflow_service.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) sync.side_effect = lambda _app, graph, *_args: _workflow(graph_dict=graph) - yield SimpleNamespace( + yield NativeEvidenceCase( port=WorkflowServiceDifyPort(), graph=graph, workflow=workflow, @@ -310,13 +329,13 @@ def repair_intent(value: object = "=", path: str = "comparison_operator") -> Mut ) -def apply_native(adapter: SimpleNamespace, intents: list[MutationIntent]) -> ApplyResult: +def apply_native(adapter: NativeEvidenceCase, intents: list[MutationIntent]) -> ApplyResult: return adapter.port.apply_repair( "app-1", _actor(), intents, expected_revision=execution_revision(adapter.workflow), failed_run_id="native-run" ) -def test_real_write_rejects_value_only_noop_that_leaves_observed_mismatch(native_adapter: SimpleNamespace) -> None: +def test_real_write_rejects_value_only_noop_that_leaves_observed_mismatch(native_adapter: NativeEvidenceCase) -> None: with pytest.raises(PreflightError, match="IF repair"): apply_native(native_adapter, [repair_intent("0.7", "value")]) native_adapter.sync.assert_not_called() @@ -324,7 +343,7 @@ def test_real_write_rejects_value_only_noop_that_leaves_observed_mismatch(native @pytest.mark.parametrize("field", ["tenant_id", "app_id", "id"]) def test_adapter_denies_mismatched_run_before_reading_any_execution( - native_adapter: SimpleNamespace, field: str + native_adapter: NativeEvidenceCase, field: str ) -> None: setattr(native_adapter.run, field, "someone-else") with pytest.raises(NotFoundError): @@ -333,36 +352,43 @@ def test_adapter_denies_mismatched_run_before_reading_any_execution( @pytest.mark.parametrize("field", ["tenant_id", "app_id", "workflow_run_id", "workflow_id"]) -def test_adapter_denies_row_identity_before_ephemeral_raw_handling(native_adapter: SimpleNamespace, field: str) -> None: - class PoisonRow: - @property - def outputs_dict(self) -> Never: - raise AssertionError("unauthorized outputs were accessed") +def test_adapter_denies_row_identity_before_ephemeral_raw_handling( + native_adapter: NativeEvidenceCase, field: str +) -> None: + def poisoned_outputs(_row) -> Never: + raise AssertionError("unauthorized outputs were accessed") - poisoned = PoisonRow() - poisoned.__dict__.update(vars(native_adapter.rows[1])) - poisoned.__dict__.pop("outputs_dict") - setattr(poisoned, field, "someone-else") - native_adapter.rows[1] = poisoned - with pytest.raises(NotFoundError): + setattr(native_adapter.rows[1], field, "someone-else") + with ( + patch.object(WorkflowNodeExecutionModel, "outputs_dict", new=property(poisoned_outputs)), + pytest.raises(NotFoundError), + ): native_adapter.port.failed_run_context("app-1", _actor(), "native-run") @pytest.mark.parametrize("kind", ["missing-run", "missing-graph", "offload", "loop-metadata", "retry-metadata"]) def test_adapter_unavailable_native_evidence_does_not_gain_observed_types( - native_adapter: SimpleNamespace, kind: str + native_adapter: NativeEvidenceCase, kind: str ) -> None: if kind == "missing-run": run_repository = native_adapter.repositories.create_api_workflow_run_repository.return_value run_repository.get_workflow_run_by_id.return_value = None elif kind == "missing-graph": - native_adapter.run.graph_dict = dict[str, object]() + native_adapter.run.graph = json.dumps({}) elif kind == "offload": - native_adapter.rows[0].outputs_truncated = True + native_adapter.rows[0].offload_data = [ + WorkflowNodeExecutionOffload( + tenant_id="tenant-1", + app_id="app-1", + node_execution_id="exec-producer", + type_=ExecutionOffLoadType.OUTPUTS, + file_id="offloaded-output", + ) + ] elif kind == "loop-metadata": - native_adapter.rows[0].execution_metadata_dict = {"loop_id": "loop-1"} + native_adapter.rows[0].execution_metadata = json.dumps({"loop_id": "loop-1"}) elif kind == "retry-metadata": - native_adapter.rows[0].execution_metadata_dict = {"retry_index": 1} + native_adapter.rows[0].execution_metadata = json.dumps({"retry_index": 1}) historical, rows, _ = native_adapter.port.failed_run_context("app-1", _actor(), "native-run") _, _, _, evidence = build_evidence(Run(), native_adapter.graph, historical, rows) assert not evidence["conditions"] or evidence["conditions"][0]["producer_json_type"] == "unknown" @@ -371,7 +397,7 @@ def test_adapter_unavailable_native_evidence_does_not_gain_observed_types( native_adapter.sync.assert_not_called() -def test_real_write_proven_numeric_equality_changes_only_nested_operator(native_adapter: SimpleNamespace) -> None: +def test_real_write_proven_numeric_equality_changes_only_nested_operator(native_adapter: NativeEvidenceCase) -> None: original = deepcopy(native_adapter.run.graph_dict) result = apply_native(native_adapter, [repair_intent()]) assert result.changed_nodes == ["branch"] @@ -394,25 +420,27 @@ def test_real_write_proven_numeric_equality_changes_only_nested_operator(native_ ], ) def test_actual_write_gate_preserves_threshold_selector_and_ordering( - native_adapter: SimpleNamespace, operator: str, value: list[str] | str, path: str + native_adapter: NativeEvidenceCase, operator: str, value: list[str] | str, path: str ) -> None: native_adapter.graph["nodes"][1]["data"]["cases"][0]["conditions"][0]["comparison_operator"] = operator native_adapter.workflow.graph = json.dumps(native_adapter.graph) - native_adapter.run.graph_dict = deepcopy(native_adapter.graph) + native_adapter.run.graph = json.dumps(native_adapter.graph) with pytest.raises(PreflightError): apply_native(native_adapter, [repair_intent(value, path)]) native_adapter.sync.assert_not_called() -def test_real_model_boundary_diagnosis_and_proposal_share_sanitized_types(native_adapter: SimpleNamespace) -> None: +def test_real_model_boundary_diagnosis_and_proposal_share_sanitized_types(native_adapter: NativeEvidenceCase) -> None: from services.dify_builder.agent import llm_agent from services.dify_builder.agent.llm_agent import LlmBuilderAgent from services.dify_builder.condition_evidence import diagnose_failure, propose_failure secret = "upstream-bearer-sealed-value" - native_adapter.rows[0].outputs_dict["token"] = secret + native_adapter.rows[0].outputs = json.dumps({**native_adapter.rows[0].outputs_dict, "token": secret}) native_adapter.rows[1].error += " " + secret - native_adapter.run.graph_dict["nodes"][0]["data"]["password"] = secret + history = native_adapter.run.graph_dict + history["nodes"][0]["data"]["password"] = secret + native_adapter.run.graph = json.dumps(history) native_adapter.graph["nodes"][1]["data"]["title"] = secret raw_run = Run(dify_run_id="native-run", immutable=True, error=secret) original = deepcopy(raw_run) @@ -660,14 +688,16 @@ def test_real_proposal_cannot_bypass_failed_if_gate_via_other_graph_mutations(at assert intents == [] -def test_native_run_error_credentials_are_collected_for_ordinary_fix(native_adapter: SimpleNamespace) -> None: +def test_native_run_error_credentials_are_collected_for_ordinary_fix(native_adapter: NativeEvidenceCase) -> None: from services.dify_builder.agent import llm_agent from services.dify_builder.condition_evidence import diagnose_failure secret = "native-run-error-only-password" native_adapter.run.error = "password=" + secret - for graph in (native_adapter.graph, native_adapter.run.graph_dict): + history = native_adapter.run.graph_dict + for graph in (native_adapter.graph, history): graph["nodes"][1]["data"]["cases"][0]["conditions"][0]["value"] = secret + native_adapter.run.graph = json.dumps(history) model = _RecordingInstance(['{"culprit_node_id":"branch","root_cause":"mismatch","severity":"high"}']) env = Env( dify=native_adapter.port, @@ -683,16 +713,16 @@ def test_native_run_error_credentials_are_collected_for_ordinary_fix(native_adap @pytest.mark.parametrize("kind", ["target-loop-metadata", "target-duplicate", "incomplete-success", "retry-zero"]) def test_incomplete_or_repeated_execution_scope_cannot_establish_types( - native_adapter: SimpleNamespace, kind: str + native_adapter: NativeEvidenceCase, kind: str ) -> None: if kind == "target-loop-metadata": - native_adapter.rows[1].execution_metadata_dict = {"loop_id": "loop-1"} + native_adapter.rows[1].execution_metadata = json.dumps({"loop_id": "loop-1"}) elif kind == "target-duplicate": native_adapter.rows.append(deepcopy(native_adapter.rows[1])) elif kind == "incomplete-success": native_adapter.rows[0].finished_at = None else: - native_adapter.rows[0].execution_metadata_dict = {"retry_index": 0} + native_adapter.rows[0].execution_metadata = json.dumps({"retry_index": 0}) historical, rows, _ = native_adapter.port.failed_run_context("app-1", _actor(), "native-run") _, _, _, evidence = build_evidence(Run(), native_adapter.graph, historical, rows) assert evidence["conditions"][0]["producer_json_type"] == "unknown" @@ -760,7 +790,7 @@ def test_over_budget_execution_identifiers_cannot_escape_context_budget() -> Non @pytest.mark.parametrize("attack", ["producer", "new-node", "delete-if"]) -def test_actual_write_refuses_indirect_if_gate_bypass(native_adapter: SimpleNamespace, attack: str) -> None: +def test_actual_write_refuses_indirect_if_gate_bypass(native_adapter: NativeEvidenceCase, attack: str) -> None: if attack == "producer": intent = MutationIntent( op="set_node_config", args={"node_id": "producer", "path": "code", "value": "def main(): return {}"} @@ -817,7 +847,7 @@ def test_proven_equality_repair_preserves_native_threshold_route( @pytest.mark.parametrize("mode", ["fix", "build", "edit"]) def test_runtime_handler_write_reloads_trusted_failed_run_and_refuses_value_only( - native_adapter: SimpleNamespace, mode: str + native_adapter: NativeEvidenceCase, mode: str ) -> None: from core.dify_builder.models import Action, DifyBuilderContext, EntryMode, Turn from core.dify_builder.state import PcState @@ -858,7 +888,7 @@ def test_runtime_handler_write_reloads_trusted_failed_run_and_refuses_value_only native_adapter.sync.assert_not_called() -def test_runtime_non_if_fix_keeps_existing_native_write_behavior(native_adapter: SimpleNamespace) -> None: +def test_runtime_non_if_fix_keeps_existing_native_write_behavior(native_adapter: NativeEvidenceCase) -> None: native_adapter.rows[0].status = "failed" native_adapter.rows[0].error = "NameError: missing_variable" native_adapter.rows[1].status = "skipped" @@ -889,7 +919,7 @@ def _producer_rewrite_payload() -> str: def _real_context_proposal( - adapter: SimpleNamespace, replies: list[str] | None = None + adapter: NativeEvidenceCase, replies: list[str] | None = None ) -> tuple[Diagnosis, list[MutationIntent], Risk, _RecordingInstance]: from services.dify_builder.agent import llm_agent from services.dify_builder.condition_evidence import diagnose_failure, propose_failure @@ -917,17 +947,17 @@ def _real_context_proposal( @pytest.mark.parametrize("boundary", ["projection", "proposal", "write"]) def test_review_r1_handled_exception_cannot_hide_terminal_failed_if( - native_adapter: SimpleNamespace, boundary: str + native_adapter: NativeEvidenceCase, boundary: str ) -> None: from services.dify_builder.preflight import preflight_errors producer = native_adapter.graph["nodes"][0]["data"] producer["error_strategy"] = "default-value" producer["default_value"] = [{"key": "structured_output", "type": "object", "value": {"confidence": "unknown"}}] - native_adapter.run.graph_dict = deepcopy(native_adapter.graph) + native_adapter.run.graph = json.dumps(native_adapter.graph) native_adapter.workflow.graph = json.dumps(native_adapter.graph) native_adapter.rows[0].status = "exception" - native_adapter.rows[0].execution_metadata_dict = {"error_strategy": "default-value"} + native_adapter.rows[0].execution_metadata = json.dumps({"error_strategy": "default-value"}) assert preflight_errors(native_adapter.graph) == [] if boundary == "write": with pytest.raises(PreflightError): @@ -961,7 +991,7 @@ def test_review_r1_handled_exception_cannot_hide_terminal_failed_if( ) @pytest.mark.parametrize("boundary", ["proposal", "write"]) def test_review_r1_r2_unknown_failure_identity_cannot_authorize_producer_rewrite( - native_adapter: SimpleNamespace, kind: str, boundary: str + native_adapter: NativeEvidenceCase, kind: str, boundary: str ) -> None: if kind == "two-terminal-failures": native_adapter.rows[0].status = "failed" @@ -974,7 +1004,7 @@ def test_review_r1_r2_unknown_failure_identity_cannot_authorize_producer_rewrite elif kind == "empty-rows": native_adapter.rows.clear() else: - native_adapter.run.graph_dict = dict[str, object]() + native_adapter.run.graph = json.dumps({}) if boundary == "proposal": _, intents, _, _ = _real_context_proposal(native_adapter) assert intents == [] @@ -998,7 +1028,7 @@ def test_review_r1_r2_unknown_failure_identity_cannot_authorize_producer_rewrite @pytest.mark.parametrize("boundary", ["proposal", "write"]) def test_review_r3_historical_if_replaced_by_same_id_code_is_stale( - native_adapter: SimpleNamespace, boundary: str + native_adapter: NativeEvidenceCase, boundary: str ) -> None: replacement = deepcopy(native_adapter.graph["nodes"][0]) replacement["id"] = "branch" @@ -1044,21 +1074,23 @@ def test_review_r3_historical_if_replaced_by_same_id_code_is_stale( @pytest.mark.parametrize("spelling", ["client_secret", "clientSecret", "refresh_token", "private_key", "auth_token"]) @pytest.mark.parametrize("source", ["native-error", "upstream-string", "expected-string"]) def test_review_r4_normalized_explicit_credentials_never_reach_prompts_or_evidence( - native_adapter: SimpleNamespace, spelling: str, source: str + native_adapter: NativeEvidenceCase, spelling: str, source: str ) -> None: secret = "review-sensitive-assignment-value" assignment = json.dumps({spelling: secret}) - for graph in (native_adapter.graph, native_adapter.run.graph_dict): + history = native_adapter.run.graph_dict + for graph in (native_adapter.graph, history): graph["nodes"][1]["data"]["cases"][0]["conditions"][0]["value"] = secret if source == "expected-string": sibling = deepcopy(graph["nodes"][1]["data"]["cases"][0]["conditions"][0]) sibling["id"] = "credential-assignment" sibling["value"] = assignment graph["nodes"][1]["data"]["cases"][0]["conditions"].append(sibling) + native_adapter.run.graph = json.dumps(history) if source == "native-error": native_adapter.run.error = spelling + '="' + secret + '"' elif source == "upstream-string": - native_adapter.rows[0].outputs_dict["annotation"] = assignment + native_adapter.rows[0].outputs = json.dumps({**native_adapter.rows[0].outputs_dict, "annotation": assignment}) diagnosis_reply = json.dumps({"culprit_node_id": "branch", "root_cause": "mismatch " + secret, "severity": "high"}) diagnosis, _, _, model = _real_context_proposal( native_adapter, [diagnosis_reply, _producer_rewrite_payload(), _producer_rewrite_payload()] @@ -1072,11 +1104,11 @@ def test_review_r4_normalized_explicit_credentials_never_reach_prompts_or_eviden @pytest.mark.parametrize("boundary", ["proposal", "write"]) def test_review_r2_missing_history_cannot_use_current_non_if_type( - native_adapter: SimpleNamespace, boundary: str + native_adapter: NativeEvidenceCase, boundary: str ) -> None: native_adapter.rows[0].status = "failed" native_adapter.rows[1].status = "skipped" - native_adapter.run.graph_dict = dict[str, object]() + native_adapter.run.graph = json.dumps({}) if boundary == "proposal": _, intents, _, _ = _real_context_proposal(native_adapter) assert intents == [] @@ -1100,7 +1132,7 @@ def test_review_r2_missing_history_cannot_use_current_non_if_type( @pytest.mark.parametrize("missing", ["run", "rows"]) def test_review_r2_write_does_not_trust_prior_available_diagnosis( - native_adapter: SimpleNamespace, missing: str + native_adapter: NativeEvidenceCase, missing: str ) -> None: diagnosis, _, _, _ = _real_context_proposal(native_adapter) assert diagnosis.condition_evidence is not None @@ -1130,7 +1162,7 @@ def test_review_r2_write_does_not_trust_prior_available_diagnosis( @pytest.mark.parametrize("attack", ["schema", "routing"]) @pytest.mark.parametrize("boundary", ["proposal", "write"]) def test_review_r2_unavailable_context_rejects_schema_and_routing( - native_adapter: SimpleNamespace, attack: str, boundary: str + native_adapter: NativeEvidenceCase, attack: str, boundary: str ) -> None: native_adapter.rows.clear() if attack == "schema": @@ -1154,7 +1186,7 @@ def test_review_r2_unavailable_context_rejects_schema_and_routing( native_adapter.sync.assert_not_called() -def test_review_r2_successful_run_cannot_authorize_a_terminal_non_if_repair(native_adapter: SimpleNamespace) -> None: +def test_review_r2_successful_run_cannot_authorize_a_terminal_non_if_repair(native_adapter: NativeEvidenceCase) -> None: native_adapter.run.status = "succeeded" native_adapter.rows[0].status = "failed" native_adapter.rows[1].status = "skipped" @@ -1170,14 +1202,18 @@ def test_review_r2_successful_run_cannot_authorize_a_terminal_non_if_repair(nati native_adapter.sync.assert_not_called() -def test_review_r4_known_credential_repeated_as_failure_identity_is_sanitized(native_adapter: SimpleNamespace) -> None: +def test_review_r4_known_credential_repeated_as_failure_identity_is_sanitized( + native_adapter: NativeEvidenceCase, +) -> None: from services.dify_builder.agent import llm_agent from services.dify_builder.condition_evidence import diagnose_failure secret = "credential-repeated-as-node-identity" - for graph in (native_adapter.graph, native_adapter.run.graph_dict): + history = native_adapter.run.graph_dict + for graph in (native_adapter.graph, history): graph["nodes"][1]["id"] = secret graph["edges"][0]["target"] = secret + native_adapter.run.graph = json.dumps(history) native_adapter.rows[1].node_id = secret native_adapter.run.error = 'client_secret="' + secret + '"' reply = json.dumps({"culprit_node_id": secret, "root_cause": "mismatch", "severity": "high"}) @@ -1200,15 +1236,17 @@ def test_review_r4_known_credential_repeated_as_failure_identity_is_sanitized(na ) @pytest.mark.parametrize("source", ["native-error", "upstream-string"]) def test_review_r4_non_sensitive_wrapper_cannot_hide_explicit_inner_credential( - native_adapter: SimpleNamespace, wrapper: str, source: str + native_adapter: NativeEvidenceCase, wrapper: str, source: str ) -> None: secret = "sealed-wrapper-value" - for graph in (native_adapter.graph, native_adapter.run.graph_dict): + history = native_adapter.run.graph_dict + for graph in (native_adapter.graph, history): graph["nodes"][1]["data"]["cases"][0]["conditions"][0]["value"] = secret + native_adapter.run.graph = json.dumps(history) if source == "native-error": native_adapter.run.error = wrapper else: - native_adapter.rows[0].outputs_dict["annotation"] = wrapper + native_adapter.rows[0].outputs = json.dumps({**native_adapter.rows[0].outputs_dict, "annotation": wrapper}) diagnosis, _, _, model = _real_context_proposal(native_adapter) assert secret not in json.dumps(diagnosis.condition_evidence) assert all(secret not in " ".join(message.content for message in call["prompt_messages"]) for call in model.calls) @@ -1216,9 +1254,11 @@ def test_review_r4_non_sensitive_wrapper_cannot_hide_explicit_inner_credential( @pytest.mark.parametrize("boundary", ["proposal", "write"]) def test_review_r2_unregistered_historical_type_cannot_prove_non_if( - native_adapter: SimpleNamespace, boundary: str + native_adapter: NativeEvidenceCase, boundary: str ) -> None: - native_adapter.run.graph_dict["nodes"][1]["data"]["type"] = "unregistered-native-type" + history = native_adapter.run.graph_dict + history["nodes"][1]["data"]["type"] = "unregistered-native-type" + native_adapter.run.graph = json.dumps(history) replacement = deepcopy(native_adapter.graph["nodes"][0]) replacement["id"] = "branch" native_adapter.graph["nodes"][1] = replacement diff --git a/api/tests/unit_tests/services/dify_builder/test_confirmed_full_run.py b/api/tests/unit_tests/services/dify_builder/test_confirmed_full_run.py index 34241de1c67..e7a45e6423f 100644 --- a/api/tests/unit_tests/services/dify_builder/test_confirmed_full_run.py +++ b/api/tests/unit_tests/services/dify_builder/test_confirmed_full_run.py @@ -4,23 +4,26 @@ import json import threading from concurrent.futures import ThreadPoolExecutor from copy import deepcopy -from types import SimpleNamespace +from dataclasses import dataclass from unittest.mock import patch from uuid import uuid4 import pytest -from sqlalchemy import create_engine, select +from sqlalchemy import Engine, create_engine, select from sqlalchemy.orm import sessionmaker -from configs import dify_config from core.app.entities.app_invoke_entities import InvokeFrom from core.dify_builder.input_schema import SYSTEM_QUERY from core.dify_builder.models import Actor, DifyBuilderContext, EntryMode, NodeOutput, Run, Session, Turn from core.dify_builder.state import PcState +from models.account import Account from models.dify_builder import DifyBuilderExecutionRequest, DifyBuilderRun, DifyBuilderSession, DifyBuilderTestInput +from models.model import App from models.tools import BuiltinToolProvider from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload, WorkflowRun from runtime_contracts.builder.execution import canonical_digest +from runtime_contracts.builder.native.agent_context import BuilderAgentExecutionContext +from runtime_contracts.builder.native.contracts import CheckpointRef, NativeTestContextRef, WaitingReceipt from services.dify_builder import dify_port from services.dify_builder.application import handlers_build from services.dify_builder.application.placeholder_agent import PlaceholderAgent @@ -28,6 +31,7 @@ from services.dify_builder.application.runner import Env from services.dify_builder.dify_port import WorkflowServiceDifyPort from services.dify_builder.effect_disclosure import disclose from services.dify_builder.repository import SqlDifyBuilderRepository +from tests.unit_tests.config_override import apply_config_overrides GRAPH = { "nodes": [ @@ -50,6 +54,30 @@ GRAPH = { } +@dataclass(frozen=True) +class FullRunDatabase: + engine: Engine + + +@dataclass +class FullRunCase: + app: App + draft: Workflow + saved: DifyBuilderTestInput + generate: object + finished: Run + factory: sessionmaker + ids: dict[str, str] + native_finish: object + + +def prepared_agent_context(): + return BuilderAgentExecutionContext( + ref=NativeTestContextRef(request_id="request", invocation_id="root", context_digest="a" * 64), + native_run_id=str(uuid4()), + ) + + def _actor() -> Actor: return Actor(account_id="acc-1", tenant_id="tenant-1") @@ -90,7 +118,7 @@ def harness(monkeypatch: pytest.MonkeyPatch, tmp_path): ) ) session.add(saved) - app = SimpleNamespace(id=ids["app"], tenant_id=ids["tenant"], mode="workflow") + app = App(id=ids["app"], tenant_id=ids["tenant"], mode="workflow") draft = Workflow( tenant_id="tenant-1", graph=json.dumps(GRAPH), @@ -98,7 +126,7 @@ def harness(monkeypatch: pytest.MonkeyPatch, tmp_path): environment_variables=[], conversation_variables=[], ) - monkeypatch.setattr(dify_config, "DIFY_BUILDER_CONFIRMED_RUN_ENABLED", True) + apply_config_overrides(monkeypatch, DIFY_BUILDER_CONFIRMED_RUN_ENABLED=True) monkeypatch.setattr(WorkflowServiceDifyPort, "_restricted_admissible", lambda *_args: False) finished = Run( status="succeeded", @@ -108,21 +136,23 @@ def harness(monkeypatch: pytest.MonkeyPatch, tmp_path): native_finish = WorkflowServiceDifyPort._finish_run monkeypatch.setattr(WorkflowServiceDifyPort, "_finish_run", lambda *_args, **_kwargs: (deepcopy(finished), None)) monkeypatch.setattr(WorkflowServiceDifyPort, "_bind_run", lambda _self, result, *_args, **_kwargs: result[0]) + account = Account(name="Owner", email="owner@example.invalid") + account.id = "acc-1" with ( patch.object(dify_port, "_session_factory", return_value=factory), - patch.object(dify_port, "db", SimpleNamespace(engine=engine)), + patch.object(dify_port, "db", FullRunDatabase(engine=engine)), patch.object(dify_port, "set_login_user"), - patch.object(dify_port, "resolve_account", return_value=SimpleNamespace(id="acc-1")), + patch.object(dify_port, "resolve_account", return_value=account), patch.object(dify_port, "load_app", return_value=app), patch.object(dify_port, "_load_draft_workflow_or_raise", return_value=draft), - patch.object(dify_port, "AppGenerateService") as generate, + patch.object(dify_port.AppGenerateService, "generate") as generate, ): - generate.generate.return_value = {"data": {"id": "native-1", "status": "succeeded"}} - state = SimpleNamespace( + generate.return_value = {"data": {"id": "native-1", "status": "succeeded"}} + state = FullRunCase( app=app, draft=draft, saved=saved, - generate=generate.generate, + generate=generate, finished=finished, factory=factory, ids=ids, @@ -140,7 +170,7 @@ def test_real_agent_admission_uses_reserved_parent_and_current_saved_input(harne harness.draft.graph = json.dumps(graph) signature = disclose(graph, inputs={"topic": "x"}).items[0].signature seen = [] - context = SimpleNamespace(native_run_id=str(uuid4())) + context = prepared_agent_context() monkeypatch.setattr( WorkflowServiceDifyPort, "_prepare_confirmed_agent_execution", @@ -224,7 +254,7 @@ def test_confirmed_pause_frame_is_public_only_after_durable_parent_barrier(harne monkeypatch.setattr( WorkflowServiceDifyPort, "_prepare_confirmed_agent_execution", - staticmethod(lambda **_kwargs: SimpleNamespace(native_run_id=str(uuid4()))), + staticmethod(lambda **_kwargs: prepared_agent_context()), ) harness.generate.return_value = iter([{"event": "workflow_paused", "data": {"id": "native-1"}}]) monkeypatch.setattr( @@ -232,7 +262,7 @@ def test_confirmed_pause_frame_is_public_only_after_durable_parent_barrier(harne "_finish_run", lambda *_args, **_kwargs: ( Run(status="failed", dify_run_id="native-1"), - SimpleNamespace(status="paused", graph_dict=graph), + WorkflowRun(status="paused", graph=json.dumps(graph)), ), ) order = [] @@ -241,7 +271,14 @@ def test_confirmed_pause_frame_is_public_only_after_durable_parent_barrier(harne order.append("barrier") if not barrier_ok: raise RuntimeError("storage unavailable") - return SimpleNamespace() + return WaitingReceipt( + request_id="request", + invocation_id="root", + generation=1, + checkpoint=CheckpointRef(checkpoint_id="checkpoint", payload_digest="a" * 64), + pending_operation_ids=("operation",), + wait_reason="approval", + ) monkeypatch.setattr(WorkflowServiceDifyPort, "_settle_confirmed_agent_parent", staticmethod(barrier)) result = _run(harness, {"agent-1": signature}, on_workflow_event=lambda _event: order.append("public")) @@ -507,7 +544,7 @@ def test_recovery_keeps_existing_attempt_when_runtime_selection_changes(harness, harness.generate.side_effect = generate _run(harness) - monkeypatch.setattr(dify_config, "DIFY_BUILDER_CONFIRMED_RUN_ENABLED", False) + apply_config_overrides(monkeypatch, DIFY_BUILDER_CONFIRMED_RUN_ENABLED=False) recovered = _run(harness) assert effects == ["sent"] @@ -598,7 +635,7 @@ def test_disclosure_is_empty_when_the_flag_is_off(monkeypatch: pytest.MonkeyPatc "slack" } - monkeypatch.setattr(dify_config, "DIFY_BUILDER_CONFIRMED_RUN_ENABLED", False) + apply_config_overrides(monkeypatch, DIFY_BUILDER_CONFIRMED_RUN_ENABLED=False) assert WorkflowServiceDifyPort().disclose_effects("app-1", _actor()).items == () @@ -712,7 +749,7 @@ def test_failed_native_plugin_resource_uses_actual_historical_graph(harness, mon monkeypatch.setattr( WorkflowServiceDifyPort, "_finish_run", - lambda *_a, **_kw: (deepcopy(harness.finished), SimpleNamespace(graph_dict=historical)), + lambda *_a, **_kw: (deepcopy(harness.finished), WorkflowRun(graph=json.dumps(historical))), ) evidence = _run(harness).verification.execution_evidence assert evidence.failure_phase == "execution" diff --git a/api/tests/unit_tests/services/dify_builder/test_confirmed_tool_launch.py b/api/tests/unit_tests/services/dify_builder/test_confirmed_tool_launch.py index 05dc9ffe3ad..d82263ce46e 100644 --- a/api/tests/unit_tests/services/dify_builder/test_confirmed_tool_launch.py +++ b/api/tests/unit_tests/services/dify_builder/test_confirmed_tool_launch.py @@ -12,7 +12,7 @@ contents only; PostgreSQL locking and actual native allocated IDs are CI-owned. import json import logging from collections.abc import Callable, Iterator, Mapping -from dataclasses import asdict +from dataclasses import asdict, dataclass from datetime import datetime, timedelta from types import ModuleType from typing import TYPE_CHECKING, Literal, NoReturn, cast, override @@ -1578,14 +1578,19 @@ def test_terminal_outputs_are_not_copied_even_when_native_run_retains_content(co assert BODY_SENTINEL not in text +@dataclass(frozen=True) +class ConfirmedDatabase: + engine: object + session: object | None = None + + def bind_fixture_engine(monkeypatch: pytest.MonkeyPatch, owner: Owner) -> None: """Point the port's and WorkflowService's engine owners at the fixture SQLite engine (no Flask app).""" - from types import SimpleNamespace import services.dify_builder.dify_port as port_module import services.workflow_service as workflow_service_module - engine = SimpleNamespace(engine=owner.factory.kw["bind"]) + engine = ConfirmedDatabase(engine=owner.factory.kw["bind"]) monkeypatch.setattr(port_module, "_session_factory", lambda: owner.factory) monkeypatch.setattr(port_module, "db", engine) monkeypatch.setattr(workflow_service_module, "db", engine) @@ -1600,11 +1605,11 @@ def install_native_generation(owner: Owner, monkeypatch: pytest.MonkeyPatch) -> the port e2e test does. Returns the fixture native Run IDs (uuid4, not Graphon-allocated). """ import threading - from types import SimpleNamespace from sqlalchemy.orm import Session import services.dify_builder.dify_port as port_module + from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from models.account import Account from models.model import App @@ -1630,20 +1635,19 @@ def install_native_generation(owner: Owner, monkeypatch: pytest.MonkeyPatch) -> with owner.factory() as db: workflow = db.get(Workflow, owner.workflow_id) assert workflow is not None - entity = SimpleNamespace( + entity = WorkflowAppGenerateEntity( builder_execution=builder_execution, files=[], single_iteration_run=None, single_loop_run=None, invoke_from=InvokeFrom.DEBUGGER, - app_config=SimpleNamespace( - app_id=owner.app_id, tenant_id=owner.actor.tenant_id, workflow_id=owner.workflow_id, app_mode="workflow" - ), + app_config=WorkflowAppConfigManager.get_app_config(app_model, workflow), user_id=owner.actor.account_id, inputs=dict(args["inputs"]), call_depth=0, workflow_execution_id=run_id, task_id="task", + stream=True, ) def worker() -> None: @@ -1710,11 +1714,11 @@ def test_port_launches_reserved_command_natively_and_projects_sanitized_builder_ ) -> None: """The actual port/admission/recorder/seal/settle chain on real rows; only native generation is faked.""" import threading - from types import SimpleNamespace from sqlalchemy.orm import Session import services.dify_builder.dify_port as port_module + from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from models.account import Account from models.model import App @@ -1751,20 +1755,19 @@ def test_port_launches_reserved_command_natively_and_projects_sanitized_builder_ workflow = db.get(Workflow, owner.workflow_id) assert workflow is not None # Duck-typed generate entity carrying exactly the attributes the admission reads. - entity = SimpleNamespace( + entity = WorkflowAppGenerateEntity( builder_execution=context, files=[], single_iteration_run=None, single_loop_run=None, invoke_from=InvokeFrom.DEBUGGER, - app_config=SimpleNamespace( - app_id=owner.app_id, tenant_id=owner.actor.tenant_id, workflow_id=owner.workflow_id, app_mode="workflow" - ), + app_config=WorkflowAppConfigManager.get_app_config(app_model, workflow), user_id=owner.actor.account_id, inputs=dict(args["inputs"]), call_depth=0, workflow_execution_id=run_id, task_id="task", + stream=True, ) def worker() -> None: @@ -1919,8 +1922,6 @@ def _drive_generator_draft_saving( token sentinels. The mutable draft-variable writer is a recorder bound to the fixture SQLite engine. """ import contextlib - from types import SimpleNamespace - from unittest.mock import Mock import core.app.apps.base_app_generator as base_generator_module from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig @@ -1935,7 +1936,7 @@ def _drive_generator_draft_saving( engine = owner.factory.kw["bind"] writes = _DraftWrites() monkeypatch.setattr(base_generator_module, "DraftVariableSaverImpl", writes.writer) - monkeypatch.setattr(base_generator_module, "db", SimpleNamespace(engine=engine)) + monkeypatch.setattr(base_generator_module, "db", ConfirmedDatabase(engine=engine)) with owner.factory() as db: account = db.get(Account, owner.actor.account_id) app = db.get(App, owner.app_id) @@ -1943,19 +1944,26 @@ def _drive_generator_draft_saving( assert account is not None assert app is not None assert workflow is not None - monkeypatch.setattr(app_generator_module, "db", SimpleNamespace(engine=engine, session=Mock())) - generator = WorkflowAppGenerator(execution_driver=Mock()) + session = owner.factory() + monkeypatch.setattr(app_generator_module, "db", ConfirmedDatabase(engine=engine, session=session)) + + def forbidden_driver(*_args, **_kwargs): + raise AssertionError("captured generation must not execute the worker") + + generator = WorkflowAppGenerator(execution_driver=forbidden_driver) monkeypatch.setattr(generator, "_bind_file_access_scope", lambda **_kwargs: contextlib.nullcontext()) - monkeypatch.setattr(app_generator_module, "WorkflowAppQueueManager", lambda **kwargs: SimpleNamespace(**kwargs)) - monkeypatch.setattr( - app_generator_module, "current_app", SimpleNamespace(_get_current_object=lambda: SimpleNamespace(name="flask")) - ) - worker = Mock() - worker.is_alive.return_value = False + import core.app.apps.base_app_queue_manager as queue_module + + monkeypatch.setattr(queue_module.redis_client, "setex", lambda *_args: True) + native_thread = app_generator_module.threading.Thread worker_kwargs: dict[str, object] = {} - def thread(**kwargs: object) -> Mock: + def thread(**kwargs: object): worker_kwargs.update(cast(dict[str, object], kwargs["kwargs"])) + worker = native_thread(**kwargs) + monkeypatch.setattr(worker, "start", lambda: None) + monkeypatch.setattr(worker, "is_alive", lambda: False) + monkeypatch.setattr(worker, "join", lambda *_args, **_kwargs: None) return worker monkeypatch.setattr(app_generator_module.threading, "Thread", thread) diff --git a/api/tests/unit_tests/services/dify_builder/test_conflict_chain.py b/api/tests/unit_tests/services/dify_builder/test_conflict_chain.py index d2fe60c7891..10fe18ac683 100644 --- a/api/tests/unit_tests/services/dify_builder/test_conflict_chain.py +++ b/api/tests/unit_tests/services/dify_builder/test_conflict_chain.py @@ -5,9 +5,7 @@ from __future__ import annotations import json from collections.abc import Callable, Mapping from dataclasses import asdict -from types import SimpleNamespace from typing import TYPE_CHECKING, TypedDict, Unpack, cast -from unittest.mock import MagicMock, Mock import pytest @@ -32,6 +30,7 @@ from models.workflow import Workflow from services.dify_builder.repository import SqlDifyBuilderRepository from services.dify_builder.revision import executable_graph_revision, execution_revision from services.dify_builder.service import DifyBuilderService +from services.workflow_run_agg import WorkflowRunAgg from tests.unit_tests.core.dify_builder.fakes import StubAgent from tests.unit_tests.core.dify_builder.test_execution_policy import fixture, graph from tests.unit_tests.services.dify_builder.test_advance_task import ( @@ -54,6 +53,7 @@ from tests.unit_tests.services.dify_builder.test_advance_task import ( from tests.unit_tests.services.dify_builder.test_advance_task import ( wired as wired, # noqa: PLC0414 ) +from tests.unit_tests.services.dify_builder.test_dify_port import PortDatabase from tests.unit_tests.services.dify_builder.test_execution_policy_service import owned as owned # noqa: PLC0414 if TYPE_CHECKING: @@ -531,10 +531,10 @@ def test_canvas_presentation_survives_the_service_worker_port_and_native_admissi monkeypatch.setattr(worker.session_lock, "exists", lambda _sid: False) monkeypatch.setattr(worker.session_lock, "acquire", lambda _sid: "queued-token") monkeypatch.setattr(worker.session_lock, "release", lambda _sid, token: released.append(token)) - monkeypatch.setattr(native, "db", SimpleNamespace(engine=factory.kw["bind"])) - monkeypatch.setattr(native, "TraceQueueManager", MagicMock()) - monkeypatch.setattr(native, "DifyCoreRepositoryFactory", MagicMock()) - generator = native.WorkflowAppGenerator(execution_driver=Mock()) + monkeypatch.setattr(native, "db", PortDatabase(factory.kw["bind"])) + monkeypatch.setattr(native.TraceQueueManager, "start_timer", lambda _self: None) + monkeypatch.setattr("core.ops.ops_trace_manager.OpsTraceManager.get_ops_trace_instance", lambda _app: None) + generator = native.WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run) monkeypatch.setattr(generator, "_generate", lambda **kwargs: kwargs) monkeypatch.setattr(port, "run_draft", _capture_run(port.run_draft, runs)) diff --git a/api/tests/unit_tests/services/dify_builder/test_dify_port.py b/api/tests/unit_tests/services/dify_builder/test_dify_port.py index d12480f9102..188b931bd97 100644 --- a/api/tests/unit_tests/services/dify_builder/test_dify_port.py +++ b/api/tests/unit_tests/services/dify_builder/test_dify_port.py @@ -1,9 +1,8 @@ """Tests for the real ``DifyPort`` adapter -- ``WorkflowServiceDifyPort``. -These mock every OSS service the adapter touches (``WorkflowService``, -``AppGenerateService``, ``DifyAPIRepositoryFactory``, and the -``sessionmaker``/``db`` plumbing) -- no real DB, no real workflow execution. -The goal is to pin the adapter's ORCHESTRATION and the P2 plan's OSS gotchas: +These use real SQLite sessions and persisted identity/draft rows. External +workflow generation and service boundaries remain patched to pin the adapter's +orchestration and the P2 plan's service contracts: - ``apply_repair`` calls ``sync_draft_workflow`` with ``graph_only=True`` and ``sync_agent_bindings=False``, mutating only the target node's data (deep @@ -26,16 +25,19 @@ is Task 5's runbook, not this suite. import json from collections.abc import Callable, Iterator, Mapping, Sequence -from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from dataclasses import dataclass +from datetime import datetime +from unittest.mock import patch import pytest +from sqlalchemy import Engine, update +from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom from core.dify_builder.models import Actor, ChangedNode, Graph, MutationIntent, NodeEvent, Run from models.account import Account from models.model import App -from models.workflow import Workflow +from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowRun from runtime_contracts.builder.execution import BuilderExecutionContext from services.dify_builder import run_mapping from services.dify_builder.application.ports import DifyPort @@ -49,48 +51,162 @@ from tests.unit_tests.services.dify_builder.workflow_stream_fixtures import ( ) -def _workflow(*, graph_dict: Graph | None = None, features_dict: Graph | None = None) -> Workflow: - return Workflow( +def _workflow( + *, graph_dict: Graph | None = None, features_dict: Graph | None = None, session: Session | None = None +) -> Workflow: + row = Workflow( tenant_id="tenant-1", + app_id="app-1", + type="workflow", + version=Workflow.VERSION_DRAFT, + created_by="acc-1", graph=json.dumps(graph_dict or {"nodes": [], "edges": []}), features=json.dumps(features_dict or {}), environment_variables=[], conversation_variables=[], ) + if session is not None: + session.add(row) + session.flush() + session.commit() + session.commit.reset_mock() + return row + + +def _account(*, id: str = "acc-1") -> Account: + row = Account(name="Actor", email=f"{id}@example.test") + row.id = id + return row + + +def _app( + *, + id: str = "app-1", + tenant_id: str = "tenant-1", + mode: str = "workflow", + workflow_id: str | None = None, + updated_by: str | None = None, + updated_at: datetime | None = None, +) -> App: + row = App( + id=id, + tenant_id=tenant_id, + mode=mode, + name="Draft", + enable_site=False, + enable_api=False, + workflow_id=workflow_id, + updated_by=updated_by, + ) + if updated_at is not None: + row.updated_at = updated_at + return row + + +def _execution( + *, + node_id: str, + node_type: str, + title: str, + status: str, + error: str | None = None, + inputs_dict: Graph | None = None, + outputs_dict: Graph | None = None, +) -> WorkflowNodeExecutionModel: + return WorkflowNodeExecutionModel( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id="run-1", + triggered_from="workflow-run", + index=1, + node_id=node_id, + node_type=node_type, + title=title, + status=status, + error=error, + created_by="acc-1", + created_by_role="account", + inputs=json.dumps(inputs_dict) if inputs_dict is not None else None, + outputs=json.dumps(outputs_dict) if outputs_dict is not None else None, + ) + + +def _run( + *, + id: str = "run-1", + status: str = "succeeded", + error: str | None = None, + elapsed_time: float = 0, + total_tokens: int = 0, + graph_dict: Graph | None = None, +) -> WorkflowRun: + return WorkflowRun( + id=id, + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + type="workflow", + triggered_from="debugging", + version="draft", + created_by="acc-1", + created_by_role="account", + status=status, + error=error, + elapsed_time=elapsed_time, + total_tokens=total_tokens, + graph=json.dumps(graph_dict or {}), + ) + + +def _published_workflow(*, id: str, marked_name: str, version_number: int = 1) -> Workflow: + row = _workflow() + row.id, row.marked_name, row.version_number = id, marked_name, version_number + return row + + +@dataclass +class PortDatabase: + engine: Engine + + +class TrackedPortSession(Session): + exits: int = 0 + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.exits += 1 + super().__exit__(exc_type, exc_value, traceback) def _actor() -> Actor: return Actor(account_id="acc-1", tenant_id="tenant-1") -def _configure_session_get( - session: MagicMock, *, account: object | None = None, app: SimpleNamespace | MagicMock | None = None -) -> None: - if app is not None and not hasattr(app, "tracing"): - app.tracing = None - - def _get(model: object, _id: str) -> object | None: - if model is Account: - return account - if model is App: - return app - return None - - session.get.side_effect = _get +def _configure_session_get(session: Session, *, account: Account | None = None, app: App | None = None) -> None: + for row in (account, app): + if row is not None: + if session.get(type(row), row.id) is None: + session.add(row) + else: + session.merge(row) + session.commit() + session.get.reset_mock() + session.refresh.reset_mock() + session.commit.reset_mock() @pytest.fixture -def mock_session() -> MagicMock: - """A ``MagicMock`` usable as a ``with session_factory() as session:`` context manager.""" - session = MagicMock() - session.__enter__.return_value = session - session.__exit__.return_value = False - return session +def mock_session(sqlite_engine, mocker) -> Iterator[TrackedPortSession]: + with TrackedPortSession(sqlite_engine, expire_on_commit=False) as session: + for name in ("get", "refresh", "commit"): + mocker.spy(session, name) + yield session @pytest.fixture(autouse=True) -def _mock_db() -> Iterator[None]: - with patch("services.dify_builder.dify_port.db"), patch("services.dify_builder.dify_port.set_login_user"): +def _mock_db(sqlite_engine, monkeypatch) -> Iterator[None]: + monkeypatch.setattr("services.dify_builder.dify_port.db", PortDatabase(sqlite_engine)) + with patch("services.dify_builder.dify_port.set_login_user"): yield @@ -106,18 +222,17 @@ def _skip_preflight() -> Iterator[None]: @pytest.fixture(autouse=True) -def _mock_sessionmaker(mock_session: MagicMock) -> Iterator[MagicMock]: - """Every ``sessionmaker(...)`` call in the adapter yields ``mock_session``.""" - with patch("services.dify_builder.dify_port.sessionmaker") as mock_sessionmaker_ctor: - mock_sessionmaker_ctor.return_value = MagicMock(return_value=mock_session) - yield mock_sessionmaker_ctor +def _mock_sessionmaker(mock_session: TrackedPortSession, monkeypatch) -> None: + monkeypatch.setattr("services.dify_builder.dify_port._session_factory", lambda: lambda: mock_session) @pytest.fixture(autouse=True) -def _default_draft_for_native_runs() -> Iterator[None]: +def _default_draft_for_native_runs(mock_session: TrackedPortSession) -> Iterator[None]: # Stream fixtures now also read a real draft to establish launch identity. with patch("services.dify_builder.dify_port.WorkflowService") as service: - service.return_value.get_draft_workflow.return_value = _workflow() + service.return_value.get_draft_workflow.return_value = _workflow( + session=mock_session, + ) yield @@ -162,13 +277,15 @@ def _mock_execution_preparation() -> Iterator[None]: # ---- read_graph -------------------------------------------------------------- -def test_read_graph_returns_graph_and_execution_revision(mock_session: MagicMock) -> None: - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_read_graph_returns_graph_and_execution_revision(mock_session: TrackedPortSession) -> None: + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, app=app) - workflow = _workflow(graph_dict={"nodes": [], "edges": []}) + workflow = _workflow(session=mock_session, graph_dict={"nodes": [], "edges": []}) with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) graph, unique_hash = WorkflowServiceDifyPort().read_graph("app-1", _actor()) @@ -178,8 +295,8 @@ def test_read_graph_returns_graph_and_execution_revision(mock_session: MagicMock mock_ws_cls.return_value.get_draft_workflow.assert_called_once_with(app, session=mock_session) -def test_read_graph_raises_when_no_draft_workflow(mock_session: MagicMock) -> None: - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_read_graph_raises_when_no_draft_workflow(mock_session: TrackedPortSession) -> None: + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, app=app) with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: @@ -192,11 +309,11 @@ def test_read_graph_raises_when_no_draft_workflow(mock_session: MagicMock) -> No # ---- node_outputs -------------------------------------------------------------- -def test_node_outputs_maps_executions_and_falls_back_to_empty_dicts(mock_session: MagicMock) -> None: - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_node_outputs_maps_executions_and_falls_back_to_empty_dicts(mock_session: TrackedPortSession) -> None: + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, app=app) - node_exec_with_io = SimpleNamespace( + node_exec_with_io = _execution( node_id="node-1", node_type="code", title="Code", @@ -205,7 +322,7 @@ def test_node_outputs_maps_executions_and_falls_back_to_empty_dicts(mock_session inputs_dict={"x": 1}, outputs_dict={"y": 2}, ) - node_exec_without_io = SimpleNamespace( + node_exec_without_io = _execution( node_id="node-2", node_type="llm", title="LLM", @@ -241,21 +358,28 @@ def test_node_outputs_maps_executions_and_falls_back_to_empty_dicts(mock_session # ---- apply_repair -------------------------------------------------------------- -def test_apply_repair_syncs_mutated_graph_with_graph_only_and_no_agent_binding_sync(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_apply_repair_syncs_mutated_graph_with_graph_only_and_no_agent_binding_sync( + mock_session: TrackedPortSession, +) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) original_graph: Graph = {"nodes": [{"id": "node-1", "data": {"code": "old"}}], "edges": []} workflow = _workflow( + session=mock_session, graph_dict=original_graph, features_dict={"feature": True}, ) - updated_workflow = _workflow() + updated_workflow = _workflow( + session=mock_session, + ) intents = [MutationIntent(op="set_node_config", args={"node_id": "node-1", "path": "code", "value": "new"})] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.return_value = updated_workflow result = WorkflowServiceDifyPort().apply_repair( @@ -276,8 +400,8 @@ def test_apply_repair_syncs_mutated_graph_with_graph_only_and_no_agent_binding_s assert kwargs["preserve_environment_variables"] is True assert kwargs["environment_variables"] == [] assert kwargs["unique_hash"] == workflow.unique_hash - assert kwargs["account"] is account - assert kwargs["app_model"] is app + assert kwargs["account"].id == account.id + assert kwargs["app_model"].id == app.id assert kwargs["session"] is mock_session mutated_node = next(n for n in kwargs["graph"]["nodes"] if n["id"] == "node-1") @@ -286,12 +410,13 @@ def test_apply_repair_syncs_mutated_graph_with_graph_only_and_no_agent_binding_s assert original_graph["nodes"][0]["data"]["code"] == "old" -def test_apply_repair_ignores_unknown_op_intents(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_apply_repair_ignores_unknown_op_intents(mock_session: TrackedPortSession) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={"nodes": [{"id": "node-1", "data": {}}], "edges": []}, features_dict={}, ) @@ -302,7 +427,9 @@ def test_apply_repair_ignores_unknown_op_intents(mock_session: MagicMock) -> Non intents = [MutationIntent(op="some_future_verb", args={"from": "node-1", "to": "node-2"})] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) result = WorkflowServiceDifyPort().apply_repair( "app-1", _actor(), intents, expected_revision=execution_revision(workflow) @@ -316,19 +443,22 @@ def test_apply_repair_ignores_unknown_op_intents(mock_session: MagicMock) -> Non assert result.new_hash == execution_revision(workflow) -def test_apply_repair_maps_hash_mismatch_to_domain_error(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_apply_repair_maps_hash_mismatch_to_domain_error(mock_session: TrackedPortSession) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={"nodes": [{"id": "node-1", "data": {}}], "edges": []}, features_dict={}, ) intents = [MutationIntent(op="set_node_config", args={"node_id": "node-1", "path": "code", "value": "x"})] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.side_effect = WorkflowHashNotEqualError() with pytest.raises(HashMismatchError): @@ -337,20 +467,25 @@ def test_apply_repair_maps_hash_mismatch_to_domain_error(mock_session: MagicMock ) -def test_apply_repair_dispatches_create_node(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_apply_repair_dispatches_create_node(mock_session: TrackedPortSession) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={"nodes": [], "edges": []}, features_dict={}, ) - updated_workflow = _workflow() + updated_workflow = _workflow( + session=mock_session, + ) intents = [MutationIntent(op="create_node", args={"node_type": "llm", "config": {}})] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.return_value = updated_workflow result = WorkflowServiceDifyPort().apply_repair( @@ -363,19 +498,24 @@ def test_apply_repair_dispatches_create_node(mock_session: MagicMock) -> None: assert kwargs["graph"]["nodes"][0]["data"]["type"] == "llm" -def test_apply_repair_dispatches_connect_and_reports_dangling_ref_as_value_error(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_apply_repair_dispatches_connect_and_reports_dangling_ref_as_value_error( + mock_session: TrackedPortSession, +) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={"nodes": [{"id": "a", "data": {}}], "edges": []}, features_dict={}, ) intents = [MutationIntent(op="connect", args={"from_node": "a", "to_node": "missing"})] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) with pytest.raises(ValueError, match="node not found: missing"): WorkflowServiceDifyPort().apply_repair( @@ -383,23 +523,30 @@ def test_apply_repair_dispatches_connect_and_reports_dangling_ref_as_value_error ) -def test_apply_repair_computes_real_diff_changes_and_scope_for_structural_edit(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_apply_repair_computes_real_diff_changes_and_scope_for_structural_edit( + mock_session: TrackedPortSession, +) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={"nodes": [{"id": "a", "data": {}}], "edges": []}, features_dict={}, ) - updated_workflow = _workflow() + updated_workflow = _workflow( + session=mock_session, + ) intents = [ MutationIntent(op="create_node", args={"node_type": "llm", "config": {}, "node_id": "llm-1"}), MutationIntent(op="connect", args={"from_node": "a", "to_node": "llm-1"}), ] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.return_value = updated_workflow result = WorkflowServiceDifyPort().apply_repair( @@ -412,20 +559,25 @@ def test_apply_repair_computes_real_diff_changes_and_scope_for_structural_edit(m assert result.nodes == [ChangedNode(node_id="llm-1", title="llm-1"), ChangedNode(node_id="a")] -def test_apply_repair_computes_configuration_scope_for_set_node_config(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_apply_repair_computes_configuration_scope_for_set_node_config(mock_session: TrackedPortSession) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={"nodes": [{"id": "node-1", "data": {"code": "old"}}], "edges": []}, features_dict={}, ) - updated_workflow = _workflow() + updated_workflow = _workflow( + session=mock_session, + ) intents = [MutationIntent(op="set_node_config", args={"node_id": "node-1", "path": "code", "value": "new"})] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.return_value = updated_workflow result = WorkflowServiceDifyPort().apply_repair( @@ -437,21 +589,24 @@ def test_apply_repair_computes_configuration_scope_for_set_node_config(mock_sess assert result.nodes == [ChangedNode(node_id="node-1")] -def test_apply_repair_invokes_on_canvas_once_per_applied_intent(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_apply_repair_invokes_on_canvas_once_per_applied_intent(mock_session: TrackedPortSession) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={"nodes": [{"id": "node-1", "data": {}}], "edges": []}, features_dict={}, ) - updated_workflow = _workflow() + updated_workflow = _workflow( + session=mock_session, + ) intents = [MutationIntent(op="set_node_config", args={"node_id": "node-1", "path": "code", "value": "x"})] events: list[Graph] = [] def on_canvas(event: Graph) -> None: - assert mock_session.__exit__.call_count == 2 + assert mock_session.exits == 2 events.append(event) def preflight_before_lock(*_args: object) -> list[str]: @@ -462,7 +617,9 @@ def test_apply_repair_invokes_on_canvas_once_per_applied_intent(mock_session: Ma patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls, patch("services.dify_builder.dify_port.new_preflight_problems", side_effect=preflight_before_lock), ): - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.return_value = updated_workflow WorkflowServiceDifyPort().apply_repair( @@ -483,22 +640,27 @@ def test_apply_repair_invokes_on_canvas_once_per_applied_intent(mock_session: Ma ], ) def test_apply_repair_maps_create_node_by_node_type_to_the_right_add_node_event( - mock_session: MagicMock, node_type: str, expected_event: None | str + mock_session: TrackedPortSession, node_type: str, expected_event: None | str ) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={"nodes": [], "edges": []}, features_dict={}, ) - updated_workflow = _workflow() + updated_workflow = _workflow( + session=mock_session, + ) intents = [MutationIntent(op="create_node", args={"node_type": node_type, "config": {}})] events: list[Graph] = [] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.return_value = updated_workflow if expected_event is None: @@ -519,20 +681,25 @@ def test_apply_repair_maps_create_node_by_node_type_to_the_right_add_node_event( assert events[0]["event"] == expected_event -def test_apply_repair_skips_on_canvas_when_not_provided(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_apply_repair_skips_on_canvas_when_not_provided(mock_session: TrackedPortSession) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={"nodes": [{"id": "node-1", "data": {}}], "edges": []}, features_dict={}, ) - updated_workflow = _workflow() + updated_workflow = _workflow( + session=mock_session, + ) intents = [MutationIntent(op="set_node_config", args={"node_id": "node-1", "path": "code", "value": "x"})] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.return_value = updated_workflow # must not raise even though on_canvas is omitted (default None) @@ -541,12 +708,12 @@ def test_apply_repair_skips_on_canvas_when_not_provided(mock_session: MagicMock) ) -def test_apply_repair_skips_already_present_create_and_connect(mock_session: MagicMock) -> None: +def test_apply_repair_skips_already_present_create_and_connect(mock_session: TrackedPortSession) -> None: """A fix.apply Retry re-applies the SAME intents against an already-mutated draft. create_node for a present id and connect for a present edge must be dropped (no duplicate, no ValueError); set_node_config still applies.""" - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) # Draft already has node "llm_1" and edge start_1 -> llm_1 (the result of a prior apply). @@ -558,6 +725,7 @@ def test_apply_repair_skips_already_present_create_and_connect(mock_session: Mag "edges": [{"id": "e1", "source": "start_1", "target": "llm_1"}], } workflow = _workflow( + session=mock_session, graph_dict=existing_graph, features_dict={}, ) @@ -567,7 +735,9 @@ def test_apply_repair_skips_already_present_create_and_connect(mock_session: Mag ] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) result = WorkflowServiceDifyPort().apply_repair( "app-1", _actor(), intents, expected_revision=execution_revision(workflow) @@ -579,7 +749,7 @@ def test_apply_repair_skips_already_present_create_and_connect(mock_session: Mag mock_ws_cls.return_value.sync_draft_workflow.assert_not_called() -def test_apply_repair_survives_delete_and_recreate_of_same_id_in_one_batch(mock_session: MagicMock) -> None: +def test_apply_repair_survives_delete_and_recreate_of_same_id_in_one_batch(mock_session: TrackedPortSession) -> None: """A from-scratch build (handlers_build.handle_plan_approval) sends delete_node(placeholder_start) + create_node(same id) in ONE batch, to replace the canvas's default placeholder start with the generator's own @@ -588,8 +758,8 @@ def test_apply_repair_survives_delete_and_recreate_of_same_id_in_one_batch(mock_ that would drop the create_node while the delete_node still runs, deleting the start node with no re-create, and the subsequent connect would then raise ValueError('node not found: start').""" - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) # Draft has only the canvas's default placeholder start node "start". @@ -598,10 +768,13 @@ def test_apply_repair_survives_delete_and_recreate_of_same_id_in_one_batch(mock_ "edges": [], } workflow = _workflow( + session=mock_session, graph_dict=existing_graph, features_dict={}, ) - updated_workflow = _workflow() + updated_workflow = _workflow( + session=mock_session, + ) intents = [ MutationIntent(op="delete_node", args={"node_id": "start"}), MutationIntent(op="create_node", args={"node_type": "start", "node_id": "start", "config": {}}), @@ -610,7 +783,9 @@ def test_apply_repair_survives_delete_and_recreate_of_same_id_in_one_batch(mock_ ] with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.return_value = updated_workflow # Must not raise -- the recreate of "start" must survive the filter. @@ -629,21 +804,28 @@ def test_apply_repair_survives_delete_and_recreate_of_same_id_in_one_batch(mock_ @pytest.mark.parametrize("operation", ["apply", "noop", "restore"]) -def test_mutations_recheck_execution_revision_after_locking(mock_session: MagicMock, operation: str) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_mutations_recheck_execution_revision_after_locking(mock_session: TrackedPortSession, operation: str) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) - workflow = _workflow(graph_dict={"nodes": [{"id": "node-1", "data": {"code": "old"}}], "edges": []}) + workflow = _workflow( + session=mock_session, graph_dict={"nodes": [{"id": "node-1", "data": {"code": "old"}}], "edges": []} + ) expected = execution_revision(workflow) def concurrent_edit(_workflow: Workflow, *, with_for_update: bool) -> None: assert with_for_update is True workflow.graph = json.dumps({"nodes": [{"id": "node-1", "data": {"code": "human edit"}}], "edges": []}) + with Session(mock_session.get_bind()) as writer, writer.begin(): + writer.execute(update(Workflow).where(Workflow.id == workflow.id).values(graph=workflow.graph)) + Session.refresh(mock_session, _workflow, with_for_update=with_for_update) mock_session.refresh.side_effect = concurrent_edit events = [] with patch("services.dify_builder.dify_port.WorkflowService") as service: - service.return_value.get_draft_workflow.return_value = workflow + service.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) port = WorkflowServiceDifyPort() if operation == "apply": with pytest.raises(HashMismatchError, match="execution configuration changed"): @@ -669,18 +851,19 @@ def test_mutations_recheck_execution_revision_after_locking(mock_session: MagicM assert workflow.graph_dict["nodes"][0]["data"]["code"] == "human edit" -def test_repair_preserves_layout_committed_while_builder_was_planning(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") +def test_repair_preserves_layout_committed_while_builder_was_planning(mock_session: TrackedPortSession) -> None: + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) workflow = _workflow( + session=mock_session, graph_dict={ "nodes": [ {"id": "node-1", "type": "custom", "data": {"type": "code", "code": "old"}}, {"id": "end", "type": "custom", "data": {"type": "end"}}, ], "edges": [{"id": "node-1-end", "source": "node-1", "target": "end", "type": "custom"}], - } + }, ) expected = execution_revision(workflow) @@ -695,13 +878,18 @@ def test_repair_preserves_layout_committed_while_builder_was_planning(mock_sessi ) graph["viewport"] = {"x": 50, "y": 70, "zoom": 2} workflow.graph = json.dumps(graph) + with Session(mock_session.get_bind()) as writer, writer.begin(): + writer.execute(update(Workflow).where(Workflow.id == workflow.id).values(graph=workflow.graph)) + Session.refresh(mock_session, _workflow, with_for_update=with_for_update) mock_session.refresh.side_effect = move_node with ( patch("services.dify_builder.dify_port.WorkflowService") as service, patch("services.dify_builder.dify_port.notify_workflow_draft_changed") as notify, ): - service.return_value.get_draft_workflow.return_value = workflow + service.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) def sync_graph(**kwargs: object) -> Workflow: assert kwargs["unique_hash"] == workflow.unique_hash @@ -752,16 +940,16 @@ _FINISHED_CHUNK = { } -def _configure_default_identity(session: MagicMock) -> None: +def _configure_default_identity(session: TrackedPortSession) -> None: _configure_session_get( session, - account=SimpleNamespace(id="acc-1"), - app=SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow"), + account=_account(id="acc-1"), + app=_app(id="app-1", tenant_id="tenant-1", mode="workflow"), ) -def _node_exec() -> SimpleNamespace: - return SimpleNamespace( +def _node_exec() -> WorkflowNodeExecutionModel: + return _execution( node_id="node-1", node_type="code", title="Code", @@ -772,11 +960,11 @@ def _node_exec() -> SimpleNamespace: ) -def test_run_draft_streams_node_events_while_the_run_is_still_going(mock_session: MagicMock) -> None: +def test_run_draft_streams_node_events_while_the_run_is_still_going(mock_session: TrackedPortSession) -> None: """In-process streaming emits each event while the workflow is running, without waiting for a second Celery task or replaying completed rows.""" - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) chunks = [ @@ -803,8 +991,8 @@ def test_run_draft_streams_node_events_while_the_run_is_still_going(mock_session ) _, kwargs = mock_ags.generate.call_args - assert kwargs["app_model"] is app - assert kwargs["user"] is account + assert kwargs["app_model"].id == app.id + assert kwargs["user"].id == account.id assert kwargs["args"] == {"inputs": {"q": "hi"}} assert kwargs["invoke_from"] == InvokeFrom.DEBUGGER assert kwargs["streaming"] is True @@ -826,14 +1014,14 @@ def test_run_draft_streams_node_events_while_the_run_is_still_going(mock_session assert run.verification.execution_evidence.safety_outcome == "execution_evidence_unknown" -def test_run_draft_consumes_the_stream_after_the_session_is_closed(mock_session: MagicMock) -> None: +def test_run_draft_consumes_the_stream_after_the_session_is_closed(mock_session: TrackedPortSession) -> None: """``generate`` needs the Session eagerly; iterating the stream does not. Pulling chunks inside the ``with`` block would pin a DB connection for the whole workflow run, so the loop must sit outside it. """ - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) session_open_at_pull: list[bool] = [] @@ -843,7 +1031,7 @@ def test_run_draft_consumes_the_stream_after_the_session_is_closed(mock_session: _sse({"event": "node_started", "data": {"node_id": "node-1", "title": "Code"}}), _sse(_FINISHED_CHUNK), ): - session_open_at_pull.append(not mock_session.__exit__.called) + session_open_at_pull.append(not bool(mock_session.exits)) yield chunk with ( @@ -864,7 +1052,7 @@ def test_run_draft_consumes_the_stream_after_the_session_is_closed(mock_session: assert session_open_at_pull == [False, False] -def test_run_draft_accepts_raw_mapping_chunks(mock_session: MagicMock) -> None: +def test_run_draft_accepts_raw_mapping_chunks(mock_session: TrackedPortSession) -> None: """Defensive: the SSE wrapping layer is not the only shape we tolerate.""" _configure_default_identity(mock_session) seen: list[str] = [] @@ -896,7 +1084,7 @@ def test_run_draft_accepts_raw_mapping_chunks(mock_session: MagicMock) -> None: @pytest.mark.parametrize("wire_format", ["mapping", "sse"]) @pytest.mark.parametrize("payload_factory", [native_workflow_payloads, native_chatflow_payloads]) def test_run_draft_forwards_full_payloads_synchronously_without_reconstruction( - mock_session: MagicMock, wire_format: str, payload_factory: Callable[[], list[Graph]] + mock_session: TrackedPortSession, wire_format: str, payload_factory: Callable[[], list[Graph]] ) -> None: _configure_default_identity(mock_session) payloads = payload_factory() @@ -930,7 +1118,7 @@ def test_run_draft_forwards_full_payloads_synchronously_without_reconstruction( assert summaries # Backend progress and diagnosis still receive node summaries. -def test_run_draft_maps_a_paused_stream_to_a_failed_run(mock_session: MagicMock) -> None: +def test_run_draft_maps_a_paused_stream_to_a_failed_run(mock_session: TrackedPortSession) -> None: """``workflow_paused`` carries ``workflow_run_id`` where blocking carried ``id``.""" _configure_default_identity(mock_session) @@ -985,7 +1173,7 @@ def _truncated_stream() -> list[str]: ] -def test_a_truncated_stream_reports_the_runs_real_status_from_the_database(mock_session: MagicMock) -> None: +def test_a_truncated_stream_reports_the_runs_real_status_from_the_database(mock_session: TrackedPortSession) -> None: """THE regression this guards: a slow run that SUCCEEDED must not read as failed. The stream can end early while the run is still fine. Synthesising "failed" @@ -1001,7 +1189,7 @@ def test_a_truncated_stream_reports_the_runs_real_status_from_the_database(mock_ node_repo = mock_repo_factory.create_api_workflow_node_execution_repository.return_value node_repo.get_executions_by_workflow_run.return_value = [_node_exec()] run_repo = mock_repo_factory.create_api_workflow_run_repository.return_value - run_repo.get_workflow_run_by_id.return_value = SimpleNamespace( + run_repo.get_workflow_run_by_id.return_value = _run( id="run-1", status="succeeded", error=None, elapsed_time=361.0, total_tokens=99 ) @@ -1017,7 +1205,7 @@ def test_a_truncated_stream_reports_the_runs_real_status_from_the_database(mock_ assert run.per_node[0].node_id == "node-1" -def test_a_truncated_stream_reports_a_real_database_failure_as_failed(mock_session: MagicMock) -> None: +def test_a_truncated_stream_reports_a_real_database_failure_as_failed(mock_session: TrackedPortSession) -> None: """The flip side: a genuine failure recovered from the row still reads failed.""" _configure_default_identity(mock_session) @@ -1029,7 +1217,7 @@ def test_a_truncated_stream_reports_a_real_database_failure_as_failed(mock_sessi node_repo = mock_repo_factory.create_api_workflow_node_execution_repository.return_value node_repo.get_executions_by_workflow_run.return_value = list[object]() run_repo = mock_repo_factory.create_api_workflow_run_repository.return_value - run_repo.get_workflow_run_by_id.return_value = SimpleNamespace( + run_repo.get_workflow_run_by_id.return_value = _run( id="run-1", status="failed", error="node blew up", elapsed_time=1.0, total_tokens=1 ) @@ -1039,7 +1227,7 @@ def test_a_truncated_stream_reports_a_real_database_failure_as_failed(mock_sessi assert run.dify_run_id == "run-1" -def test_a_truncated_stream_over_a_still_running_run_is_unknown_not_failed(mock_session: MagicMock) -> None: +def test_a_truncated_stream_over_a_still_running_run_is_unknown_not_failed(mock_session: TrackedPortSession) -> None: """Outcome genuinely unknown -> "running" + a visible reason, never "failed".""" _configure_default_identity(mock_session) @@ -1051,7 +1239,7 @@ def test_a_truncated_stream_over_a_still_running_run_is_unknown_not_failed(mock_ node_repo = mock_repo_factory.create_api_workflow_node_execution_repository.return_value node_repo.get_executions_by_workflow_run.return_value = list[object]() run_repo = mock_repo_factory.create_api_workflow_run_repository.return_value - run_repo.get_workflow_run_by_id.return_value = SimpleNamespace( + run_repo.get_workflow_run_by_id.return_value = _run( id="run-1", status="running", error=None, elapsed_time=300.0, total_tokens=5 ) @@ -1063,7 +1251,7 @@ def test_a_truncated_stream_over_a_still_running_run_is_unknown_not_failed(mock_ assert run.dify_run_id == "run-1" -def test_a_truncated_stream_with_no_run_row_is_unknown_not_failed(mock_session: MagicMock) -> None: +def test_a_truncated_stream_with_no_run_row_is_unknown_not_failed(mock_session: TrackedPortSession) -> None: _configure_default_identity(mock_session) with ( @@ -1082,7 +1270,7 @@ def test_a_truncated_stream_with_no_run_row_is_unknown_not_failed(mock_session: assert run.error == run_mapping.TRUNCATED_STREAM_ERROR -def test_a_stream_of_nothing_but_keep_alives_is_unknown_not_failed(mock_session: MagicMock) -> None: +def test_a_stream_of_nothing_but_keep_alives_is_unknown_not_failed(mock_session: TrackedPortSession) -> None: """No frame ever carried a run id, so there is nothing to look up.""" _configure_default_identity(mock_session) @@ -1106,12 +1294,19 @@ def test_a_stream_of_nothing_but_keep_alives_is_unknown_not_failed(mock_session: assert run.dify_run_id == "" -def test_run_draft_closes_the_stream_when_the_callback_raises(mock_session: MagicMock) -> None: +def test_run_draft_closes_the_stream_when_the_callback_raises(mock_session: TrackedPortSession) -> None: """Streaming only releases the app's rate-limit slot on close().""" _configure_default_identity(mock_session) - response = MagicMock() - response.__iter__ = lambda _self: iter([_sse({"event": "node_started", "data": {"node_id": "n1", "title": "S"}})]) + closed = [] + + def response_stream(): + try: + yield _sse({"event": "node_started", "data": {"node_id": "n1", "title": "S"}}) + finally: + closed.append(True) + + response = response_stream() def _boom(_event: NodeEvent) -> None: raise RuntimeError("callback exploded") @@ -1126,15 +1321,15 @@ def test_run_draft_closes_the_stream_when_the_callback_raises(mock_session: Magi "app-1", _actor(), {}, _boom, session_id="session-1", test_input_id="test-1" ) - response.close.assert_called_once_with() + assert closed == [True] # ---- publish -------------------------------------------------------------- -def test_publish_publishes_workflow_updates_app_workflow_id_and_commits(mock_session: MagicMock) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace( +def test_publish_publishes_workflow_updates_app_workflow_id_and_commits(mock_session: TrackedPortSession) -> None: + account = _account(id="acc-1") + app = _app( id="app-1", tenant_id="tenant-1", workflow_id="old-wf-id", @@ -1142,7 +1337,7 @@ def test_publish_publishes_workflow_updates_app_workflow_id_and_commits(mock_ses updated_at=None, ) _configure_session_get(mock_session, account=account, app=app) - published_workflow = SimpleNamespace(id="new-wf-id", marked_name="", version_number=2) + published_workflow = _published_workflow(id="new-wf-id", marked_name="", version_number=2) with ( patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls, @@ -1152,11 +1347,17 @@ def test_publish_publishes_workflow_updates_app_workflow_id_and_commits(mock_ses # NOT a (workflow, retirement_candidates) tuple. Mock the real signature so # this test catches the tuple-unpack regression that crashed the publish step. mock_ws_cls.return_value.publish_workflow.return_value = published_workflow - mock_naive_utc_now.return_value = "the-now" + mock_naive_utc_now.return_value = datetime(2026, 10, 20, 12) - draft = _workflow() + draft = _workflow( + session=mock_session, + ) draft.id = "locked-draft" - mock_ws_cls.return_value.get_draft_workflow.return_value = draft + mock_session.commit() + mock_session.commit.reset_mock() + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, draft.id + ) result = WorkflowServiceDifyPort().publish( "app-1", _actor(), @@ -1164,15 +1365,19 @@ def test_publish_publishes_workflow_updates_app_workflow_id_and_commits(mock_ses expected_graph_revision=WorkflowServiceDifyPort().graph_revision(dict(draft.graph_dict)), ) - mock_ws_cls.return_value.publish_workflow.assert_called_once_with( - session=mock_session, app_model=app, account=account, expected_draft_id=draft.id - ) - assert app.workflow_id == "new-wf-id" + publish = mock_ws_cls.return_value.publish_workflow + assert publish.call_count == 1 + assert publish.call_args.kwargs["session"] is mock_session + assert publish.call_args.kwargs["app_model"].id == app.id + assert publish.call_args.kwargs["account"].id == account.id + assert publish.call_args.kwargs["expected_draft_id"] == draft.id + stored_app = mock_session.get(App, app.id) + assert stored_app.workflow_id == "new-wf-id" # Publish must also advance the app's audit fields -- mirrors the console idiom at # controllers/console/app/workflow.py (~1315) so publishing via the dify_builder leaves the # same trail as publishing via the console UI. - assert app.updated_by == "acc-1" - assert app.updated_at == "the-now" + assert stored_app.updated_by == "acc-1" + assert stored_app.updated_at == datetime(2026, 10, 20, 12) assert result.version_name == "# 2" assert result.status == "live" mock_session.commit.assert_called_once() @@ -1185,7 +1390,7 @@ def test_workflow_service_dify_port_conforms_to_dify_port_protocol() -> None: assert isinstance(WorkflowServiceDifyPort(), DifyPort) -def test_run_draft_reads_a_blocking_dict_response(mock_session: MagicMock) -> None: +def test_run_draft_reads_a_blocking_dict_response(mock_session: TrackedPortSession) -> None: """AppGenerateService.generate(streaming=False) returns a DICT, not a stream. Iterating it yields its KEYS as strings -- probed live against the real service: @@ -1200,8 +1405,8 @@ def test_run_draft_reads_a_blocking_dict_response(mock_session: MagicMock) -> No user back to build.execution forever. The pre-3838db8aa7 code read ``response["data"]`` directly; the streaming rewrite dropped that path. """ - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) blocking_response = { @@ -1229,12 +1434,12 @@ def test_run_draft_reads_a_blocking_dict_response(mock_session: MagicMock) -> No assert run.per_node == [] -def test_run_draft_does_not_enqueue_a_second_celery_task(mock_session: MagicMock) -> None: +def test_run_draft_does_not_enqueue_a_second_celery_task(mock_session: TrackedPortSession) -> None: """The Builder owns a Celery slot, so it must request in-process streaming. Default streaming dispatches a child task and deadlocks when all slots are occupied by Builder tasks waiting for their children.""" - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) with ( @@ -1279,7 +1484,7 @@ ESQ1_302_ERROR_FRAME = { def _run_stream( chunks: Sequence[str | Graph], *, node_execs: list[object] | None = None, run_row: object | None = None -) -> tuple[Run, list[Mapping[str, object]], MagicMock]: +) -> tuple[Run, list[Mapping[str, object]], TrackedPortSession]: """Exercise the legacy presentation mapper only; no native/sealed execution proof.""" forwarded = [value for chunk in chunks if (value := run_mapping.stream_chunk_as_mapping(chunk)) is not None] final, run_id, error = dict[str, object](), "", None @@ -1297,7 +1502,7 @@ def _run_stream( return run, forwarded, node_repo -def test_the_esq1_302_launch_error_frame_is_a_failed_run_carrying_the_message(mock_session: MagicMock) -> None: +def test_the_esq1_302_launch_error_frame_is_a_failed_run_carrying_the_message(mock_session: TrackedPortSession) -> None: _configure_default_identity(mock_session) run, forwarded, node_repo = _run_stream([_sse(ESQ1_302_ERROR_FRAME)]) @@ -1311,14 +1516,14 @@ def test_the_esq1_302_launch_error_frame_is_a_failed_run_carrying_the_message(mo node_repo.get_executions_by_workflow_run.assert_not_called() # no run id -> nothing to read -def test_an_error_frame_after_workflow_started_keeps_the_run_id_and_its_rows(mock_session: MagicMock) -> None: +def test_an_error_frame_after_workflow_started_keeps_the_run_id_and_its_rows(mock_session: TrackedPortSession) -> None: _configure_default_identity(mock_session) chunks = [ _sse({"event": "workflow_started", "workflow_run_id": "run-9", "data": {"id": "run-9"}}), _sse({"event": "node_started", "workflow_run_id": "run-9", "data": {"node_id": "node-1", "title": "Code"}}), _sse({"event": "error", "workflow_run_id": "run-9", "code": "internal", "status": 500, "message": "boom"}), ] - failed_row = SimpleNamespace( + failed_row = _execution( node_id="node-1", node_type="code", title="Code", status="failed", error="boom", inputs_dict={}, outputs_dict={} ) @@ -1332,7 +1537,9 @@ def test_an_error_frame_after_workflow_started_keeps_the_run_id_and_its_rows(moc node_repo.get_executions_by_workflow_run.assert_called_once_with("tenant-1", "app-1", "run-9") -def test_chatflow_ordering_finished_failed_then_error_prefers_the_terminal_frame(mock_session: MagicMock) -> None: +def test_chatflow_ordering_finished_failed_then_error_prefers_the_terminal_frame( + mock_session: TrackedPortSession, +) -> None: """advanced_chat's pipeline yields workflow_finished(failed) AND THEN an error frame for the same failure. The terminal frame is the authority.""" _configure_default_identity(mock_session) @@ -1372,7 +1579,7 @@ def test_chatflow_ordering_finished_failed_then_error_prefers_the_terminal_frame assert run.tokens == 7 -def test_an_error_frame_never_overrides_a_terminal_success_frame(mock_session: MagicMock) -> None: +def test_an_error_frame_never_overrides_a_terminal_success_frame(mock_session: TrackedPortSession) -> None: _configure_default_identity(mock_session) chunks = [ _sse({"event": "workflow_started", "workflow_run_id": "run-3", "data": {"id": "run-3"}}), @@ -1386,7 +1593,7 @@ def test_an_error_frame_never_overrides_a_terminal_success_frame(mock_session: M assert run.error == "" -def test_no_terminal_frame_and_no_error_frame_is_still_unknown(mock_session: MagicMock) -> None: +def test_no_terminal_frame_and_no_error_frame_is_still_unknown(mock_session: TrackedPortSession) -> None: _configure_default_identity(mock_session) run, _, _ = _run_stream(["event: ping\n\n", "ping"]) @@ -1396,11 +1603,13 @@ def test_no_terminal_frame_and_no_error_frame_is_still_unknown(mock_session: Mag @pytest.mark.parametrize("custom", [{}, {"query": "custom Start query", "file": {"id": "file-1"}}]) -def test_chatflow_is_unsupported_even_with_valid_system_message(mock_session: MagicMock, custom: Graph) -> None: +def test_chatflow_is_unsupported_even_with_valid_system_message( + mock_session: TrackedPortSession, custom: Graph +) -> None: _configure_session_get( mock_session, - account=SimpleNamespace(id="acc-1"), - app=SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="advanced-chat"), + account=_account(id="acc-1"), + app=_app(id="app-1", tenant_id="tenant-1", mode="advanced-chat"), ) inputs = {**custom, "sys.query": "User system message"} with patch("services.dify_builder.dify_port.AppGenerateService") as generate: @@ -1419,11 +1628,13 @@ def test_chatflow_is_unsupported_even_with_valid_system_message(mock_session: Ma @pytest.mark.parametrize( "inputs", [{}, {"sys.query": ""}, {"sys.query": " "}, {"sys.query": 1}, {"query": "only Start query"}] ) -def test_chatflow_rejects_invalid_system_query_before_native_launch(mock_session: MagicMock, inputs: Graph) -> None: +def test_chatflow_rejects_invalid_system_query_before_native_launch( + mock_session: TrackedPortSession, inputs: Graph +) -> None: _configure_session_get( mock_session, - account=SimpleNamespace(id="acc-1"), - app=SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="advanced-chat"), + account=_account(id="acc-1"), + app=_app(id="app-1", tenant_id="tenant-1", mode="advanced-chat"), ) with patch("services.dify_builder.dify_port.AppGenerateService") as generate: run = WorkflowServiceDifyPort().run_draft( @@ -1434,11 +1645,11 @@ def test_chatflow_rejects_invalid_system_query_before_native_launch(mock_session generate.generate.assert_not_called() -def test_workflow_keeps_query_as_an_ordinary_start_input(mock_session: MagicMock) -> None: +def test_workflow_keeps_query_as_an_ordinary_start_input(mock_session: TrackedPortSession) -> None: _configure_session_get( mock_session, - account=SimpleNamespace(id="acc-1"), - app=SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow"), + account=_account(id="acc-1"), + app=_app(id="app-1", tenant_id="tenant-1", mode="workflow"), ) with patch("services.dify_builder.dify_port.AppGenerateService") as generate: generate.generate.return_value = {"data": dict(_FINISHED_CHUNK["data"])} @@ -1454,10 +1665,10 @@ def test_workflow_keeps_query_as_an_ordinary_start_input(mock_session: MagicMock assert generate.generate.call_args.kwargs["args"] == {"inputs": {"query": "custom"}} -def test_get_app_mode_reads_persisted_tenant_scoped_app(mock_session: MagicMock) -> None: - _configure_session_get(mock_session, app=SimpleNamespace(tenant_id="tenant-1", mode="advanced-chat")) +def test_get_app_mode_reads_persisted_tenant_scoped_app(mock_session: TrackedPortSession) -> None: + _configure_session_get(mock_session, app=_app(tenant_id="tenant-1", mode="advanced-chat")) assert WorkflowServiceDifyPort().get_app_mode("app-1", _actor()) == "advanced-chat" - _configure_session_get(mock_session, app=SimpleNamespace(tenant_id="other-tenant", mode="workflow")) + _configure_session_get(mock_session, app=_app(tenant_id="other-tenant", mode="workflow")) from core.dify_builder.errors import NotFoundError with pytest.raises(NotFoundError): @@ -1469,12 +1680,14 @@ def test_get_app_mode_reads_persisted_tenant_scoped_app(mock_session: MagicMock) [("same", False, True), ("different", False, False), (None, False, False), ("same", True, False)], ) def test_verification_binds_actual_native_graph( - mock_session: MagicMock, native_graph: None | str, changed_features: bool, bound: bool + mock_session: TrackedPortSession, native_graph: None | str, changed_features: bool, bound: bool ) -> None: - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow") + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow") _configure_session_get(mock_session, account=account, app=app) - workflow = _workflow(graph_dict={"nodes": [{"id": "a", "data": {"type": "start"}}], "edges": []}) + workflow = _workflow( + session=mock_session, graph_dict={"nodes": [{"id": "a", "data": {"type": "start"}}], "edges": []} + ) native = ( workflow.graph_dict if native_graph == "same" @@ -1497,16 +1710,16 @@ def test_verification_binds_actual_native_graph( patch("services.dify_builder.dify_port.AppGenerateService") as gen, patch("services.dify_builder.dify_port.DifyAPIRepositoryFactory") as factory, ): - ws.return_value.get_draft_workflow.return_value = workflow + ws.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get(Workflow, workflow.id) gen.generate.return_value = stream() node_repo = factory.create_api_workflow_node_execution_repository.return_value node_repo.get_executions_by_workflow_run.return_value = list[object]() repo = factory.create_api_workflow_run_repository.return_value - repo.get_workflow_run_by_id.return_value = SimpleNamespace(graph_dict=native) if native else None + repo.get_workflow_run_by_id.return_value = _run(graph_dict=native) if native else None list(stream()) # apply the deliberate post-execution configuration change run = run_mapping.map_run_result({"id": "native", "status": "succeeded", "outputs": {"result": None}}, []) run = WorkflowServiceDifyPort()._bind_run( - (run, SimpleNamespace(graph_dict=native) if native else None), + (run, _run(graph_dict=native) if native else None), "app-1", _actor(), before, @@ -1519,27 +1732,35 @@ def test_verification_binds_actual_native_graph( @pytest.mark.parametrize("stale", [True, False]) -def test_publish_verification_locked_guard(mock_session: MagicMock, stale: bool) -> None: +def test_publish_verification_locked_guard(mock_session: TrackedPortSession, stale: bool) -> None: from services.dify_builder.revision import executable_graph_revision - account = SimpleNamespace(id="acc-1") - app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="workflow", workflow_id="old") + account = _account(id="acc-1") + app = _app(id="app-1", tenant_id="tenant-1", mode="workflow", workflow_id="old") _configure_session_get(mock_session, account=account, app=app) - draft = _workflow() + draft = _workflow( + session=mock_session, + ) draft.id = "locked-draft" + mock_session.commit() + mock_session.commit.reset_mock() expected = execution_revision(draft) expected_graph = executable_graph_revision(dict(draft.graph_dict)) def refresh(row: Workflow, *, with_for_update: bool) -> None: - assert row is draft + assert row.id == draft.id assert with_for_update if stale: - draft.features = json.dumps({"changed": True}) + with Session(mock_session.get_bind()) as writer, writer.begin(): + writer.execute( + update(Workflow).where(Workflow.id == draft.id).values(_features=json.dumps({"changed": True})) + ) + Session.refresh(mock_session, row, with_for_update=with_for_update) mock_session.refresh.side_effect = refresh with patch("services.dify_builder.dify_port.WorkflowService") as ws: - ws.return_value.get_draft_workflow.return_value = draft - ws.return_value.publish_workflow.return_value = SimpleNamespace(id="published", marked_name="v1") + ws.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get(Workflow, draft.id) + ws.return_value.publish_workflow.return_value = _published_workflow(id="published", marked_name="v1") if stale: with pytest.raises(HashMismatchError): WorkflowServiceDifyPort().publish( @@ -1553,16 +1774,18 @@ def test_publish_verification_locked_guard(mock_session: MagicMock, stale: bool) "app-1", _actor(), expected_revision=expected, expected_graph_revision=expected_graph ) assert ws.return_value.publish_workflow.call_args.kwargs["expected_draft_id"] == "locked-draft" - assert app.workflow_id == "published" + assert mock_session.get(App, app.id).workflow_id == "published" mock_session.commit.assert_called_once() - mock_session.refresh.assert_called_once_with(draft, with_for_update=True) + assert mock_session.refresh.call_count == 1 + assert mock_session.refresh.call_args.args[0].id == draft.id + assert mock_session.refresh.call_args.kwargs == {"with_for_update": True} @pytest.mark.parametrize( ("consent_id", "command_id"), [("consent-1", None), (None, "command-1"), ("", None), ("consent-1", "")] ) def test_confirmed_run_requires_both_server_reserved_identifiers_together( - mock_session: MagicMock, consent_id: str | None, command_id: str | None + mock_session: TrackedPortSession, consent_id: str | None, command_id: str | None ) -> None: with patch("services.dify_builder.dify_port.AppGenerateService.generate") as generate: run = WorkflowServiceDifyPort().run_draft( diff --git a/api/tests/unit_tests/services/dify_builder/test_dify_port_preflight.py b/api/tests/unit_tests/services/dify_builder/test_dify_port_preflight.py index 5dd7ecfeec6..9c2992dc654 100644 --- a/api/tests/unit_tests/services/dify_builder/test_dify_port_preflight.py +++ b/api/tests/unit_tests/services/dify_builder/test_dify_port_preflight.py @@ -9,14 +9,33 @@ from copy import deepcopy from unittest.mock import MagicMock, patch import pytest +from sqlalchemy.orm import Session from core.dify_builder.models import ApplyResult, Graph, MutationIntent +from models.workflow import Workflow from services.dify_builder.dify_port import WorkflowServiceDifyPort from services.dify_builder.errors import PreflightError from services.dify_builder.revision import execution_revision -from tests.unit_tests.services.dify_builder.test_dify_port import _actor, _configure_session_get, _workflow +from tests.unit_tests.services.dify_builder.test_dify_port import ( + PortDatabase, + TrackedPortSession, + _account, + _actor, + _app, + _configure_session_get, + _workflow, +) from tests.unit_tests.services.dify_builder.test_native_http_output_contract import http_graph + +@pytest.fixture +def mock_session(sqlite_engine, mocker) -> Iterator[TrackedPortSession]: + with TrackedPortSession(sqlite_engine, expire_on_commit=False) as session: + for name in ("get", "refresh", "commit"): + mocker.spy(session, name) + yield session + + _START: Graph = {"id": "node1", "type": "custom", "data": {"type": "start", "title": "Start", "variables": []}} # ``comparison_operator`` must be one of graphon's SupportedComparisonOperator # literals; "equals" is not, and no normalizer heals it -- it reaches the @@ -37,32 +56,25 @@ _BROKEN_IF_ELSE_CONFIG = { } -@pytest.fixture -def mock_session() -> MagicMock: - session = MagicMock() - session.__enter__.return_value = session - session.__exit__.return_value = False - return session - - @pytest.fixture(autouse=True) -def _mock_db() -> Iterator[None]: - with patch("services.dify_builder.dify_port.db"), patch("services.dify_builder.dify_port.set_login_user"): +def _mock_db(sqlite_engine, monkeypatch) -> Iterator[None]: + monkeypatch.setattr("services.dify_builder.dify_port.db", PortDatabase(sqlite_engine)) + with patch("services.dify_builder.dify_port.set_login_user"): yield @pytest.fixture(autouse=True) -def _mock_sessionmaker(mock_session: MagicMock) -> Iterator[MagicMock]: - with patch("services.dify_builder.dify_port.sessionmaker") as ctor: - ctor.return_value = MagicMock(return_value=mock_session) - yield ctor +def _mock_sessionmaker(mock_session: Session, monkeypatch) -> None: + monkeypatch.setattr("services.dify_builder.dify_port._session_factory", lambda: lambda: mock_session) -def _apply(mock_session: MagicMock, graph_dict: Graph, intents: list[MutationIntent]) -> tuple[ApplyResult, MagicMock]: - _configure_session_get(mock_session, account=MagicMock(id="acc-1"), app=MagicMock(id="app-1", tenant_id="tenant-1")) - workflow = _workflow(graph_dict=graph_dict, features_dict={}) +def _apply(mock_session: Session, graph_dict: Graph, intents: list[MutationIntent]) -> tuple[ApplyResult, MagicMock]: + _configure_session_get(mock_session, account=_account(), app=_app()) + workflow = _workflow(session=mock_session, graph_dict=graph_dict, features_dict={}) with patch("services.dify_builder.dify_port.WorkflowService") as mock_ws_cls: - mock_ws_cls.return_value.get_draft_workflow.return_value = workflow + mock_ws_cls.return_value.get_draft_workflow.side_effect = lambda _app, *, session: session.get( + Workflow, workflow.id + ) mock_ws_cls.return_value.sync_draft_workflow.return_value = _workflow(graph_dict=graph_dict) result = WorkflowServiceDifyPort().apply_repair( "app-1", _actor(), intents, expected_revision=execution_revision(workflow) @@ -70,7 +82,7 @@ def _apply(mock_session: MagicMock, graph_dict: Graph, intents: list[MutationInt return result, mock_ws_cls.return_value.sync_draft_workflow -def test_apply_repair_refuses_numeric_http_status_string_operator_before_sync(mock_session: MagicMock) -> None: +def test_apply_repair_refuses_numeric_http_status_string_operator_before_sync(mock_session: Session) -> None: graph = http_graph("=", "200") original = deepcopy(graph) intent = MutationIntent( @@ -90,7 +102,7 @@ def test_apply_repair_refuses_numeric_http_status_string_operator_before_sync(mo assert graph == original -def test_apply_repair_preserves_unrelated_existing_numeric_http_operator_exemption(mock_session: MagicMock) -> None: +def test_apply_repair_preserves_unrelated_existing_numeric_http_operator_exemption(mock_session: Session) -> None: graph: Graph = dict(http_graph()) intent = MutationIntent(op="set_node_config", args={"node_id": "start", "path": "title", "value": "Begin"}) @@ -102,7 +114,7 @@ def test_apply_repair_preserves_unrelated_existing_numeric_http_operator_exempti ) -def test_a_repair_that_would_not_start_is_rejected_before_it_is_written(mock_session: MagicMock) -> None: +def test_a_repair_that_would_not_start_is_rejected_before_it_is_written(mock_session: Session) -> None: intents = [ MutationIntent( op="create_node", args={"node_type": "if-else", "node_id": "node2", "config": _BROKEN_IF_ELSE_CONFIG} @@ -126,7 +138,7 @@ def test_a_preflight_rejection_is_the_core_draft_would_not_start_error() -> None assert issubclass(PreflightError, ValueError) -def test_a_pre_existing_broken_node_the_repair_does_not_touch_does_not_veto_it(mock_session: MagicMock) -> None: +def test_a_pre_existing_broken_node_the_repair_does_not_touch_does_not_veto_it(mock_session: Session) -> None: broken = {"id": "node2", "type": "custom", "data": {"type": "if-else", **_BROKEN_IF_ELSE_CONFIG}} fix_elsewhere = [MutationIntent(op="set_node_config", args={"node_id": "node1", "path": "title", "value": "Begin"})] @@ -136,7 +148,7 @@ def test_a_pre_existing_broken_node_the_repair_does_not_touch_does_not_veto_it(m sync.assert_called_once() -def test_a_repair_that_heals_the_broken_node_is_written(mock_session: MagicMock) -> None: +def test_a_repair_that_heals_the_broken_node_is_written(mock_session: Session) -> None: broken = {"id": "node2", "type": "custom", "data": {"type": "if-else", **_BROKEN_IF_ELSE_CONFIG}} healed_cases = json.loads(json.dumps(_BROKEN_IF_ELSE_CONFIG["cases"])) healed_cases[0]["conditions"][0]["comparison_operator"] = "=" @@ -149,7 +161,7 @@ def test_a_repair_that_heals_the_broken_node_is_written(mock_session: MagicMock) assert kwargs["graph"]["nodes"][1]["data"]["cases"][0]["conditions"][0]["comparison_operator"] == "=" -def test_apply_repair_coerces_a_numeric_condition_value_before_the_preflight(mock_session: MagicMock) -> None: +def test_apply_repair_coerces_a_numeric_condition_value_before_the_preflight(mock_session: Session) -> None: """The ESQ1-285 flip-flop: a Fix/Edit repair writes ``"value": 60`` (the generator never produced this intent, so the shared postprocess never saw it). The chokepoint normalizes it, and the preflight then passes.""" @@ -174,7 +186,7 @@ def test_apply_repair_coerces_a_numeric_condition_value_before_the_preflight(moc assert kwargs["graph"]["nodes"][1]["data"]["cases"][0]["conditions"][0]["value"] == "60" -def test_apply_repair_canonicalizes_an_ascii_comparison_operator_before_the_preflight(mock_session: MagicMock) -> None: +def test_apply_repair_canonicalizes_an_ascii_comparison_operator_before_the_preflight(mock_session: Session) -> None: """F4 cause (a): the Edit LLM rewrote an if-else's ``cases`` with ``">="``, which graphon's ``SupportedComparisonOperator`` refuses -- so the whole batch was rejected and the session parked at ``edit.plan_approval``. @@ -201,7 +213,7 @@ def test_apply_repair_canonicalizes_an_ascii_comparison_operator_before_the_pref assert kwargs["graph"]["nodes"][1]["data"]["cases"][0]["conditions"][0]["comparison_operator"] == "≥" -def test_apply_repair_still_rejects_an_ambiguous_equality_operator(mock_session: MagicMock) -> None: +def test_apply_repair_still_rejects_an_ambiguous_equality_operator(mock_session: Session) -> None: """``==`` is ambiguous between graphon's number equality (``=``) and its string equality (``is``), exactly like the word form ``equals``. Healing it would silently pick one comparison; it must keep failing loudly instead.""" @@ -224,7 +236,7 @@ def test_apply_repair_still_rejects_an_ambiguous_equality_operator(mock_session: assert "comparison_operator" in str(excinfo.value) -def test_apply_repair_fills_http_body_item_types_before_the_preflight(mock_session: MagicMock) -> None: +def test_apply_repair_fills_http_body_item_types_before_the_preflight(mock_session: Session) -> None: body = {"type": "json", "data": [{"key": "", "value": '{"a": 1}'}]} # one item, no ``type`` http: Graph = { "id": "node4", @@ -249,7 +261,7 @@ def test_apply_repair_fills_http_body_item_types_before_the_preflight(mock_sessi assert kwargs["graph"]["nodes"][1]["data"]["body"]["data"][0]["type"] == "text" -def test_apply_repair_counts_a_node_the_chokepoint_normalizer_heals_as_changed(mock_session: MagicMock) -> None: +def test_apply_repair_counts_a_node_the_chokepoint_normalizer_heals_as_changed(mock_session: Session) -> None: """The chokepoint normalizers scan every node in the graph, not just the ones an intent named: a repair that only touches node1's title can still heal an UNTOUCHED node's numeric condition value as a side effect. @@ -291,7 +303,7 @@ def _http_create_intent(node_id: str, authorization: Graph) -> MutationIntent: return MutationIntent(op="create_node", args={"node_type": "http-request", "node_id": node_id, "config": config}) -def test_apply_repair_heals_an_authorization_without_type_and_writes(mock_session: MagicMock) -> None: +def test_apply_repair_heals_an_authorization_without_type_and_writes(mock_session: Session) -> None: """The live E2E crash: this shape made the preflight raise KeyError out of ``apply_repair`` and failed the Build session. The chokepoint normalizer now fills the type, so the repair is written.""" @@ -305,7 +317,7 @@ def test_apply_repair_heals_an_authorization_without_type_and_writes(mock_sessio assert written["data"]["authorization"]["type"] == "api-key" -def test_apply_repair_unwraps_a_nested_parameter_extractor_query_before_the_preflight(mock_session: MagicMock) -> None: +def test_apply_repair_unwraps_a_nested_parameter_extractor_query_before_the_preflight(mock_session: Session) -> None: """Blocker A: a Fix/Edit repair writes a parameter-extractor ``query`` as an array of selector arrays (``[["sys", "query"]]``); graphon's ``ParameterExtractorNodeData.query`` is ``list[str]``, one selector, and @@ -332,7 +344,7 @@ def test_apply_repair_unwraps_a_nested_parameter_extractor_query_before_the_pref assert written["data"]["query"] == ["sys", "query"] -def test_apply_repair_rejects_a_crashing_node_it_cannot_heal_instead_of_raising_it(mock_session: MagicMock) -> None: +def test_apply_repair_rejects_a_crashing_node_it_cannot_heal_instead_of_raising_it(mock_session: Session) -> None: """A shape no normalizer heals (a present but invalid ``type`` also makes graphon raise KeyError) is a preflight PROBLEM -- a PreflightError the handlers already catch -- never a KeyError that ends the session.""" @@ -342,7 +354,7 @@ def test_apply_repair_rejects_a_crashing_node_it_cannot_heal_instead_of_raising_ _apply(mock_session, {"nodes": [_START], "edges": []}, intents) -def test_apply_repair_writes_a_template_transform_the_llm_created_without_variables(mock_session: MagicMock) -> None: +def test_apply_repair_writes_a_template_transform_the_llm_created_without_variables(mock_session: Session) -> None: """F4 cause (b): the Edit LLM created a ``template-transform`` carrying only ``title`` and ``template``. ``TemplateTransformNodeData.variables`` is required with no default, so the preflight refused the WHOLE batch and the @@ -370,7 +382,7 @@ def test_apply_repair_writes_a_template_transform_the_llm_created_without_variab assert written["data"]["template"] == "excellent" -def test_a_repair_that_leaves_its_own_culprit_invalid_is_refused_not_applied(mock_session: MagicMock) -> None: +def test_a_repair_that_leaves_its_own_culprit_invalid_is_refused_not_applied(mock_session: Session) -> None: """The silent false success the location key cannot see, and the reason a node the batch WROTE gets no exemption at all. @@ -393,7 +405,7 @@ def test_a_repair_that_leaves_its_own_culprit_invalid_is_refused_not_applied(moc assert "klingon" in str(excinfo.value) # names the value it just wrote, not the one it replaced -def test_a_repair_that_only_half_fixes_the_node_it_wrote_is_refused_too(mock_session: MagicMock) -> None: +def test_a_repair_that_only_half_fixes_the_node_it_wrote_is_refused_too(mock_session: Session) -> None: """Same rule, the other shape: the repair genuinely improves ``node2`` (it adds the missing ``varType``) but leaves the operator invalid. Improved is not startable, and writing it would still produce a draft that dies at @@ -407,7 +419,7 @@ def test_a_repair_that_only_half_fixes_the_node_it_wrote_is_refused_too(mock_ses _apply(mock_session, {"nodes": [_START, broken], "edges": []}, repair) -def test_a_node_the_healer_touched_is_still_not_a_node_this_batch_wrote(mock_session: MagicMock) -> None: +def test_a_node_the_healer_touched_is_still_not_a_node_this_batch_wrote(mock_session: Session) -> None: """``heal_nodes_for_preflight`` scans EVERY node, so its returned ids are folded into ``changed_nodes`` for the change set. They must NOT widen the set the preflight holds answerable: a node the healer merely normalized is not a @@ -439,7 +451,7 @@ def test_a_node_the_healer_touched_is_still_not_a_node_this_batch_wrote(mock_ses sync.assert_called_once() # ...and its untouched, unhealed defect still did not veto the write -def test_wiring_an_already_broken_node_is_still_written(mock_session: MagicMock) -> None: +def test_wiring_an_already_broken_node_is_still_written(mock_session: Session) -> None: """The port's own copy of the ``connect`` exclusion. ``node2`` is refused as it stands and this batch only adds an EDGE to it -- ``apply_connect`` reports both endpoints as changed (right for the change set), but an edge cannot @@ -474,7 +486,7 @@ def test_wiring_an_already_broken_node_is_still_written(mock_session: MagicMock) ) @pytest.mark.parametrize("op", ["create_node", "insert_between"]) def test_direct_write_refuses_first_phase_additions_before_sync( - mock_session: MagicMock, node_type: str, config: Graph, op: str + mock_session: Session, node_type: str, config: Graph, op: str ) -> None: graph: Graph = { "nodes": [_START, {"id": "end", "type": "custom", "data": {"type": "end", "title": "End", "outputs": []}}], @@ -513,7 +525,7 @@ def test_direct_write_refuses_first_phase_additions_before_sync( ("agent", {"version": "2", "agent_node_kind": "dify_agent"}), ], ) -def test_direct_write_refuses_disguised_type_conversion(mock_session: MagicMock, node_type: str, config: Graph) -> None: +def test_direct_write_refuses_disguised_type_conversion(mock_session: Session, node_type: str, config: Graph) -> None: graph: Graph = { "nodes": [ { @@ -541,7 +553,7 @@ def test_direct_write_refuses_disguised_type_conversion(mock_session: MagicMock, sync.assert_not_called() -def test_direct_write_refuses_retrieval_binding_change(mock_session: MagicMock) -> None: +def test_direct_write_refuses_retrieval_binding_change(mock_session: Session) -> None: graph: Graph = { "nodes": [ { @@ -566,7 +578,7 @@ def test_direct_write_refuses_retrieval_binding_change(mock_session: MagicMock) sync.assert_not_called() -def test_direct_write_refuses_legacy_agent_to_native_new_agent_conversion(mock_session: MagicMock) -> None: +def test_direct_write_refuses_legacy_agent_to_native_new_agent_conversion(mock_session: Session) -> None: graph: Graph = {"nodes": [{"id": "a", "type": "custom", "data": {"type": "agent", "title": "Legacy"}}], "edges": []} intents = [ MutationIntent(op="set_node_config", args={"node_id": "a", "path": "version", "value": "2"}), @@ -582,7 +594,7 @@ def test_direct_write_refuses_legacy_agent_to_native_new_agent_conversion(mock_s @pytest.mark.parametrize("node_id", ["node1", "agent", "hitl"]) def test_direct_write_retains_historical_nodes_and_allows_permitted_repairs( - mock_session: MagicMock, node_id: str + mock_session: Session, node_id: str ) -> None: from tests.unit_tests.services.dify_builder.test_preflight import _human_input_graph @@ -613,7 +625,7 @@ def test_direct_write_retains_historical_nodes_and_allows_permitted_repairs( @pytest.mark.parametrize("node_type", ["knowledge-retrieval", "human-input", "agent"]) -def test_direct_write_preserves_already_present_create_replay(mock_session: MagicMock, node_type: str) -> None: +def test_direct_write_preserves_already_present_create_replay(mock_session: Session, node_type: str) -> None: graph: Graph = {"nodes": [{"id": "existing", "type": "custom", "data": {"type": node_type}}], "edges": []} intent = MutationIntent(op="create_node", args={"node_type": node_type, "node_id": "existing", "config": {}}) @@ -623,7 +635,7 @@ def test_direct_write_preserves_already_present_create_replay(mock_session: Magi sync.assert_not_called() -def test_direct_write_allows_edge_only_wiring_without_retrieval_config_changes(mock_session: MagicMock) -> None: +def test_direct_write_allows_edge_only_wiring_without_retrieval_config_changes(mock_session: Session) -> None: retrieval = { "id": "r", "type": "custom", diff --git a/api/tests/unit_tests/services/dify_builder/test_draft_tool_setup.py b/api/tests/unit_tests/services/dify_builder/test_draft_tool_setup.py index 3f5079e59f1..69a26661e02 100644 --- a/api/tests/unit_tests/services/dify_builder/test_draft_tool_setup.py +++ b/api/tests/unit_tests/services/dify_builder/test_draft_tool_setup.py @@ -2,7 +2,6 @@ import json from dataclasses import asdict -from types import SimpleNamespace import pytest from sqlalchemy import text @@ -11,7 +10,8 @@ from sqlalchemy.orm import Session as OrmSession from core.app.app_config.entities import ModelConfig from core.dify_builder.models import BuildNodesResult, Graph -from graphon.model_runtime.entities.message_entities import PromptMessage +from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage from models.enums import PermissionEnum from services.dify_builder.agent import build from services.dify_builder.agent.llm_agent import LlmBuilderAgent @@ -34,8 +34,8 @@ from tests.unit_tests.services.dify_builder.installed_tool_inventory import ( draft_tools = installed_draft_tools -class GeneratedPlanModel: - """Respond at the LLM boundary; retain the actual shared generator pipeline.""" +class GeneratedPlanResponses: + """Hold scripted provider responses and the observed generation requests.""" def __init__( self, *, description: str = "User description: notify the support channel.", credential_id: object | None = None @@ -44,7 +44,7 @@ class GeneratedPlanModel: self.credential_id = credential_id self.requests = [] - def invoke_llm(self, *, prompt_messages: list[PromptMessage], **kwargs: object) -> SimpleNamespace: # noqa: ARG002 + def respond(self, *, prompt_messages: list[PromptMessage], **kwargs: object) -> LLMResult: # noqa: ARG002 self.requests.append(prompt_messages) prompt = prompt_messages[1].content assert isinstance(prompt, str) @@ -85,17 +85,21 @@ class GeneratedPlanModel: ], "edges": [{"source": "s", "target": "t"}, {"source": "t", "target": "e"}], } - return SimpleNamespace(message=SimpleNamespace(get_text_content=lambda: json.dumps(payload)), usage=None) + return LLMResult( + model="x", message=AssistantPromptMessage(content=json.dumps(payload)), usage=LLMUsage.empty_usage() + ) -def generate_draft(monkeypatch: pytest.MonkeyPatch, model: object) -> BuildNodesResult: +def generate_draft(monkeypatch: pytest.MonkeyPatch, responses: GeneratedPlanResponses) -> BuildNodesResult: config = ModelConfig.model_validate({"provider": "anthropic", "name": "x", "mode": "chat"}) monkeypatch.setattr(build, "_generator_model_config", lambda *a, **k: config) # noqa: ARG005 - monkeypatch.setattr( - ModelManager, - "for_tenant", - lambda **kw: SimpleNamespace(get_model_instance=lambda **kw: model), # noqa: ARG005 - ) + from tests.unit_tests.core.model_fixtures import make_model_instance + + model = make_model_instance(provider=config.provider, model=config.name) + monkeypatch.setattr(model, "invoke_llm", responses.respond) + manager = ModelManager.for_tenant(tenant_id=TENANT_ID) + monkeypatch.setattr(manager, "get_model_instance", lambda **_kwargs: model) + monkeypatch.setattr(ModelManager, "for_tenant", lambda **_kwargs: manager) return build.build_nodes( TENANT_ID, {}, @@ -114,7 +118,7 @@ def test_generated_unauthenticated_tool_has_real_schema_topology_outputs_and_vis draft_tools: DraftToolInventory, monkeypatch: pytest.MonkeyPatch, ) -> None: - result = generate_draft(monkeypatch, GeneratedPlanModel()) + result = generate_draft(monkeypatch, GeneratedPlanResponses()) config = tool_config(result) assert (config["provider_id"], config["provider_type"], config["tool_name"]) == (AUTH_PROVIDER, "builtin", "post") assert config["plugin_id"] == "acme/huddle" @@ -143,7 +147,7 @@ def test_generated_unauthenticated_tool_has_real_schema_topology_outputs_and_vis def test_repeated_builder_pass_does_not_duplicate_setup_note_or_overwrite_user_edits( draft_tools: DraftToolInventory, monkeypatch: pytest.MonkeyPatch ) -> None: - model = GeneratedPlanModel() + model = GeneratedPlanResponses() first = tool_config(generate_draft(monkeypatch, model))["desc"] model.description = first second = tool_config(generate_draft(monkeypatch, model))["desc"] @@ -169,7 +173,7 @@ def test_generated_private_credential_is_removed_without_discarding_the_installe user_id=ACCOUNT_B, visibility=PermissionEnum.ONLY_ME, ) - model = GeneratedPlanModel(credential_id=private.id) + model = GeneratedPlanResponses(credential_id=private.id) result = generate_draft(monkeypatch, model) config = tool_config(result) assert not config.get("credential_id") @@ -192,7 +196,7 @@ def test_credential_query_failure_still_binds_verified_installation_with_a_verif None, TENANT_ID, ["tool node posts to Huddle"], [f"{AUTH_PROVIDER}/post"], account_id=ACCOUNT_A ) assert f"[{AUTH_PROVIDER}/post]" in "\n".join(bound) - config = tool_config(generate_draft(monkeypatch, GeneratedPlanModel())) + config = tool_config(generate_draft(monkeypatch, GeneratedPlanResponses())) assert "verif" in config["desc"].lower() assert draft_tools.forbidden == [] @@ -246,7 +250,7 @@ def test_generated_configured_tool_preserves_authorized_selection_and_user_descr provider=AUTH_PROVIDER, user_id=ACCOUNT_A, ) - config = tool_config(generate_draft(monkeypatch, GeneratedPlanModel(credential_id=credential.id))) + config = tool_config(generate_draft(monkeypatch, GeneratedPlanResponses(credential_id=credential.id))) assert config["credential_id"] == credential.id assert config["desc"] == "User description: notify the support channel." assert draft_tools.forbidden == [] diff --git a/api/tests/unit_tests/services/dify_builder/test_edit_plan_lifecycle.py b/api/tests/unit_tests/services/dify_builder/test_edit_plan_lifecycle.py index c643ed766b6..3f25a130160 100644 --- a/api/tests/unit_tests/services/dify_builder/test_edit_plan_lifecycle.py +++ b/api/tests/unit_tests/services/dify_builder/test_edit_plan_lifecycle.py @@ -1,6 +1,7 @@ """A failed replacement plan keeps the durable plan and an actionable gate.""" from copy import deepcopy +from dataclasses import asdict from datetime import datetime from unittest.mock import patch @@ -90,6 +91,7 @@ def test_failed_replanning_preserves_snapshot_and_recovers_on_retry( assert failed.current_state == waiting_state assert create_checkpoint.call_count == 0 _, stored = repo.get_session(session.id) + stored_fields, preserved_fields = asdict(stored), asdict(preserved) for key in ( "plan_title", "plan_input_summary", @@ -104,7 +106,7 @@ def test_failed_replanning_preserves_snapshot_and_recovers_on_retry( "unknown_outcome_count", "staged_repair", ): - assert getattr(stored, key) == getattr(preserved, key), key + assert stored_fields[key] == preserved_fields[key], key items = repo.list_conversation(session.id) assert [item for item in items if item.kind == "plan"] == [original_card] assistant = next(item for item in reversed(items) if item.kind == "assistant_turn") diff --git a/api/tests/unit_tests/services/dify_builder/test_effect_account_bindings.py b/api/tests/unit_tests/services/dify_builder/test_effect_account_bindings.py index d3269a34972..89615d78971 100644 --- a/api/tests/unit_tests/services/dify_builder/test_effect_account_bindings.py +++ b/api/tests/unit_tests/services/dify_builder/test_effect_account_bindings.py @@ -1,14 +1,40 @@ """Resolved credential identity, rather than its display alias, binds disclosure.""" from datetime import datetime -from types import SimpleNamespace -from unittest.mock import MagicMock + +from sqlalchemy import update +from sqlalchemy.orm import Session from graphon.variables import SecretVariable +from models.tools import BuiltinToolProvider +from models.workflow import Workflow from services.dify_builder.dify_port import _effect_scope_binding, _tool_account_metadata -def test_default_account_binding_changes_when_same_named_default_changes(): +def _credential( + *, + id: str, + provider: str, + name: str, + is_default: bool, + created_at: datetime, + updated_at: datetime, + encrypted_credentials: str | None = None, +) -> BuiltinToolProvider: + row = BuiltinToolProvider( + tenant_id="tenant", + user_id="actor", + provider=provider, + name=name, + is_default=is_default, + encrypted_credentials=encrypted_credentials, + ) + row.id = id + row.created_at, row.updated_at = created_at, updated_at + return row + + +def test_default_account_binding_changes_when_same_named_default_changes(sqlite_session: Session): graph = { "nodes": [ { @@ -21,7 +47,7 @@ def test_default_account_binding_changes_when_same_named_default_changes(): } ] } - first = SimpleNamespace( + first = _credential( id="credential-1", encrypted_credentials=None, provider="example/send", @@ -30,7 +56,7 @@ def test_default_account_binding_changes_when_same_named_default_changes(): created_at=datetime(2026, 1, 1), updated_at=datetime(2026, 1, 1), ) - second = SimpleNamespace( + second = _credential( id="credential-2", encrypted_credentials=None, provider="example/send", @@ -39,16 +65,23 @@ def test_default_account_binding_changes_when_same_named_default_changes(): created_at=datetime(2026, 1, 2), updated_at=datetime(2026, 1, 2), ) - session = MagicMock() - session.scalars.return_value.all.return_value = [first, second] + session = sqlite_session + session.add(first) + session.flush() names, before = _tool_account_metadata(session, "tenant", graph) first.is_default, second.is_default = False, True + # Display aliases are unique per provider; replace the old account with + # its same-named successor instead of inventing impossible duplicate rows. + session.delete(first) + session.flush() + session.add(second) + session.flush() after_names, after = _tool_account_metadata(session, "tenant", graph) assert names == after_names == {"send": "Test account"} assert before["send"] != after["send"] -def test_explicit_account_version_changes_binding_without_exposing_credentials(): +def test_explicit_account_version_changes_binding_without_exposing_credentials(sqlite_session: Session): graph = { "nodes": [ { @@ -62,7 +95,7 @@ def test_explicit_account_version_changes_binding_without_exposing_credentials() } ] } - account = SimpleNamespace( + account = _credential( id="credential-1", encrypted_credentials=None, provider="example/send", @@ -71,8 +104,9 @@ def test_explicit_account_version_changes_binding_without_exposing_credentials() created_at=datetime(2026, 1, 1), updated_at=datetime(2026, 1, 1), ) - session = MagicMock() - session.scalars.return_value.all.return_value = [account] + session = sqlite_session + session.add(account) + session.flush() _, before = _tool_account_metadata(session, "tenant", graph) account.updated_at = datetime(2026, 2, 1) _, after = _tool_account_metadata(session, "tenant", graph) @@ -81,7 +115,7 @@ def test_explicit_account_version_changes_binding_without_exposing_credentials() assert "credential-1" not in before["send"] -def test_legacy_native_provider_row_binds_canonical_plugin_account(): +def test_legacy_native_provider_row_binds_canonical_plugin_account(sqlite_session: Session): graph = { "nodes": [ { @@ -94,7 +128,7 @@ def test_legacy_native_provider_row_binds_canonical_plugin_account(): } ] } - account = SimpleNamespace( + account = _credential( id="credential-1", encrypted_credentials=None, provider="slack", @@ -103,14 +137,15 @@ def test_legacy_native_provider_row_binds_canonical_plugin_account(): created_at=datetime(2026, 1, 1), updated_at=datetime(2026, 1, 1), ) - session = MagicMock() - session.scalars.return_value.all.return_value = [account] + session = sqlite_session + session.add(account) + session.flush() names, bindings = _tool_account_metadata(session, "tenant", graph) assert names == {"send": "Test account"} assert len(bindings["send"]) == 64 -def test_same_timestamp_credential_rotation_changes_binding(): +def test_same_timestamp_credential_rotation_changes_binding(sqlite_session: Session): graph = { "nodes": [ { @@ -123,7 +158,7 @@ def test_same_timestamp_credential_rotation_changes_binding(): } ] } - account = SimpleNamespace( + account = _credential( id="credential-1", provider="example/send", name="Test account", @@ -132,24 +167,28 @@ def test_same_timestamp_credential_rotation_changes_binding(): updated_at=datetime(2026, 1, 1), encrypted_credentials="synthetic-ciphertext-before", ) - session = MagicMock() - session.scalars.return_value.all.return_value = [account] + session = sqlite_session + session.add(account) + session.flush() _, before = _tool_account_metadata(session, "tenant", graph) - account.encrypted_credentials = "synthetic-ciphertext-after" + session.execute( + update(BuiltinToolProvider) + .where(BuiltinToolProvider.id == account.id) + .values(encrypted_credentials="synthetic-ciphertext-after", updated_at=datetime(2026, 1, 1)) + ) + session.expire(account) _, after = _tool_account_metadata(session, "tenant", graph) assert before != after -def test_scope_binding_uses_semantic_variables_instead_of_reencrypted_storage(): +def test_scope_binding_uses_semantic_variables_instead_of_reencrypted_storage(mocker): variable = SecretVariable(name="DESTINATION", value="synthetic-value") - workflow = SimpleNamespace( - _environment_variables="synthetic-ciphertext-before", - _conversation_variables="{}", - environment_variables=[variable], - conversation_variables=[], + workflow = Workflow( + tenant_id="tenant", _environment_variables="synthetic-ciphertext-before", _conversation_variables="{}" ) + semantic_variables = mocker.patch("models.workflow.load_environment_variables", return_value=[variable]) before = _effect_scope_binding(workflow) workflow._environment_variables = "synthetic-ciphertext-after" assert _effect_scope_binding(workflow) == before - workflow.environment_variables = [variable.model_copy(update={"value": "changed-synthetic-value"})] + semantic_variables.return_value = [variable.model_copy(update={"value": "changed-synthetic-value"})] assert _effect_scope_binding(workflow) != before diff --git a/api/tests/unit_tests/services/dify_builder/test_effect_mock.py b/api/tests/unit_tests/services/dify_builder/test_effect_mock.py index e04426bcab6..2ead11ee22c 100644 --- a/api/tests/unit_tests/services/dify_builder/test_effect_mock.py +++ b/api/tests/unit_tests/services/dify_builder/test_effect_mock.py @@ -1,6 +1,5 @@ import pytest -from configs import dify_config from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context from graphon.engine_events import NodeRunStartedEvent, NodeRunSucceededEvent from graphon.enums import BuiltinNodeTypes, ErrorStrategy, WorkflowNodeExecutionStatus @@ -12,6 +11,7 @@ from graphon.runtime import InitParams, RuntimeState, VariablePool from services.dify_builder.effect_mock import MOCKED_PROCESS_KEY, mocked_node_class from services.workflow_runtime.agent_node import DifyAgentNode from services.workflow_runtime.node_factory import DifyNodeFactory +from tests.unit_tests.config_override import apply_config_overrides def _factory(mocked: frozenset[str]) -> DifyNodeFactory: @@ -77,8 +77,8 @@ def _agent_config(**data: object) -> dict[str, object]: def test_mocked_agent_needs_no_backend_client_or_native_constructor(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", None) - monkeypatch.setattr(dify_config, "AGENT_BACKEND_USE_FAKE", False) + apply_config_overrides(monkeypatch, AGENT_BACKEND_BASE_URL=None) + apply_config_overrides(monkeypatch, AGENT_BACKEND_USE_FAKE=False) def forbidden(*_args: object, **_kwargs: object) -> None: raise AssertionError("mocked Agent must not initialize native clients or resources") @@ -126,7 +126,7 @@ def test_legacy_agent_mock_start_event_preserves_strategy_without_presentation_r def test_mocked_agent_preserves_declared_output_shapes_without_fabricating_files( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_USE_FAKE", True) + apply_config_overrides(monkeypatch, AGENT_BACKEND_USE_FAKE=True) node = _factory(frozenset({"agent"})).create_node( _agent_config( agent_declared_outputs=[ @@ -156,7 +156,7 @@ def test_mocked_agent_preserves_declared_output_shapes_without_fabricating_files def test_mocked_agent_selects_a_configured_route_for_downstream_execution(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_USE_FAKE", True) + apply_config_overrides(monkeypatch, AGENT_BACKEND_USE_FAKE=True) node = _factory(frozenset({"agent"})).create_node( _agent_config( agent_output_routes={ @@ -180,7 +180,7 @@ def test_mocked_agent_selects_a_configured_route_for_downstream_execution(monkey def test_agent_routes_reject_node_default_value_in_both_real_and_mock_runs( monkeypatch: pytest.MonkeyPatch, mocked: frozenset[str] ) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_USE_FAKE", True) + apply_config_overrides(monkeypatch, AGENT_BACKEND_USE_FAKE=True) config = _agent_config( error_strategy=ErrorStrategy.DEFAULT_VALUE, agent_output_routes={ diff --git a/api/tests/unit_tests/services/dify_builder/test_execution_policy_contexts.py b/api/tests/unit_tests/services/dify_builder/test_execution_policy_contexts.py index f329b6b57b3..1a070c1269d 100644 --- a/api/tests/unit_tests/services/dify_builder/test_execution_policy_contexts.py +++ b/api/tests/unit_tests/services/dify_builder/test_execution_policy_contexts.py @@ -101,9 +101,7 @@ def test_native_json_factory_constructs_tool_with_typed_run_context(prepared_jso def test_json_completion_receipt_is_recorded_without_http_fixture_fields(prepared_json: PreparedJson) -> None: context = prepared_json.context binding = context.qualified_tool - installation = InstalledJsonParse( - **{name: getattr(binding, name) for name in InstalledJsonParse.__dataclass_fields__} - ) + installation = InstalledJsonParse.from_binding(binding) receipt = BuilderExecutionObservation( observation_id="native-node", invocation_id="native-node", diff --git a/api/tests/unit_tests/services/dify_builder/test_execution_policy_service.py b/api/tests/unit_tests/services/dify_builder/test_execution_policy_service.py index 64217abd252..115ad3f5cc2 100644 --- a/api/tests/unit_tests/services/dify_builder/test_execution_policy_service.py +++ b/api/tests/unit_tests/services/dify_builder/test_execution_policy_service.py @@ -27,6 +27,7 @@ from models.model import App, AppMode from models.workflow import Workflow, WorkflowKind, WorkflowNodeExecutionModel, WorkflowRun, WorkflowType from runtime_contracts.builder.execution import TrustedExecutionCompletion from services.dify_builder.application import execution_policy as domain +from tests.unit_tests.config_override import apply_config_overrides from tests.unit_tests.core.dify_builder.test_execution_policy import fixture, graph, policy type Owned = tuple[sessionmaker[Session], Actor, App, Workflow, str, str] @@ -316,7 +317,6 @@ def test_full_owner_chain_required(owned: Owned, owner: str) -> None: def test_owner_helpers_are_exported_once_for_shared_callers(owned: Owned, monkeypatch: pytest.MonkeyPatch) -> None: - from configs import dify_config from services.enterprise.rbac_service import RBACService factory, actor, app, workflow, sid, tid = owned @@ -335,7 +335,7 @@ def test_owner_helpers_are_exported_once_for_shared_callers(owned: Owned, monkey with pytest.raises(policy().BuilderExecutionPolicyError, match="execution_owner_mismatch"): module.load_execution_owners(db, app_id=str(uuid4()), actor=actor) module.check_execution_permission(actor, app.id, factory) - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr(RBACService.CheckAccess, "check", lambda *_args, **_kwargs: False) with pytest.raises(policy().BuilderExecutionPolicyError, match="execution_permission_denied"): module.check_execution_permission(actor, app.id, factory) @@ -706,14 +706,13 @@ def test_append_verifies_fixture_node_implementation_profile_and_bound_limit(own def test_native_rbac_requires_both_scenes_and_allows_delegated_membership( owned: Owned, monkeypatch: pytest.MonkeyPatch ) -> None: - from configs import dify_config from core.rbac import RBACPermission from services.enterprise.rbac_service import RBACService factory, actor, app, workflow, sid, tid = owned with factory.begin() as db: db.query(TenantAccountJoin).update({"role": TenantAccountRole.NORMAL}) - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) calls = [] def check( @@ -733,7 +732,6 @@ def test_native_rbac_requires_both_scenes_and_allows_delegated_membership( def test_native_rbac_plain_app_maintainer_bypass_is_persisted(owned: Owned, monkeypatch: pytest.MonkeyPatch) -> None: - from configs import dify_config from services.enterprise.rbac_service import RBACService factory, actor, app, workflow, sid, tid = owned @@ -742,7 +740,7 @@ def test_native_rbac_plain_app_maintainer_bypass_is_persisted(owned: Owned, monk assert stored_app is not None stored_app.maintainer = actor.account_id db.query(TenantAccountJoin).update({"role": TenantAccountRole.NORMAL}) - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr( RBACService.CheckAccess, "check", lambda *_args, **_kwargs: pytest.fail("maintainer bypass called RPC") ) @@ -753,7 +751,6 @@ def test_native_rbac_plain_app_maintainer_bypass_is_persisted(owned: Owned, monk def test_native_rbac_agent_app_binding_precedes_maintainer_bypass( owned: Owned, monkeypatch: pytest.MonkeyPatch, status: str ) -> None: - from configs import dify_config from models.agent import Agent, AgentScope, AgentSource, AgentStatus from services.enterprise.rbac_service import RBACService @@ -774,7 +771,7 @@ def test_native_rbac_agent_app_binding_precedes_maintainer_bypass( app_id=app.id, ) ) - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr( RBACService.CheckAccess, "check", lambda *_args, **_kwargs: pytest.fail("Agent guard called RPC") ) diff --git a/api/tests/unit_tests/services/dify_builder/test_identity.py b/api/tests/unit_tests/services/dify_builder/test_identity.py index 3abb1ad4555..f2f2470b532 100644 --- a/api/tests/unit_tests/services/dify_builder/test_identity.py +++ b/api/tests/unit_tests/services/dify_builder/test_identity.py @@ -2,14 +2,11 @@ These services do NO authorization of their own; ``load_app``'s tenant check is the only gate standing between an ``Actor`` and another tenant's app. Both -helpers are exercised against a mocked ``session`` (``session.get(Model, id)``) --- no DB. +helpers read persisted rows through a real SQLite session. """ -from types import SimpleNamespace -from unittest.mock import Mock - import pytest +from sqlalchemy.orm import Session from core.dify_builder.errors import NotFoundError from core.dify_builder.models import Actor @@ -22,48 +19,52 @@ def _actor(account_id: str = "acc-1", tenant_id: str = "tenant-1") -> Actor: return Actor(account_id=account_id, tenant_id=tenant_id) -def test_resolve_account_returns_account_on_hit() -> None: - account = SimpleNamespace(id="acc-1") - session = Mock() - session.get.return_value = account +def test_resolve_account_returns_account_on_hit(sqlite_session: Session, mocker) -> None: + session = sqlite_session + account = Account(name="Actor", email="actor@example.test") + account.id = "acc-1" + session.add(account) + session.flush() + get = mocker.spy(session, "get") result = resolve_account(session, _actor(account_id="acc-1")) assert result is account - session.get.assert_called_once_with(Account, "acc-1") + get.assert_called_once_with(Account, "acc-1") -def test_resolve_account_raises_not_found_on_miss() -> None: - session = Mock() - session.get.return_value = None +def test_resolve_account_raises_not_found_on_miss(sqlite_session: Session) -> None: + session = sqlite_session with pytest.raises(NotFoundError): resolve_account(session, _actor(account_id="acc-missing")) -def test_load_app_returns_app_on_tenant_match() -> None: - app = SimpleNamespace(id="app-1", tenant_id="tenant-1") - session = Mock() - session.get.return_value = app +def test_load_app_returns_app_on_tenant_match(sqlite_session: Session, mocker) -> None: + session = sqlite_session + app = App(id="app-1", tenant_id="tenant-1", name="Draft", mode="workflow", enable_site=False, enable_api=False) + session.add(app) + session.flush() + get = mocker.spy(session, "get") result = load_app(session, "app-1", _actor(tenant_id="tenant-1")) assert result is app - session.get.assert_called_once_with(App, "app-1") + get.assert_called_once_with(App, "app-1") -def test_load_app_raises_not_found_on_tenant_mismatch() -> None: - app = SimpleNamespace(id="app-1", tenant_id="other-tenant") - session = Mock() - session.get.return_value = app +def test_load_app_raises_not_found_on_tenant_mismatch(sqlite_session: Session) -> None: + session = sqlite_session + app = App(id="app-1", tenant_id="other-tenant", name="Draft", mode="workflow", enable_site=False, enable_api=False) + session.add(app) + session.flush() with pytest.raises(NotFoundError): load_app(session, "app-1", _actor(tenant_id="tenant-1")) -def test_load_app_raises_not_found_on_absent() -> None: - session = Mock() - session.get.return_value = None +def test_load_app_raises_not_found_on_absent(sqlite_session: Session) -> None: + session = sqlite_session with pytest.raises(NotFoundError): load_app(session, "does-not-exist", _actor()) diff --git a/api/tests/unit_tests/services/dify_builder/test_initial_chatflow.py b/api/tests/unit_tests/services/dify_builder/test_initial_chatflow.py index fb0f6a77f6f..726e5394898 100644 --- a/api/tests/unit_tests/services/dify_builder/test_initial_chatflow.py +++ b/api/tests/unit_tests/services/dify_builder/test_initial_chatflow.py @@ -5,7 +5,6 @@ import json from dataclasses import asdict from datetime import datetime from typing import cast, override -from unittest.mock import Mock import pytest @@ -117,7 +116,7 @@ def _generated_chatflow(initial_graph: Graph) -> Graph: @pytest.fixture -def generator(monkeypatch: pytest.MonkeyPatch, initial_graph: Graph) -> Mock: +def generator(monkeypatch: pytest.MonkeyPatch, initial_graph: Graph): monkeypatch.setattr( build.resources, "list_tenant_resources", @@ -130,35 +129,46 @@ def generator(monkeypatch: pytest.MonkeyPatch, initial_graph: Graph) -> Mock: {"provider": "openai", "name": "gpt-4o", "mode": "chat", "completion_params": {}} ), ) - generate = Mock(return_value={"graph": _generated_chatflow(initial_graph), "error": "", "errors": []}) + calls, responses = [], [] + + def generate(**kwargs): + calls.append(kwargs) + return ( + responses.pop(0) if responses else {"graph": _generated_chatflow(initial_graph), "error": "", "errors": []} + ) + monkeypatch.setattr(build.WorkflowGeneratorService, "generate_workflow_graph", generate) - return generate + return calls, responses -def test_chatflow_generation_accepts_answer_and_uses_the_actual_mode(generator: Mock) -> None: +def test_chatflow_generation_accepts_answer_and_uses_the_actual_mode(generator) -> None: + calls, _responses = generator result = build.build_nodes("tenant", {}, ["Reply to the user"], mode="advanced-chat") assert result.error == "" assert [i.args["node_type"] for i in result.intents if i.op == "create_node"] == ["start", "llm", "answer"] - assert generator.call_args is not None - assert generator.call_args.kwargs["mode"] == "advanced-chat" - assert "'answer' node" in generator.call_args.kwargs["instruction"] - assert "Do NOT use 'end' nodes" in generator.call_args.kwargs["instruction"] + assert calls + assert calls[-1]["mode"] == "advanced-chat" + assert "'answer' node" in calls[-1]["instruction"] + assert "Do NOT use 'end' nodes" in calls[-1]["instruction"] -def test_chatflow_retry_rejects_a_workflow_terminal(generator: Mock, initial_graph: Graph) -> None: +def test_chatflow_retry_rejects_a_workflow_terminal(generator, initial_graph: Graph) -> None: wrong = _generated_chatflow(initial_graph) wrong["nodes"][2]["data"] = {"type": "end", "title": "End", "outputs": []} errors: list[dict[str, str]] = [] - generator.side_effect = [ - {"graph": wrong, "error": "", "errors": errors}, - {"graph": _generated_chatflow(initial_graph), "error": "", "errors": errors}, - ] + calls, responses = generator + responses.extend( + [ + {"graph": wrong, "error": "", "errors": errors}, + {"graph": _generated_chatflow(initial_graph), "error": "", "errors": errors}, + ] + ) result = build.build_nodes("tenant", {}, ["Reply"], mode="advanced-chat") assert result.intents - assert generator.call_count == 2 - assert all(call.kwargs["mode"] == "advanced-chat" for call in generator.call_args_list) - assert generator.call_args is not None - retry = generator.call_args.kwargs["instruction"] + assert len(calls) == 2 + assert all(call["mode"] == "advanced-chat" for call in calls) + assert calls + retry = calls[-1]["instruction"] assert "answer" in retry assert "Do NOT use 'end' nodes" in retry @@ -240,7 +250,19 @@ def test_failed_generation_keeps_the_initial_template(initial_graph: Graph) -> N def test_chatflow_mode_reaches_planning_capability_checks_and_generation(monkeypatch: pytest.MonkeyPatch) -> None: - planner, gap, generate = Mock(return_value=None), Mock(return_value=""), Mock(return_value=BuildNodesResult()) + planner_calls, gap_calls, generation_calls = [], [], [] + + def planner(*_args, **kwargs): + planner_calls.append(kwargs) + + def gap(*_args, **kwargs): + gap_calls.append(kwargs) + return "" + + def generate(*_args, **kwargs): + generation_calls.append(kwargs) + return BuildNodesResult() + monkeypatch.setattr(build, "propose_plan_v1", planner) monkeypatch.setattr(build, "assess_capability_gap", gap) monkeypatch.setattr(build, "build_nodes", generate) @@ -250,20 +272,18 @@ def test_chatflow_mode_reaches_planning_capability_checks_and_generation(monkeyp agent.propose_plan_v1({}, goal_text="Reply") agent.assess_capability_gap(["Reply"], [], goal_text="Reply", requirements={}) agent.build_nodes(["Reply"]) - assert planner.call_args is not None - assert gap.call_args is not None - assert generate.call_args is not None - assert ( - planner.call_args.kwargs["mode"] - == gap.call_args.kwargs["mode"] - == generate.call_args.kwargs["mode"] - == "advanced-chat" - ) + assert planner_calls + assert gap_calls + assert generation_calls + assert planner_calls[-1]["mode"] == gap_calls[-1]["mode"] == generation_calls[-1]["mode"] == "advanced-chat" def test_chatflow_planning_and_capability_prompts_use_answer_nodes(monkeypatch: pytest.MonkeyPatch) -> None: - planner = Mock( - return_value=json.dumps( + calls = [] + + def planner(*_args, **kwargs): + calls.append(kwargs) + return json.dumps( { "title": "Chat assistant", "input_summary": "A message", @@ -271,16 +291,19 @@ def test_chatflow_planning_and_capability_prompts_use_answer_nodes(monkeypatch: "plan": ["answer node replies to the user"], } ) - ) - gap = Mock(return_value={"gap": ""}) + + def gap(*_args, **kwargs): + calls.append(kwargs) + return {"gap": ""} + monkeypatch.setattr(build.llm, "invoke_text", planner) monkeypatch.setattr(build.llm, "invoke_json", gap) assert build.propose_plan_v1(object(), {}, goal_text="Reply", mode="advanced-chat") is not None assert build.assess_capability_gap(object(), ["Reply"], [], mode="advanced-chat") == "" - for call in (planner.call_args, gap.call_args): - assert call is not None - assert '- "answer"' in call.kwargs["system"] - assert '- "end"' not in call.kwargs["system"] + assert len(calls) == 2 + for call in calls: + assert '- "answer"' in call["system"] + assert '- "end"' not in call["system"] def test_worker_resolves_the_persisted_app_mode_before_constructing_the_agent( @@ -301,7 +324,13 @@ def test_worker_resolves_the_persisted_app_mode_before_constructing_the_agent( current_state=PcState.BUILD_CAPABILITY_CHECK, ) repo.create_session(session, DifyBuilderContext(goal_text="Build a chat assistant"), []) - factory = Mock(wraps=task.build_dify_builder_agent) + original_factory = task.build_dify_builder_agent + factory_calls = [] + + def factory(*args, **kwargs): + factory_calls.append(kwargs) + return original_factory(*args, **kwargs) + events: list[dict[str, object]] = [] monkeypatch.setattr(task, "_build_repo", lambda: repo) monkeypatch.setattr(task, "WorkflowServiceDifyPort", lambda: port) @@ -314,8 +343,8 @@ def test_worker_resolves_the_persisted_app_mode_before_constructing_the_agent( task.advance_session( session.id, asdict(Action(kind="start_build", base_version=session.version)), asdict(actor), "token" ) - assert factory.call_args is not None - assert factory.call_args.kwargs["app_mode"] == "advanced-chat" + assert factory_calls + assert factory_calls[-1]["app_mode"] == "advanced-chat" assert repo.get_session(session.id)[0].current_state == PcState.BUILD_GOAL_ANALYSIS assert events[-1]["kind"] == "command_finished" diff --git a/api/tests/unit_tests/services/dify_builder/test_json_tool_execution.py b/api/tests/unit_tests/services/dify_builder/test_json_tool_execution.py index c3b812ed1c3..b5a4cf2beec 100644 --- a/api/tests/unit_tests/services/dify_builder/test_json_tool_execution.py +++ b/api/tests/unit_tests/services/dify_builder/test_json_tool_execution.py @@ -5,13 +5,14 @@ from collections.abc import Sequence from copy import deepcopy from pathlib import Path from typing import Never, override -from unittest.mock import Mock import pytest from pydantic import Field, ValidationError from core.dify_builder.models import Graph -from graphon.runtime import VariablePool +from core.workflow.node_runtime import DifyToolFileManager, DifyToolNodeRuntime +from graphon.nodes.tool.tool_node import ToolNode +from graphon.runtime import RuntimeState, VariablePool from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError, RestrictedAdmissionSnapshot from services.dify_builder.json_tool_execution import ( QualifiedJsonToolBinding, @@ -19,6 +20,7 @@ from services.dify_builder.json_tool_execution import ( resolve_json_parameters, validate_json_parameters, ) +from tests.workflow_test_utils import build_test_graph_init_params _GRAPH = Path(__file__).resolve().parents[3] / "fixtures/workflow/builder_restricted/json_parse_owned_graph.json" @@ -63,6 +65,18 @@ def add_native_editor_metadata(graph: Graph) -> Graph: return graph +def _native_parameter_node(graph: Graph) -> ToolNode: + init_params = build_test_graph_init_params(graph_config=graph) + return ToolNode( + node_id="node2", + data=ToolNode.validate_node_data(_tool(graph)), + init_params=init_params, + runtime_state=RuntimeState(workflow_id=init_params.workflow_id, variable_pool=VariablePool(), start_at=0.0), + tool_file_manager=DifyToolFileManager(init_params.run_context), + runtime=DifyToolNodeRuntime(init_params.run_context), + ) + + def test_reviewed_pure_mixed_graph_admits_exact_native_chain(json_graph: Graph) -> None: admitted = admit_json_workflow(snapshot_for(json_graph)) assert (admitted.start_node_id, admitted.tool_node_id, admitted.end_node_id) == ("node1", "node2", "node3") @@ -499,7 +513,11 @@ def test_native_generation_and_log_read_only_declared_start_values(json_graph: G parameters = (ToolRuntimeParameter("content", required=True), ToolRuntimeParameter("json_filter", required=True)) for for_log in (False, True): generated = ToolNode._generate_parameters( - Mock(spec=ToolNode), tool_parameters=parameters, variable_pool=pool, node_data=native_data, for_log=for_log + _native_parameter_node(json_graph), + tool_parameters=parameters, + variable_pool=pool, + node_data=native_data, + for_log=for_log, ) assert generated == expected assert pool.reads == [("node1", "payload"), ("node1", "json_path")] * 2 @@ -527,7 +545,7 @@ def test_native_jsonpath_constant_cannot_read_non_start_value(json_graph: Graph) assert admit_json_workflow(snapshot_for(json_graph)).tool_node_id == "node2" native_data = ToolNode.validate_node_data(_tool(json_graph)) generated = ToolNode._generate_parameters( - Mock(spec=ToolNode), + _native_parameter_node(json_graph), tool_parameters=( ToolRuntimeParameter("content", required=True), ToolRuntimeParameter("json_filter", required=True), diff --git a/api/tests/unit_tests/services/dify_builder/test_json_tool_metadata.py b/api/tests/unit_tests/services/dify_builder/test_json_tool_metadata.py index 2532ec52538..e9949023fd3 100644 --- a/api/tests/unit_tests/services/dify_builder/test_json_tool_metadata.py +++ b/api/tests/unit_tests/services/dify_builder/test_json_tool_metadata.py @@ -93,7 +93,7 @@ def _owners( def _expected(actual: metadata.InstalledJsonParse) -> QualifiedJsonToolBinding: return QualifiedJsonToolBinding( node_id="node2", - **{field: getattr(actual, field) for field in actual.__dataclass_fields__}, + **actual.binding_fields(), final_parameters_digest="a" * 64, ) diff --git a/api/tests/unit_tests/services/dify_builder/test_json_tool_runtime.py b/api/tests/unit_tests/services/dify_builder/test_json_tool_runtime.py index 4fbfc65121a..7503a9086aa 100644 --- a/api/tests/unit_tests/services/dify_builder/test_json_tool_runtime.py +++ b/api/tests/unit_tests/services/dify_builder/test_json_tool_runtime.py @@ -3,9 +3,8 @@ import json from collections.abc import Callable, Generator, Mapping from copy import deepcopy -from dataclasses import replace +from dataclasses import dataclass, replace from pathlib import Path -from types import SimpleNamespace from typing import Never, cast import pytest @@ -90,7 +89,7 @@ def _context(installation: InstalledJsonParse, expected: dict[str, str | bool]) ) qualified = QualifiedJsonToolBinding( node_id="node2", - **{field: getattr(installation, field) for field in installation.__dataclass_fields__}, + **installation.binding_fields(), final_parameters_digest=canonical_digest(expected), ) initial = JsonToolExecutionContext( @@ -306,6 +305,19 @@ def test_final_envelope_mismatch_prevents_native_dispatch( list(guarded.invoke(session, "user", {**EXPECTED, "ensure_ascii": True})) +@dataclass(frozen=True) +class ReviewedJsonIssuer: + binding: JsonRuntimeBinding + + def json_runtime_binding(self, **_kwargs: str) -> JsonRuntimeBinding: + return self.binding + + +class UnqualifiedToolProvider: + def get_tool(self, _name: str) -> PluginTool: + return _native() + + def _runtime( monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, @@ -313,7 +325,7 @@ def _runtime( expected: Mapping[str, str | bool] | None = None, metadata_reader: Callable[[str], InstalledJsonParse] | None = None, recorder: Recorder | None = None, - controller: PluginToolProviderController | SimpleNamespace | None = None, + controller: PluginToolProviderController | UnqualifiedToolProvider | None = None, maker: sessionmaker[Session] | None = None, ) -> tuple[QualifiedJsonToolNodeRuntime, Recorder]: expected = dict(EXPECTED if expected is None else expected) @@ -328,7 +340,7 @@ def _runtime( recorder=recorder, metadata_reader=metadata_reader or (lambda _tenant: installed), ) - issuer = SimpleNamespace(json_runtime_binding=lambda **_kwargs: binding) + issuer = ReviewedJsonIssuer(binding) controller = controller or PluginToolProviderController( entity=_provider().declaration, plugin_id=metadata.PLUGIN_ID, @@ -481,7 +493,7 @@ def test_actual_parse_schema_drift_refuses_before_dynamic_fetch( def test_hardcoded_fallback_refuses_before_credential_branch( monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine ) -> None: - fallback = SimpleNamespace(get_tool=lambda _name: _native()) + fallback = UnqualifiedToolProvider() runtime, _ = _runtime(monkeypatch, sqlite_engine, controller=fallback) with pytest.raises(ToolRuntimeResolutionError): runtime.get_runtime( @@ -610,10 +622,7 @@ def test_post_dispatch_metadata_change_prevents_receipt(monkeypatch: pytest.Monk reads.append(True) if len(reads) == 1: return installed - return InstalledJsonParse( - **{field: getattr(installed, field) for field in installed.__dataclass_fields__ if field != "checksum"}, - checksum="f" * 64, - ) + return replace(installed, checksum="f" * 64) class Manager: def invoke(self, **_kwargs: object) -> Generator[ToolInvokeMessage]: @@ -756,7 +765,7 @@ def test_file_producing_output_refuses_before_all_file_owners( monkeypatch.setattr(ToolFileManager, "create_file_by_url", fail("create-file-by-url")) monkeypatch.setattr(ToolFileManager, "create_file_by_raw", fail("create-file-by-raw")) monkeypatch.setattr(file_manager_module.remote_fetcher, "make_request", fail("file-download")) - monkeypatch.setattr(file_manager_module, "storage", SimpleNamespace(save=fail("file-storage"))) + monkeypatch.setattr(file_manager_module.storage, "save", fail("file-storage")) original = ToolFileMessageTransformer.transform_tool_invoke_messages def observing_transform( diff --git a/api/tests/unit_tests/services/dify_builder/test_model_resolver.py b/api/tests/unit_tests/services/dify_builder/test_model_resolver.py index f87583b7303..5d8201afc42 100644 --- a/api/tests/unit_tests/services/dify_builder/test_model_resolver.py +++ b/api/tests/unit_tests/services/dify_builder/test_model_resolver.py @@ -1,12 +1,12 @@ -from types import SimpleNamespace -from unittest.mock import Mock +from dataclasses import dataclass, field import pytest from core.dify_builder.errors import BadRequestError, ModelUnavailableError from core.dify_builder.models import Graph, Inputs -from graphon.model_runtime.entities.model_entities import ModelType +from core.model_manager import ModelInstance, ModelManager from services.dify_builder.agent import model_resolver +from tests.unit_tests.core.model_fixtures import make_model_config, make_model_instance MC = { "provider": "openai", @@ -16,47 +16,52 @@ MC = { } -class _FakeManager: - def __init__(self, *, fail: bool = False, active: bool = True) -> None: - self._fail = fail - self.default_used = False - self.explicit_args: tuple[str, str, str, str] | None = None - self.lookup = Mock(return_value=SimpleNamespace(model_properties={"mode": "chat"}) if active else None) - self.instance = SimpleNamespace( - provider="openai", - model_name="gpt-4o", - provider_model_bundle=SimpleNamespace(configuration=SimpleNamespace(get_provider_model=self.lookup)), - ) +@dataclass +class ModelSelection: + manager: ModelManager + instance: ModelInstance + default_used: bool = False + explicit_args: tuple[str, str, str, str] | None = None + lookup_calls: list[dict[str, object]] = field(default_factory=list) - def get_model_instance( - self, *, tenant_id: str, provider: str, model_type: ModelType, model: str - ) -> SimpleNamespace: - if self._fail: + +def _manager(monkeypatch: pytest.MonkeyPatch, *, fail: bool = False, active: bool = True) -> ModelSelection: + manager = ModelManager.for_tenant(tenant_id="t1") + instance = make_model_instance(provider="openai", model="gpt-4o") + selection = ModelSelection(manager, instance) + model = make_model_config(provider="openai", model="gpt-4o", mode="chat").model_schema + model.model_properties = {"mode": "chat"} + + def lookup(**kwargs): + selection.lookup_calls.append(kwargs) + return model if active else None + + def explicit(*, tenant_id, provider, model_type, model): + if fail: raise ValueError("provider not configured") - self.explicit_args = (tenant_id, provider, str(model_type), model) - return self.instance + selection.explicit_args = (tenant_id, provider, str(model_type), model) + return instance - def get_default_model_instance(self, *, tenant_id: str, model_type: ModelType) -> SimpleNamespace: # noqa: ARG002 - self.default_used = True - if self._fail: + def default(**_kwargs): + selection.default_used = True + if fail: raise ValueError("default model not configured") - return self.instance + return instance - def get_default_provider_model_name(self, *, tenant_id: str, model_type: ModelType) -> tuple[str, str]: # noqa: ARG002 - return ("anthropic", "claude-sonnet-5") - - -def _patch_manager(monkeypatch: pytest.MonkeyPatch, manager: _FakeManager | Mock) -> None: monkeypatch.setattr( - model_resolver.ModelManager, - "for_tenant", - staticmethod(lambda tenant_id, user_id=None: manager), # noqa: ARG005 + type(instance.provider_model_bundle.configuration), + "get_provider_model", + lambda _self, **kwargs: lookup(**kwargs), ) + monkeypatch.setattr(manager, "get_model_instance", explicit) + monkeypatch.setattr(manager, "get_default_model_instance", default) + monkeypatch.setattr(manager, "get_default_provider_model_name", lambda **_kwargs: ("anthropic", "claude-sonnet-5")) + monkeypatch.setattr(model_resolver.ModelManager, "for_tenant", lambda *_args, **_kwargs: manager) + return selection def test_resolve_explicit_model(monkeypatch: pytest.MonkeyPatch) -> None: - mgr = _FakeManager() - _patch_manager(monkeypatch, mgr) + mgr = _manager(monkeypatch) inst = model_resolver.resolve_model_instance("t1", MC) assert inst is mgr.instance assert mgr.explicit_args is not None @@ -66,8 +71,7 @@ def test_resolve_explicit_model(monkeypatch: pytest.MonkeyPatch) -> None: def test_resolve_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None: - mgr = _FakeManager() - _patch_manager(monkeypatch, mgr) + mgr = _manager(monkeypatch) assert model_resolver.resolve_model_instance("t1", None) is mgr.instance assert mgr.default_used @@ -76,7 +80,7 @@ def test_resolve_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None: def test_validate_raises_model_unavailable_on_unresolvable( monkeypatch: pytest.MonkeyPatch, config: Graph | Inputs | None ) -> None: - _patch_manager(monkeypatch, _FakeManager(fail=True)) + _manager(monkeypatch, fail=True) with pytest.raises(ModelUnavailableError): model_resolver.validate_model_config("t1", config) @@ -85,27 +89,25 @@ def test_validate_raises_model_unavailable_on_unresolvable( def test_validate_rejects_inactive_or_missing_model( monkeypatch: pytest.MonkeyPatch, config: Graph | Inputs | None ) -> None: - _patch_manager(monkeypatch, _FakeManager(active=False)) + _manager(monkeypatch, active=False) with pytest.raises(ModelUnavailableError): model_resolver.validate_model_config("t1", config) @pytest.mark.parametrize("config", [MC, None, {}]) def test_validate_freezes_resolved_active_model(monkeypatch: pytest.MonkeyPatch, config: Graph | Inputs | None) -> None: - mgr = _FakeManager() - _patch_manager(monkeypatch, mgr) + mgr = _manager(monkeypatch) result = model_resolver.validate_model_config("t1", config) assert result == {**MC, "completion_params": MC["completion_params"] if config else {}} - mgr.lookup.assert_called_once_with(model_type=model_resolver.ModelType.LLM, model="gpt-4o", only_active=True) + assert mgr.lookup_calls == [{"model_type": model_resolver.ModelType.LLM, "model": "gpt-4o", "only_active": True}] def test_invalid_model_shape_is_bad_request_without_resolution(monkeypatch: pytest.MonkeyPatch) -> None: - manager = Mock() - _patch_manager(monkeypatch, manager) + manager = _manager(monkeypatch) with pytest.raises(BadRequestError) as exc: model_resolver.validate_model_config("t1", {"name": "missing-provider"}) assert not isinstance(exc.value, ModelUnavailableError) - manager.get_model_instance.assert_not_called() + assert manager.explicit_args is None def test_resolved_names_explicit() -> None: @@ -113,7 +115,7 @@ def test_resolved_names_explicit() -> None: def test_resolved_names_default(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_manager(monkeypatch, _FakeManager()) + _manager(monkeypatch) assert model_resolver.resolved_model_names("t1", None) == {"provider": "anthropic", "name": "claude-sonnet-5"} diff --git a/api/tests/unit_tests/services/dify_builder/test_native_agent_binding.py b/api/tests/unit_tests/services/dify_builder/test_native_agent_binding.py index 6623c3bcb09..6b85635dc77 100644 --- a/api/tests/unit_tests/services/dify_builder/test_native_agent_binding.py +++ b/api/tests/unit_tests/services/dify_builder/test_native_agent_binding.py @@ -6,6 +6,7 @@ import pytest from pydantic import ValidationError from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError +from tests.unit_tests.config_override import apply_config_overrides def test_active_roster_drift_requires_reconfirmation(): @@ -70,10 +71,9 @@ def test_only_bound_new_agent_is_admitted_by_proposal_policy(): @pytest.fixture def agent_case(sqlite_session, monkeypatch): - from configs import dify_config from tests.builder_agent_fixtures import make_case - monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) case = make_case(sqlite_session) sqlite_session.commit() sqlite_session.begin() @@ -304,7 +304,6 @@ def test_confirmed_ordinary_agent_rechecks_selection_before_backend( """The actual ordinary node must refuse changed authority before allocation or dispatch.""" from contextlib import contextmanager - from configs import dify_config from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom from core.rbac import RBACPermission from graphon.enums import WorkflowNodeExecutionStatus @@ -361,7 +360,7 @@ def test_confirmed_ordinary_agent_rechecks_selection_before_backend( sqlite_session.delete(membership) sqlite_session.commit() if change == "preview": - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr( access_service, "resolve_current_agent_access_filter", @@ -480,7 +479,6 @@ def test_full_native_node_job_is_persisted_in_existing_binding_owner(agent_case, def test_native_authorization_rpc_precedes_draft_transaction(agent_case, sqlite_session, monkeypatch): - from configs import dify_config from models.agent_config_entities import WorkflowNodeJobConfig from services.agent import access_service from services.dify_builder.native_testing.agent_binding import AgentBindingService @@ -488,7 +486,7 @@ def test_native_authorization_rpc_precedes_draft_transaction(agent_case, sqlite_ app_id, agent_id = agent_case.workflow.app_id, agent_case.agent.id sqlite_session.rollback() - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) checked = [] def remote_check(*_args, **_kwargs): diff --git a/api/tests/unit_tests/services/dify_builder/test_native_agent_catalog.py b/api/tests/unit_tests/services/dify_builder/test_native_agent_catalog.py index 400a8bb70d6..178f5e1913a 100644 --- a/api/tests/unit_tests/services/dify_builder/test_native_agent_catalog.py +++ b/api/tests/unit_tests/services/dify_builder/test_native_agent_catalog.py @@ -7,6 +7,8 @@ import pytest from models.agent import Agent, AgentConfigSnapshot from models.agent_config_entities import AgentSoulConfig +from tests.unit_tests.config_override import apply_config_overrides +from tests.unit_tests.services.dify_builder.test_dify_port import PortDatabase def test_projection_is_published_and_output_contract_is_honest(): @@ -76,9 +78,7 @@ def test_public_roster_description_is_an_honest_capability_fallback(summary): def test_agent_contract_reaches_planning_without_private_soul_values(sqlite_session, monkeypatch, phase): """An eligible real SQL projection must survive both planning consumers intact.""" import json - from types import SimpleNamespace - from configs import dify_config from core.app.app_config.entities import ModelConfig from models.agent_config_entities import WorkflowNodeJobConfig from services.dify_builder.agent import build, resources @@ -86,7 +86,7 @@ def test_agent_contract_reaches_planning_without_private_soul_values(sqlite_sess from services.dify_builder.native_testing.agent_catalog import project_published_agent from tests.builder_agent_fixtures import make_case - monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) case = make_case(sqlite_session) case.agent.description = "Public review capability " * 40 case.snapshot.summary = None @@ -96,7 +96,7 @@ def test_agent_contract_reaches_planning_without_private_soul_values(sqlite_sess case.snapshot.config_snapshot = AgentSoulConfig.model_validate(soul) sqlite_session.commit() candidate = project_published_agent(agent=case.agent, snapshot=case.snapshot) - monkeypatch.setattr(resources, "db", SimpleNamespace(engine=sqlite_session.get_bind())) + monkeypatch.setattr(resources, "db", PortDatabase(sqlite_session.get_bind())) monkeypatch.setattr(resources, "_list_models", lambda _tenant_id: []) monkeypatch.setattr(resources, "_list_datasets", lambda _tenant_id: []) monkeypatch.setattr(resources, "_list_tools", lambda _tenant_id: []) @@ -244,14 +244,13 @@ def test_changed_agent_selection_cannot_silently_rebind(monkeypatch): def test_native_catalog_filters_before_pagination(sqlite_session, monkeypatch): - from configs import dify_config from models.account import Tenant from models.agent import AgentStatus from services.agent import access_service from services.dify_builder.native_testing.agent_catalog import AgentCatalogService from tests.builder_agent_fixtures import add_agent, make_case - monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) case = make_case(sqlite_session) hidden, _ = add_agent(sqlite_session, tenant_id=case.actor.tenant_id, name="Hidden") unpublished, _ = add_agent(sqlite_session, tenant_id=case.actor.tenant_id, name="Unpublished", published=False) @@ -280,11 +279,10 @@ def test_native_catalog_filters_before_pagination(sqlite_session, monkeypatch): def test_missing_tool_credentials_remain_discoverable_with_setup_status(sqlite_session, monkeypatch): - from configs import dify_config from services.dify_builder.native_testing.agent_catalog import AgentCatalogService from tests.builder_agent_fixtures import make_case - monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) case = make_case(sqlite_session) candidates = AgentCatalogService(sqlite_session).list_candidates( tenant_id=case.actor.tenant_id, actor_id=case.actor.account_id, app_id=case.workflow.app_id, query=None @@ -297,9 +295,7 @@ def test_missing_tool_credentials_remain_discoverable_with_setup_status(sqlite_s @pytest.mark.parametrize("state", ["unchanged", "drifted", "revoked"]) def test_confirmed_agent_is_revalidated_directly_after_catalog_page_movement(sqlite_session, monkeypatch, state): from datetime import datetime - from types import SimpleNamespace - from configs import dify_config from models.agent import AgentConfigRevision, AgentConfigRevisionOperation from services.agent import access_service from services.dify_builder.agent import build, resources @@ -307,7 +303,7 @@ def test_confirmed_agent_is_revalidated_directly_after_catalog_page_movement(sql from services.dify_builder.native_testing.agent_catalog import project_published_agent from tests.builder_agent_fixtures import add_agent, make_case - monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) case = make_case(sqlite_session) candidate = project_published_agent(agent=case.agent, snapshot=case.snapshot) for index in range(101): @@ -315,7 +311,7 @@ def test_confirmed_agent_is_revalidated_directly_after_catalog_page_movement(sql newer.updated_at = datetime(2026, 10, 14) case.agent.updated_at = datetime(2000, 1, 1) sqlite_session.commit() - monkeypatch.setattr(resources, "db", SimpleNamespace(engine=sqlite_session.get_bind())) + monkeypatch.setattr(resources, "db", PortDatabase(sqlite_session.get_bind())) monkeypatch.setattr(resources, "_list_models", lambda _tenant_id: []) monkeypatch.setattr(resources, "_list_datasets", lambda _tenant_id: []) monkeypatch.setattr(resources, "_list_tools", lambda _tenant_id: []) diff --git a/api/tests/unit_tests/services/dify_builder/test_native_execution_admission.py b/api/tests/unit_tests/services/dify_builder/test_native_execution_admission.py index e235fcae174..9649dc27da5 100644 --- a/api/tests/unit_tests/services/dify_builder/test_native_execution_admission.py +++ b/api/tests/unit_tests/services/dify_builder/test_native_execution_admission.py @@ -4,7 +4,6 @@ import json from collections.abc import Iterator, Mapping from threading import Thread from typing import Never -from unittest.mock import MagicMock, Mock import pytest @@ -20,7 +19,7 @@ from tests.unit_tests.services.dify_builder.test_execution_policy_service import @pytest.fixture -def effects(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[MagicMock]]: +def effects(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[str]]: import services.workflow_runtime.workflow_generator as generator from core.helper import encrypter from core.helper.code_executor.code_executor import CodeExecutor @@ -30,11 +29,12 @@ def effects(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[MagicMock]]: from core.tools.tool_manager import ToolManager from factories import file_factory - spies = [] + calls: list[str] = [] for owner, name in ( (file_factory, "build_from_mappings"), (file_factory, "build_from_mapping"), - (generator, "TraceQueueManager"), + (generator.TraceQueueManager, "start_timer"), + (OpsTraceManager, "get_ops_trace_instance"), (ToolManager, "get_workflow_tool_runtime"), (OAuthHandler, "refresh_credentials"), (encrypter, "decrypt_token"), @@ -42,12 +42,14 @@ def effects(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[MagicMock]]: (ModelManager, "get_model_instance"), (CodeExecutor, "execute_code"), ): - spy = MagicMock(side_effect=AssertionError(f"forbidden effect: {name}")) - monkeypatch.setattr(owner, name, spy) - spies.append(spy) - yield spies - for spy in spies: - spy.assert_not_called() + + def forbidden(*_args: object, effect_name: str = name, **_kwargs: object) -> Never: + calls.append(effect_name) + raise AssertionError(f"forbidden effect: {effect_name}") + + monkeypatch.setattr(owner, name, forbidden) + yield calls + assert calls == [] @pytest.mark.parametrize( @@ -81,10 +83,22 @@ def test_port_refuses_unsupported_before_native_generation_or_secrets( inputs["extra"] = float("inf") monkeypatch.setattr(module, "_session_factory", lambda: factory) monkeypatch.setattr(module, "load_app", lambda *_: app) - monkeypatch.setattr(module, "resolve_account", lambda *_: MagicMock()) + from models.account import Account + + account = Account(name="Owner", email="owner@example.invalid") + account.id = actor.account_id + monkeypatch.setattr(module, "resolve_account", lambda *_: account) monkeypatch.setattr(module, "_load_draft_workflow_or_raise", lambda *_, **__: workflow) - native = MagicMock(side_effect=AssertionError("native generator forbidden")) - secret = MagicMock(side_effect=AssertionError("revision may resolve environment secrets")) + calls = [] + + def native(*_args, **_kwargs): + calls.append("native") + raise AssertionError("native generator forbidden") + + def secret(*_args, **_kwargs): + calls.append("secret") + raise AssertionError("revision may resolve environment secrets") + monkeypatch.setattr(module.AppGenerateService, "generate", native) monkeypatch.setattr(module, "execution_revision", secret) result = module.WorkflowServiceDifyPort().run_draft( @@ -93,8 +107,7 @@ def test_port_refuses_unsupported_before_native_generation_or_secrets( assert result.dify_run_id == "" assert result.execution_refusal is not None assert result.execution_refusal.safety_outcome == "unsupported_safe_execution" - native.assert_not_called() - secret.assert_not_called() + assert calls == [] @pytest.mark.parametrize( @@ -122,14 +135,19 @@ def test_port_uses_native_defaults_numbers_and_sanitization( monkeypatch.setattr(module, "_session_factory", lambda: factory) monkeypatch.setattr(module, "set_login_user", lambda _: None) monkeypatch.setattr(module, "_load_draft_workflow_or_raise", lambda *_, **__: workflow) - native = MagicMock(return_value={}) + calls = [] + + def native(**kwargs): + calls.append(kwargs) + return {} + monkeypatch.setattr(module.AppGenerateService, "generate", native) result = module.WorkflowServiceDifyPort().run_draft( app.id, actor, submitted, lambda _: None, session_id=sid, test_input_id=tid ) assert result.dify_run_id == "" - assert native.call_args.kwargs["args"]["inputs"] == expected - marker = native.call_args.kwargs["builder_execution"] + assert calls[0]["args"]["inputs"] == expected + marker = calls[0]["builder_execution"] assert marker.effective_inputs_digest == scalar_inputs_digest(expected) assert marker.submitted_inputs_digest == scalar_inputs_digest(submitted) @@ -138,8 +156,6 @@ def test_port_uses_native_defaults_numbers_and_sanitization( def test_native_generator_revalidates_its_normalized_effective_inputs( owned: Owned, monkeypatch: pytest.MonkeyPatch, inputs: dict[str, object], expected: dict[str, object] ) -> None: - from types import SimpleNamespace - import services.workflow_runtime.workflow_generator as module from core.app.app_config.workflow_ui_based_app.variables.manager import WorkflowVariablesConfigManager from core.app.apps.base_app_generator import BaseAppGenerator @@ -182,10 +198,10 @@ def test_native_generator_revalidates_its_normalized_effective_inputs( effective_inputs=effective, ) launch = _BuilderExecutionLaunch(service, marker) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=factory.kw["bind"])) - monkeypatch.setattr(module, "TraceQueueManager", MagicMock()) - monkeypatch.setattr(module, "DifyCoreRepositoryFactory", MagicMock()) - generator = module.WorkflowAppGenerator(execution_driver=Mock()) + from services.workflow_run_agg import WorkflowRunAgg + + monkeypatch.setattr(type(module.db), "engine", property(lambda _db: factory.kw["bind"])) + generator = module.WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run) monkeypatch.setattr(generator, "_generate", lambda **kwargs: kwargs) result = generator.generate( app_model=app, @@ -257,8 +273,6 @@ def test_port_preserves_worker_policy_refusal_without_allocating_native_result( def test_postflight_rejects_raw_changes_before_secret_revision( owned: Owned, monkeypatch: pytest.MonkeyPatch, metadata: str ) -> None: - from types import SimpleNamespace - import services.dify_builder.dify_port as module from core.dify_builder.models import Run, RunVerification from models.model import App @@ -273,7 +287,12 @@ def test_postflight_rejects_raw_changes_before_secret_revision( setattr(db.get(Workflow, workflow.id), f"_{metadata}_variables", '{"secret":{}}') monkeypatch.setattr(module, "_session_factory", lambda: factory) monkeypatch.setattr(module, "_load_draft_workflow_or_raise", lambda _, session: session.get(Workflow, workflow.id)) - revision = MagicMock(side_effect=AssertionError("raw changes must be denied before revision resolves secrets")) + revisions = [] + + def revision(*_args, **_kwargs): + revisions.append(True) + raise AssertionError("raw changes must be denied before revision resolves secrets") + monkeypatch.setattr(module, "execution_revision", revision) run = Run( status="succeeded", @@ -286,13 +305,11 @@ def test_postflight_rejects_raw_changes_before_secret_revision( no_output_dead_branch=False, ), ) - result = module.WorkflowServiceDifyPort()._bind_run( - (run, SimpleNamespace(graph_dict=workflow.graph_dict)), app.id, actor, "a" * 64, "b" * 64 - ) + result = module.WorkflowServiceDifyPort()._bind_run((run, workflow), app.id, actor, "a" * 64, "b" * 64) assert result.execution_refusal is not None assert result.verification is not None assert result.verification.execution_revision == "" - revision.assert_not_called() + assert revisions == [] @pytest.mark.parametrize("change", ["environment", "file_default", "tracing"]) @@ -328,11 +345,18 @@ def test_direct_restricted_generator_admits_raw_before_config_and_normalization( } ] workflow.graph = json.dumps(graph) - config = MagicMock(side_effect=AssertionError("config forbidden before raw admission")) + configs = [] + + def config(*_args, **_kwargs): + configs.append(True) + raise AssertionError("config forbidden before raw admission") + monkeypatch.setattr(module.WorkflowAppConfigManager, "get_app_config", config) launch = _BuilderExecutionLaunch(BuilderExecutionPolicyService(factory), marker) + from services.workflow_run_agg import WorkflowRunAgg + with pytest.raises(BuilderExecutionPolicyError): - module.WorkflowAppGenerator(execution_driver=Mock()).generate( + module.WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run).generate( app_model=app, workflow=workflow, user=account, @@ -343,7 +367,7 @@ def test_direct_restricted_generator_admits_raw_before_config_and_normalization( builder_execution=marker, builder_execution_admit=launch, ) - config.assert_not_called() + assert configs == [] @pytest.mark.parametrize("alive_at_snapshot", [False, True]) @@ -591,7 +615,11 @@ def test_final_seal_failure_returns_unsealed_unknown_without_allocation_identity monkeypatch.setattr(module, "set_login_user", lambda _: None) monkeypatch.setattr(module, "_load_draft_workflow_or_raise", lambda *_, **__: workflow) monkeypatch.setattr(module.AppGenerateService, "generate", lambda **_: {}) - monkeypatch.setattr(module.BuilderExecutionPolicyService, "seal", MagicMock(side_effect=RuntimeError("storage"))) + + def seal(*_args, **_kwargs): + raise RuntimeError("storage") + + monkeypatch.setattr(module.BuilderExecutionPolicyService, "seal", seal) run = module.WorkflowServiceDifyPort().run_draft( app.id, actor, {"n": "41"}, lambda _: None, session_id=sid, test_input_id=tid ) @@ -616,6 +644,9 @@ def test_invalid_worker_candidate_latches_unknown(owned: Owned, invalid: str) -> context = prepare_inputs(owned) launch = _BuilderExecutionLaunch(service_module().BuilderExecutionPolicyService(owned[0]), context) + from services.dify_builder.execution_policy_service import ServiceExecutionRecorder + + foreign_recorder = ServiceExecutionRecorder(BuilderExecutionPolicyService(owned[0]), context, "foreign", "task") errors: list[str] = [] def worker() -> None: @@ -632,7 +663,7 @@ def test_invalid_worker_candidate_latches_unknown(owned: Owned, invalid: str) -> context=context.model_copy(update={"request_id": "foreign"}) if invalid == "context" else context, native_run_id="allocated", task_id="task", - recorder=MagicMock() if invalid == "recorder" else None, + recorder=foreign_recorder if invalid == "recorder" else None, worker_thread=threading.main_thread() if invalid == "thread" else threading.current_thread(), exit_kind="failed" if invalid == "conflicting_exit" else "returned", ) diff --git a/api/tests/unit_tests/services/dify_builder/test_native_http_output_contract.py b/api/tests/unit_tests/services/dify_builder/test_native_http_output_contract.py index 0a745aade79..d649cc812f9 100644 --- a/api/tests/unit_tests/services/dify_builder/test_native_http_output_contract.py +++ b/api/tests/unit_tests/services/dify_builder/test_native_http_output_contract.py @@ -3,15 +3,14 @@ import json from collections.abc import Callable from copy import deepcopy -from types import SimpleNamespace from typing import cast -from unittest.mock import MagicMock import pytest from core.dify_builder.models import Graph, MutationIntent from core.workflow.generator.types import GraphDict -from graphon.model_runtime.entities.message_entities import PromptMessage +from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage from services.dify_builder.native_http_output_contract import ( aliased_http_output, declares_http_output, @@ -24,6 +23,7 @@ from services.dify_builder.native_http_output_contract import ( from services.dify_builder.preflight import new_preflight_problems, preflight_errors, vet_intents from services.workflow_generation.graph_normalizers import derive_if_else_var_types from services.workflow_generation.runner import WorkflowGenerator +from tests.unit_tests.core.model_fixtures import make_model_instance def http_graph(operator: str = "is", value: str = "private-expected-value") -> Graph: @@ -265,35 +265,38 @@ def build_messages( plan_changes(nodes) captured: dict[str, list[PromptMessage]] = {} - def invoke_llm(*, prompt_messages: list[PromptMessage], **_kwargs: object) -> MagicMock: + def invoke_llm(*, prompt_messages: list[PromptMessage], **_kwargs: object) -> LLMResult: user = str(prompt_messages[1].content) node_id = next(node["id"] for node in nodes if f"id={node['id']}, type=" in user) captured[node_id] = prompt_messages - result = MagicMock() - result.message.get_text_content.return_value = '{"config":{}}' - return result + return LLMResult( + model="gpt-4o", message=AssistantPromptMessage(content='{"config":{}}'), usage=LLMUsage.empty_usage() + ) - WorkflowGenerator._run_parallel_node_builders( - model_instance=SimpleNamespace(invoke_llm=invoke_llm), - model_parameters={}, - provider="openai", - model_name="gpt-4o", - model_mode="chat", - mode="workflow", - instruction="Original health goal: private-goal", - ideal_output="", - plan_nodes=nodes, - plan_edges=[ - {"source": "http-a", "target": "middle"}, - {"source": "middle", "target": "consumer"}, - {"source": "http-b", "target": "consumer"}, - ], - tool_catalogue_text="", - tool_catalogue_entries=[], - start_inputs=[], - node_outputs=node_outputs or {}, - current_graph=current_graph, - ) + model_instance = make_model_instance(provider="openai", model="gpt-4o") + with pytest.MonkeyPatch.context() as patch: + patch.setattr(model_instance, "invoke_llm", invoke_llm) + WorkflowGenerator._run_parallel_node_builders( + model_instance=model_instance, + model_parameters={}, + provider="openai", + model_name="gpt-4o", + model_mode="chat", + mode="workflow", + instruction="Original health goal: private-goal", + ideal_output="", + plan_nodes=nodes, + plan_edges=[ + {"source": "http-a", "target": "middle"}, + {"source": "middle", "target": "consumer"}, + {"source": "http-b", "target": "consumer"}, + ], + tool_catalogue_text="", + tool_catalogue_entries=[], + start_inputs=[], + node_outputs=node_outputs or {}, + current_graph=current_graph, + ) return captured diff --git a/api/tests/unit_tests/services/dify_builder/test_native_runtime_failure.py b/api/tests/unit_tests/services/dify_builder/test_native_runtime_failure.py index c9859e854d0..bf12b441d14 100644 --- a/api/tests/unit_tests/services/dify_builder/test_native_runtime_failure.py +++ b/api/tests/unit_tests/services/dify_builder/test_native_runtime_failure.py @@ -5,6 +5,7 @@ from pydantic import ValidationError from core.dify_builder.models import NodeOutput, Run, RunVerification from runtime_contracts.builder.execution import ExecutionEvidenceSummary +from services.dify_builder.application import handlers_build, handlers_edit, handlers_fix from services.dify_builder.application.verification import result_card @@ -100,16 +101,14 @@ def test_runtime_attribution_uses_failed_native_resource_not_code_error_text(): @pytest.mark.parametrize("flow", ["build", "edit", "fix"]) -def test_known_plugin_outage_does_not_ask_llm_to_rewrite_graph(flow): +def test_known_plugin_outage_does_not_ask_llm_to_rewrite_graph(flow, mocker): import importlib - from unittest.mock import Mock from core.dify_builder.models import Turn from core.dify_builder.state import PcState from tests.unit_tests.core.dify_builder.fakes import saved_test_context helpers = importlib.import_module(f"tests.unit_tests.core.dify_builder.test_handlers_{flow}") - handlers = importlib.import_module(f"services.dify_builder.application.handlers_{flow}") env, repo = helpers._new_env() state = { "build": PcState.BUILD_TEST_AND_REPAIR, @@ -131,13 +130,14 @@ def test_known_plugin_outage_does_not_ask_llm_to_rewrite_graph(flow): execution_evidence=summary(), ), ) - env.dify.run_draft = Mock(return_value=raw) - env.agent.diagnose = Mock(side_effect=AssertionError("infrastructure is not a graph bug")) - env.agent.propose_repair = Mock(side_effect=AssertionError("infrastructure is not a graph bug")) - handler = getattr( - handlers, - {"build": "handle_test_and_repair", "edit": "handle_test_affected_paths", "fix": "handle_verify"}[flow], - ) + mocker.patch.object(env.dify, "run_draft", return_value=raw) + mocker.patch.object(env.agent, "diagnose", side_effect=AssertionError("infrastructure is not a graph bug")) + mocker.patch.object(env.agent, "propose_repair", side_effect=AssertionError("infrastructure is not a graph bug")) + handler = { + "build": handlers_build.handle_test_and_repair, + "edit": handlers_edit.handle_test_affected_paths, + "fix": handlers_fix.handle_verify, + }[flow] result = handler(env, Turn(actor=helpers._actor()), session, context) assert not result.context.staged_repair assert result.run.dify_run_id == "native" diff --git a/api/tests/unit_tests/services/dify_builder/test_notice_retirement.py b/api/tests/unit_tests/services/dify_builder/test_notice_retirement.py index 43879fc10df..d03dc47dce2 100644 --- a/api/tests/unit_tests/services/dify_builder/test_notice_retirement.py +++ b/api/tests/unit_tests/services/dify_builder/test_notice_retirement.py @@ -3,7 +3,6 @@ from dataclasses import asdict from datetime import datetime from typing import cast -from unittest.mock import MagicMock import pytest @@ -17,8 +16,9 @@ from services.dify_builder.application.handlers_edit import handle_publish as pu from services.dify_builder.application.handlers_fix import handle_publish as publish_fix from services.dify_builder.application.ports import DifyBuilderAgent, DifyPort, Repository from services.dify_builder.application.runner import Env, Handler, Runner -from services.dify_builder.service import DifyBuilderService, SessionLock +from services.dify_builder.service import DifyBuilderService from tests.unit_tests.core.dify_builder.fakes import FakeDifyPort, InMemoryRepository, StubAgent, seed_verified_run +from tests.unit_tests.services.dify_builder.test_service import FakeSessionLock @pytest.mark.parametrize( @@ -56,8 +56,7 @@ def test_recovery_reason_is_in_the_decision_and_old_history_keeps_its_watermark( ), ] repo.create_session(session, DifyBuilderContext(recovery_class=recovery_class, reply_language="ja"), history) - lock = MagicMock(spec=SessionLock) - lock.exists.return_value = False + lock = FakeSessionLock() service = DifyBuilderService(cast(Repository, repo), lock, lambda *_args: None) view = service.get_session_view(session.id, actor) diff --git a/api/tests/unit_tests/services/dify_builder/test_output_evidence.py b/api/tests/unit_tests/services/dify_builder/test_output_evidence.py index d2fa1b34e65..b6f67d2ed2e 100644 --- a/api/tests/unit_tests/services/dify_builder/test_output_evidence.py +++ b/api/tests/unit_tests/services/dify_builder/test_output_evidence.py @@ -1,8 +1,9 @@ -from types import SimpleNamespace +import json import pytest from core.dify_builder.models import Graph, NodeOutput +from models.workflow import ExecutionOffLoadType, WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload, WorkflowRun from services.dify_builder import run_mapping @@ -32,8 +33,14 @@ def test_verification_terminal_outputs(outputs: dict[str, object] | None) -> Non def test_verification_node_availability_and_inputs() -> None: output_values: list[dict[str, object] | None] = [{}, None, {"text": None}] rows = [ - SimpleNamespace( - node_id="tool", title="", status="succeeded", error=None, inputs_dict={"x": 0}, outputs_dict=outputs + WorkflowNodeExecutionModel( + node_id="tool", + title="", + status="succeeded", + error=None, + inputs=json.dumps({"x": 0}), + outputs=json.dumps(outputs) if outputs is not None else None, + offload_data=[], ) for outputs in output_values ] @@ -88,21 +95,25 @@ def test_output_evidence_container_and_retry_unknown() -> None: def test_verification_offloaded_outputs_are_not_complete() -> None: - row = SimpleNamespace( + row = WorkflowNodeExecutionModel( node_id="tool", title="", status="succeeded", error=None, - inputs_dict={}, - outputs_dict={}, - outputs_truncated=True, + inputs="{}", + outputs="{}", + offload_data=[ + WorkflowNodeExecutionOffload( + tenant_id="t", app_id="a", node_execution_id="n", type_=ExecutionOffLoadType.OUTPUTS, file_id="f" + ) + ], ) run = run_mapping.map_run_result({"id": "native", "status": "succeeded"}, [row]) assert run.per_node[0].outputs_available is False def test_verification_native_missing_outputs_stays_unavailable() -> None: - row = SimpleNamespace(id="native", status="succeeded", outputs=None, outputs_dict={}) + row = WorkflowRun(id="native", status="succeeded", outputs=None) assert run_mapping.run_result_data_from_run_row(row)["outputs"] is None @@ -291,16 +302,22 @@ def test_native_agent_offloaded_outputs_do_not_prove_key_absence() -> None: from services.dify_builder.output_evidence import collect_output_findings native_rows = [ - SimpleNamespace( + WorkflowNodeExecutionModel( node_id="tool", title="", status="succeeded", error=None, - inputs_dict={}, - outputs_dict={}, - outputs_truncated=True, + inputs="{}", + outputs="{}", + offload_data=[ + WorkflowNodeExecutionOffload( + tenant_id="t", app_id="a", node_execution_id="n", type_=ExecutionOffLoadType.OUTPUTS, file_id="f" + ) + ], + ), + WorkflowNodeExecutionModel( + node_id="end", title="", status="succeeded", error=None, inputs="{}", outputs="{}", offload_data=[] ), - SimpleNamespace(node_id="end", title="", status="succeeded", error=None, inputs_dict={}, outputs_dict={}), ] run = run_mapping.map_run_result({"id": "native", "status": "succeeded"}, native_rows) finding = collect_output_findings(native_agent_graph(), run.per_node)[0] diff --git a/api/tests/unit_tests/services/dify_builder/test_plan_history.py b/api/tests/unit_tests/services/dify_builder/test_plan_history.py index faa8e1bac09..96e4637c0d0 100644 --- a/api/tests/unit_tests/services/dify_builder/test_plan_history.py +++ b/api/tests/unit_tests/services/dify_builder/test_plan_history.py @@ -1,7 +1,6 @@ """Plan snapshots retain their own overview across pagination and serialization.""" from copy import deepcopy -from unittest.mock import MagicMock import pytest from pydantic import ValidationError @@ -13,6 +12,8 @@ from core.dify_builder.contract import AssistantTurnItem, ExecutionProgress from core.dify_builder.models import Actor, ConversationItem, DifyBuilderContext, EntryMode, Session from core.dify_builder.state import PcState from services.dify_builder.repository import SqlDifyBuilderRepository +from services.dify_builder.service import DifyBuilderService +from tests.unit_tests.services.dify_builder.test_service import FakeSessionLock def test_plan_history_pages_preserve_snapshots_and_cursors(monkeypatch: pytest.MonkeyPatch) -> None: @@ -53,10 +54,7 @@ def test_plan_history_pages_preserve_snapshots_and_cursors(monkeypatch: pytest.M ] ) repo.create_session(session, DifyBuilderContext(), items) - service = MagicMock() - service.get_conversation_page.side_effect = lambda session_id, _actor, **kwargs: repo.list_conversation_page( - session_id, **kwargs - ) + service = DifyBuilderService(repo, FakeSessionLock(), lambda *_args: None) monkeypatch.setattr(controller, "build_service", lambda: service) actor = Actor(account_id=session.owner_account_id, tenant_id=session.tenant_id) diff --git a/api/tests/unit_tests/services/dify_builder/test_plugin_activation_diagnostics.py b/api/tests/unit_tests/services/dify_builder/test_plugin_activation_diagnostics.py index c59e9ac290f..5c3fc39c668 100644 --- a/api/tests/unit_tests/services/dify_builder/test_plugin_activation_diagnostics.py +++ b/api/tests/unit_tests/services/dify_builder/test_plugin_activation_diagnostics.py @@ -2,7 +2,6 @@ import json from contextvars import copy_context -from types import SimpleNamespace import pytest @@ -71,7 +70,7 @@ def _runtime_failure(monkeypatch): envelope = {"code": -500, "message": json.dumps({"error_type": "PluginDaemonInnerError", "message": MESSAGE})} monkeypatch.setattr(client, "_stream_request", lambda *_a, **_k: iter([json.dumps(envelope)])) # Only this test-local object misses the schema cache; shared Redis is untouched. - monkeypatch.setattr("core.plugin.impl.model_runtime.redis_client", SimpleNamespace(get=lambda _: None)) + monkeypatch.setattr("core.plugin.impl.model_runtime.redis_client.get", lambda _: None) runtime = PluginModelRuntime("tenant", "account", client, PluginService) return lambda: runtime.get_model_schema( provider="langgenius/synthetic/synthetic", model_type=ModelType.LLM, model="synthetic", credentials={} diff --git a/api/tests/unit_tests/services/dify_builder/test_progress_bus.py b/api/tests/unit_tests/services/dify_builder/test_progress_bus.py index 91d777fcc21..55f14418a64 100644 --- a/api/tests/unit_tests/services/dify_builder/test_progress_bus.py +++ b/api/tests/unit_tests/services/dify_builder/test_progress_bus.py @@ -3,30 +3,45 @@ import json import threading from collections.abc import Iterator +from contextlib import contextmanager from typing import Self, override -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest +from redis import Redis from libs.broadcast_channel.channel import Subscription, SupportsPreparedSubscription +from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel, Topic from services.dify_builder import progress_bus, wiring SESSION_ID = "11111111-1111-1111-1111-111111111111" +@contextmanager +def channel_for(subscriber=None): + with Redis() as client, patch.object(client, "publish"): + channel = BroadcastChannel(client) + topic = Topic(client, f"dify_builder:{SESSION_ID}") + with ( + patch.object(channel, "topic", return_value=topic) as topic_call, + patch.object(topic, "as_subscriber", return_value=topic if subscriber is None else subscriber), + patch.object(topic, "publish", wraps=topic.publish) as publish_call, + ): + yield channel, topic, topic_call, publish_call + + def test_publish_sends_json_encoded_event_to_the_session_topic() -> None: - fake_channel = MagicMock() event = {"kind": "workflow", "payload": {"event": "workflow_started", "workflow_run_id": "run-1"}} - - with patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=fake_channel): + with ( + channel_for() as (channel, topic, topic_call, publish_call), + patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=channel), + ): progress_bus.publish(SESSION_ID, event) - - fake_channel.topic.assert_called_once_with(f"dify_builder:{SESSION_ID}") - fake_topic = fake_channel.topic.return_value - fake_topic.publish.assert_called_once() - (payload,) = fake_topic.publish.call_args.args - assert isinstance(payload, bytes) - assert json.loads(payload.decode()) == event + topic_call.assert_called_once_with(f"dify_builder:{SESSION_ID}") + publish_call.assert_called_once() + (payload,) = publish_call.call_args.args + assert isinstance(payload, bytes) + assert json.loads(payload.decode()) == event class _Subscription(Subscription): @@ -101,13 +116,13 @@ def test_subscribe_prepares_stream_delivery_boundary_without_activating() -> Non order: list[str] = [] subscription = _Subscription(order) subscriber = _PreparedSubscriber(subscription, order) - fake_channel = MagicMock() - fake_channel.topic.return_value.as_subscriber.return_value = subscriber - - with patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=fake_channel): + with ( + channel_for(subscriber) as (channel, topic, topic_call, publish_call), + patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=channel), + ): result = progress_bus.subscribe(SESSION_ID) - fake_channel.topic.assert_called_once_with(f"dify_builder:{SESSION_ID}") + topic_call.assert_called_once_with(f"dify_builder:{SESSION_ID}") assert result is subscription assert order == ["prepare"] @@ -116,10 +131,10 @@ def test_subscribe_activates_pubsub_before_returning_to_dispatch_caller() -> Non order: list[str] = [] subscription = _Subscription(order) subscriber = _LazySubscriber(subscription, order) - fake_channel = MagicMock() - fake_channel.topic.return_value.as_subscriber.return_value = subscriber - - with patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=fake_channel): + with ( + channel_for(subscriber) as (channel, topic, topic_call, publish_call), + patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=channel), + ): result = progress_bus.subscribe(SESSION_ID) order.append("dispatch") @@ -132,9 +147,6 @@ def test_publish_after_subscribe_cannot_overtake_pubsub_activation_barrier() -> order: list[str] = [] subscription = _AckControlledSubscription(order) subscriber = _LazySubscriber(subscription, order) - fake_channel = MagicMock() - fake_topic = fake_channel.topic.return_value - fake_topic.as_subscriber.return_value = subscriber finished = threading.Event() errors: list[BaseException] = [] @@ -147,13 +159,16 @@ def test_publish_after_subscribe_cannot_overtake_pubsub_activation_barrier() -> except BaseException as error: errors.append(error) - with patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=fake_channel): + with ( + channel_for(subscriber) as (channel, topic, topic_call, publish_call), + patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=channel), + ): caller = threading.Thread(target=subscribe_then_publish) caller.start() try: assert subscription.waiting_for_ack.wait(timeout=1) assert not finished.is_set() - fake_topic.publish.assert_not_called() + publish_call.assert_not_called() subscription.release_ack.set() caller.join(timeout=1) @@ -165,18 +180,16 @@ def test_publish_after_subscribe_cannot_overtake_pubsub_activation_barrier() -> assert errors == [] assert finished.is_set() assert order == ["construct", "activate", "ack", "publish"] - fake_topic.publish.assert_called_once() + publish_call.assert_called_once() def test_subscribe_closes_pubsub_when_activation_fails() -> None: order: list[str] = [] subscription = _Subscription(order, fail_on_enter=True) subscriber = _LazySubscriber(subscription, order) - fake_channel = MagicMock() - fake_channel.topic.return_value.as_subscriber.return_value = subscriber - with ( - patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=fake_channel), + channel_for(subscriber) as (channel, topic, topic_call, publish_call), + patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=channel), pytest.raises(RuntimeError, match="activation failed"), ): progress_bus.subscribe(SESSION_ID) @@ -189,10 +202,10 @@ def test_stream_generator_does_not_reenter_and_closes_prepared_subscription() -> order: list[str] = [] subscription = _TerminalSubscription(order) subscriber = _LazySubscriber(subscription, order) - fake_channel = MagicMock() - fake_channel.topic.return_value.as_subscriber.return_value = subscriber - - with patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=fake_channel): + with ( + channel_for(subscriber) as (channel, topic, topic_call, publish_call), + patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=channel), + ): prepared = progress_bus.subscribe(SESSION_ID) frames = list(wiring.stream_advance_frames({"session_id": SESSION_ID}, prepared, expect_advance=True)) diff --git a/api/tests/unit_tests/services/dify_builder/test_progress_snapshot.py b/api/tests/unit_tests/services/dify_builder/test_progress_snapshot.py index ffabbd14e77..9b4d41e00f3 100644 --- a/api/tests/unit_tests/services/dify_builder/test_progress_snapshot.py +++ b/api/tests/unit_tests/services/dify_builder/test_progress_snapshot.py @@ -1,17 +1,19 @@ """Current-worker progress recovery, including tenant and lease isolation.""" import json +from collections.abc import Iterator from dataclasses import asdict -from unittest.mock import MagicMock +from unittest.mock import patch import pytest +from redis import Redis -from configs import dify_config from core.dify_builder.contract import ExecutionActivity, ExecutionProgress from extensions.redis_names import serialize_redis_name from runtime_contracts.builder.copy_ids import BuilderCopyId from runtime_contracts.builder.presentation import copy_text from services.dify_builder import progress_snapshot, session_lock +from tests.unit_tests.config_override import apply_config_overrides def _snapshot() -> progress_snapshot.ProgressSnapshot: @@ -35,18 +37,18 @@ def _snapshot() -> progress_snapshot.ProgressSnapshot: @pytest.fixture -def redis(monkeypatch: pytest.MonkeyPatch) -> MagicMock: - client = MagicMock() - monkeypatch.setattr(progress_snapshot, "redis_client", client) - monkeypatch.setattr(session_lock, "redis_client", client) - return client +def redis(monkeypatch: pytest.MonkeyPatch) -> Iterator[Redis]: + with Redis() as client, patch.object(client, "get"), patch.object(client, "eval"): + monkeypatch.setattr(progress_snapshot, "redis_client", client) + monkeypatch.setattr(session_lock, "redis_client", client) + yield client @pytest.mark.parametrize("prefix", ["", "deployment"]) def test_save_scopes_snapshot_to_tenant_session_and_activated_owner( - redis: MagicMock, monkeypatch: pytest.MonkeyPatch, prefix: str + redis: Redis, monkeypatch: pytest.MonkeyPatch, prefix: str ) -> None: - monkeypatch.setattr(dify_config, "REDIS_KEY_PREFIX", prefix) + apply_config_overrides(monkeypatch, REDIS_KEY_PREFIX=prefix) snapshot = _snapshot() progress_snapshot.save(snapshot, tenant_id="tenant-1", owner="activated-owner") @@ -63,7 +65,7 @@ def test_save_scopes_snapshot_to_tenant_session_and_activated_owner( assert args[7] == session_lock.active_advance_time_limit() -def test_read_restores_ordered_steps_states_locale_and_node_parent(redis: MagicMock) -> None: +def test_read_restores_ordered_steps_states_locale_and_node_parent(redis: Redis) -> None: snapshot = _snapshot() raw = json.dumps(asdict(snapshot)).encode() redis.get.side_effect = [b"activated-owner", raw, b"activated-owner"] @@ -76,14 +78,14 @@ def test_read_restores_ordered_steps_states_locale_and_node_parent(redis: MagicM @pytest.mark.parametrize("next_owner", [None, b"replacement-owner"]) def test_read_discards_snapshot_if_command_settles_or_changes_worker_during_read( - redis: MagicMock, next_owner: bytes | None + redis: Redis, next_owner: bytes | None ) -> None: redis.get.side_effect = [b"old-owner", json.dumps(asdict(_snapshot())).encode(), next_owner] assert progress_snapshot.read(tenant_id="tenant-1", session_id="session-1") is None -def test_read_never_uses_a_previous_workers_or_another_tenants_snapshot(redis: MagicMock) -> None: +def test_read_never_uses_a_previous_workers_or_another_tenants_snapshot(redis: Redis) -> None: values = { "dify_builder:advance:session-1": b"current-owner", "dify_builder:progress:tenant-1:session-1:previous-owner": json.dumps(asdict(_snapshot())).encode(), @@ -94,7 +96,7 @@ def test_read_never_uses_a_previous_workers_or_another_tenants_snapshot(redis: M assert progress_snapshot.read(tenant_id="tenant-1", session_id="session-1") is None -def test_read_does_not_load_progress_after_lease_release(redis: MagicMock) -> None: +def test_read_does_not_load_progress_after_lease_release(redis: Redis) -> None: redis.get.return_value = None assert progress_snapshot.read(tenant_id="tenant-1", session_id="session-1") is None diff --git a/api/tests/unit_tests/services/dify_builder/test_real_call_consent_service.py b/api/tests/unit_tests/services/dify_builder/test_real_call_consent_service.py index c247c46d606..c68f990fb96 100644 --- a/api/tests/unit_tests/services/dify_builder/test_real_call_consent_service.py +++ b/api/tests/unit_tests/services/dify_builder/test_real_call_consent_service.py @@ -23,7 +23,6 @@ from sqlalchemy import Table, event, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker -from configs import dify_config from core.dify_builder.errors import ConflictError from core.dify_builder.input_schema import schema_hash, start_schema from core.dify_builder.models import Actor, ConversationItem, DifyBuilderContext, MutationIntent @@ -68,6 +67,7 @@ from services.dify_builder.repository import SqlDifyBuilderRepository from services.dify_builder.revision import execution_revision from services.dify_builder.serde import context_from_dict, context_to_dict from services.tools.builder_credential_reference import credential_revision, select_builder_credential +from tests.unit_tests.config_override import apply_config_overrides from tests.unit_tests.core.dify_builder.test_gmail_read_policy import gmail_graph, tool_data from tests.unit_tests.services.dify_builder.test_execution_policy_service import ( owned as owned, # noqa: PLC0414 - shared pytest fixture @@ -172,8 +172,8 @@ def consent_owner( owned: tuple[sessionmaker[Session], Actor, App, Workflow, str, str], monkeypatch: pytest.MonkeyPatch ) -> Owner: factory, actor, app, workflow, session_id, test_input_id = owned - monkeypatch.setattr(dify_config, "SECRET_KEY", SECRET) - monkeypatch.setattr(dify_config, "DIFY_BUILDER_CONFIRMED_TOOL_ENABLED", True) + apply_config_overrides(monkeypatch, SECRET_KEY=SECRET) + apply_config_overrides(monkeypatch, DIFY_BUILDER_CONFIRMED_TOOL_ENABLED=True) # SYNTHETIC qualification constants solely so the exact-match code paths run. monkeypatch.setattr(gmail_read_policy, "QUALIFIED_GMAIL_ARTIFACT", SYNTHETIC_ARTIFACT) monkeypatch.setattr(gmail_read_policy, "QUALIFIED_GMAIL_SCHEMA_DIGEST", SYNTHETIC_SCHEMA) @@ -470,14 +470,14 @@ def test_unqualified_profile_or_disabled_flag_refuses_before_any_row( consent_owner: Owner, monkeypatch: pytest.MonkeyPatch ) -> None: owner = consent_owner - monkeypatch.setattr(dify_config, "DIFY_BUILDER_CONFIRMED_TOOL_ENABLED", False) + apply_config_overrides(monkeypatch, DIFY_BUILDER_CONFIRMED_TOOL_ENABLED=False) with pytest.raises(BuilderExecutionPolicyError, match="unsupported_action"): prepare(owner) assert owner.metadata_reads == [] # Restore the real unavailable profile and the real metadata owner. monkeypatch.undo() - monkeypatch.setattr(dify_config, "SECRET_KEY", SECRET) - monkeypatch.setattr(dify_config, "DIFY_BUILDER_CONFIRMED_TOOL_ENABLED", True) + apply_config_overrides(monkeypatch, SECRET_KEY=SECRET) + apply_config_overrides(monkeypatch, DIFY_BUILDER_CONFIRMED_TOOL_ENABLED=True) assert gmail_read_policy.QUALIFIED_GMAIL_ARTIFACT is None with pytest.raises(BuilderExecutionPolicyError, match="unsupported_action"): prepare(owner) @@ -641,7 +641,7 @@ def test_prepare_refusals_leave_no_row( assert session is not None session.current_state = str(PcState.BUILD_TEST_AND_REPAIR) elif change == "rotated_scope_key": - monkeypatch.setattr(dify_config, "SECRET_KEY", "") + apply_config_overrides(monkeypatch, SECRET_KEY="") with pytest.raises(BuilderExecutionPolicyError, match=reason): prepare(owner, **changes) assert owner.consents() == 0 @@ -653,7 +653,7 @@ def test_permission_denial_is_checked_outside_sql_and_creates_nothing( from services.enterprise.rbac_service import RBACService owner = consent_owner - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr(RBACService.CheckAccess, "check", lambda *_args, **_kwargs: False) with pytest.raises(BuilderExecutionPolicyError, match="execution_permission_denied"): prepare(owner) @@ -787,7 +787,7 @@ def test_scope_change_before_confirm_invalidates( assert grant is not None db.delete(grant) elif change == "scope_key": - monkeypatch.setattr(dify_config, "SECRET_KEY", "rotated-consent-secret") + apply_config_overrides(monkeypatch, SECRET_KEY="rotated-consent-secret") with pytest.raises(BuilderExecutionPolicyError): confirm(owner, prepared) current = get(owner, prepared.consent_id) @@ -1081,7 +1081,7 @@ def test_known_pre_phase_denial_after_claim_is_failed( else: from services.enterprise.rbac_service import RBACService - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr(RBACService.CheckAccess, "check", lambda *_args, **_kwargs: False) with pytest.raises(BuilderExecutionPolicyError): owner.service().claim_phase(binding=binding, phase="business", final_parameters=parameters) @@ -1363,7 +1363,10 @@ class LockClock: self.jump_to: datetime | None = None self.jumps = 0 module = service_module() - original: Callable[[Session, str | None], object] = getattr(module, lock_name) + original: Callable[[Session, str | None], object] = { + "_lock_consent": module._lock_consent, + "_lock_request": module._lock_request, + }[lock_name] def waiting_lock(db: Session, ident: str | None) -> object: row = original(db, ident) diff --git a/api/tests/unit_tests/services/dify_builder/test_real_call_metadata.py b/api/tests/unit_tests/services/dify_builder/test_real_call_metadata.py index 37218325894..6590140c1eb 100644 --- a/api/tests/unit_tests/services/dify_builder/test_real_call_metadata.py +++ b/api/tests/unit_tests/services/dify_builder/test_real_call_metadata.py @@ -8,7 +8,6 @@ exact matching; they are not evidence about any installed or deployed artifact. from collections.abc import Iterator from datetime import UTC, datetime -from unittest.mock import Mock import pytest @@ -132,7 +131,7 @@ class FakeMetadata: @pytest.fixture(autouse=True) -def forbidden_effects(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: +def forbidden_effects(mocker) -> Iterator[None]: """Metadata reads only: invoke/validate/runtime/OAuth/decrypt/DB sessions fail the test.""" from sqlalchemy.orm import Session @@ -159,8 +158,7 @@ def forbidden_effects(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: ] spies = [] for owner, name in targets: - spy = Mock(side_effect=AssertionError("unexpected effect")) - monkeypatch.setattr(owner, name, spy) + spy = mocker.patch.object(owner, name, side_effect=AssertionError("unexpected effect")) spies.append(spy) yield for spy in spies: diff --git a/api/tests/unit_tests/services/dify_builder/test_recovery.py b/api/tests/unit_tests/services/dify_builder/test_recovery.py index cefb94e21c5..7fb11cc3bb4 100644 --- a/api/tests/unit_tests/services/dify_builder/test_recovery.py +++ b/api/tests/unit_tests/services/dify_builder/test_recovery.py @@ -1,6 +1,5 @@ from datetime import datetime from typing import override -from unittest.mock import MagicMock import pytest @@ -9,8 +8,9 @@ from core.dify_builder.models import Action, Actor, DifyBuilderContext, EntryMod from core.dify_builder.state import PcState from services.dify_builder.application.handlers_edit import edit_registry from services.dify_builder.application.runner import Env, Runner -from services.dify_builder.service import DifyBuilderService, SessionLock, resolve_submitted_action +from services.dify_builder.service import DifyBuilderService from tests.unit_tests.core.dify_builder.fakes import FakeDifyPort, InMemoryRepository, StubAgent +from tests.unit_tests.services.dify_builder.test_service import FakeSessionLock, resolve_submitted_action type EditFlow = tuple[DifyBuilderService, InMemoryRepository, FakeDifyPort, ImpactAgent, Session, Actor] @@ -71,12 +71,13 @@ def edit_flow() -> EditFlow: Env(dify=dify, agent=agent, repo=repository, now=lambda: datetime.min), edit_registry(), ) - lock = MagicMock(spec=SessionLock) - lock.exists.return_value = False - lock.acquire.return_value = "lease" + lock = FakeSessionLock() def enqueue(session_id: str, action: Action, actor: Actor, _token: str) -> None: - runner.advance(session_id, Turn(action=action, actor=actor)) + try: + runner.advance(session_id, Turn(action=action, actor=actor)) + finally: + lock.release(session_id, _token) service = DifyBuilderService( repository, diff --git a/api/tests/unit_tests/services/dify_builder/test_resource_grounding.py b/api/tests/unit_tests/services/dify_builder/test_resource_grounding.py index fd30bd1662c..4f9e94db692 100644 --- a/api/tests/unit_tests/services/dify_builder/test_resource_grounding.py +++ b/api/tests/unit_tests/services/dify_builder/test_resource_grounding.py @@ -1,9 +1,11 @@ """Real catalogue identities and truthful metadata ground Builder edits.""" -from types import SimpleNamespace from unittest.mock import patch +from core.entities.model_entities import ModelStatus +from graphon.model_runtime.entities.model_entities import ModelPropertyKey from services.dify_builder.agent import resources +from tests.unit_tests.services.dify_builder.test_resources import _provider_models def catalogue(models): @@ -29,10 +31,8 @@ def test_catalogue_failure_is_distinct_from_successful_empty(): def test_model_binding_uses_exact_source_ids_without_claiming_live_health(): - model = SimpleNamespace( - model="claude-fable-5-1", status="active", deprecated=False, features=[], model_properties={} - ) - with catalogue([SimpleNamespace(provider="langgenius/anthropic/anthropic", models=[model])]): + provider = _provider_models("langgenius/anthropic/anthropic", ["claude-fable-5-1"]) + with catalogue([provider]): ref = resources.list_tenant_resources("tenant").models[0] assert ref.binding == {"provider": "langgenius/anthropic/anthropic", "name": "claude-fable-5-1"} assert ref.availability["configuration"] == "configured" @@ -41,14 +41,16 @@ def test_model_binding_uses_exact_source_ids_without_claiming_live_health(): def test_explicitly_deprecated_models_are_not_candidates(): - model = SimpleNamespace(model="retired", status="active", deprecated=True) - with catalogue([SimpleNamespace(provider="provider", models=[model])]): + provider = _provider_models("provider", ["retired"]) + provider.models[0].deprecated = True + with catalogue([provider]): assert resources.list_tenant_resources("tenant").models == [] def test_visible_model_without_configuration_is_not_ready(): - model = SimpleNamespace(model="needs-setup", status="no-configure", deprecated=False) - with catalogue([SimpleNamespace(provider="provider", models=[model])]): + provider = _provider_models("provider", ["needs-setup"]) + provider.models[0].status = ModelStatus.NO_CONFIGURE + with catalogue([provider]): ref = resources.list_tenant_resources("tenant").models[0] assert ref.readiness == "missing_config" assert ref.availability["runtime"] == "unverified" @@ -661,10 +663,9 @@ def test_selected_model_preserves_declared_mode_for_compatibility(): from services.dify_builder.resource_grounding import ResourceBindingError, validate_changed_resources - model = SimpleNamespace( - model="new", status="active", deprecated=False, features=[], model_properties={"mode": "completion"} - ) - with catalogue([SimpleNamespace(provider="provider", models=[model])]): + provider = _provider_models("provider", ["new"]) + provider.models[0].model_properties = {ModelPropertyKey.MODE: "completion"} + with catalogue([provider]): directory = resources.list_tenant_resources("tenant") graph = model_graph(name="new") # existing chat mode cannot silently become a completion model with pytest.raises(ResourceBindingError, match="resource_configuration_incompatible"): diff --git a/api/tests/unit_tests/services/dify_builder/test_resources.py b/api/tests/unit_tests/services/dify_builder/test_resources.py index bcddedea68f..6ae207cef95 100644 --- a/api/tests/unit_tests/services/dify_builder/test_resources.py +++ b/api/tests/unit_tests/services/dify_builder/test_resources.py @@ -1,6 +1,5 @@ import logging from collections.abc import Sequence -from dataclasses import dataclass from unittest.mock import patch import pytest @@ -29,17 +28,34 @@ from tests.unit_tests.services.dify_builder.installed_tool_inventory import ( ) +def _provider_models(provider: str, models: list[str]): + from core.entities.model_entities import ModelStatus, ProviderModelWithStatusEntity + from graphon.model_runtime.entities.common_entities import I18nObject + from graphon.model_runtime.entities.model_entities import FetchFrom, ModelType + from services.entities.model_provider_entities import CustomConfigurationStatus, ProviderWithModelsResponse + + return ProviderWithModelsResponse( + tenant_id="t1", + provider=provider, + label=I18nObject(en_US=provider), + status=CustomConfigurationStatus.ACTIVE, + models=[ + ProviderModelWithStatusEntity( + model=name, + label=I18nObject(en_US=name), + model_type=ModelType.LLM, + fetch_from=FetchFrom.PREDEFINED_MODEL, + model_properties={}, + status=ModelStatus.ACTIVE, + ) + for name in models + ], + ) + + def test_list_tenant_resources_normalizes_all_three_kinds() -> None: from services.dify_builder.agent import resources - @dataclass - class _Model: - model: str - - class _ProvResp: - def __init__(self, p: str, ms: list[str]) -> None: - self.provider, self.models = p, [_Model(m) for m in ms] - class _DS: def __init__(self, i: str, n: str) -> None: self.id, self.name = i, n @@ -51,7 +67,7 @@ def test_list_tenant_resources_normalizes_all_three_kinds() -> None: "description": "", } with ( - patch.object(resources, "_list_models", return_value=[_ProvResp("anthropic", ["claude-opus-4-8"])]), + patch.object(resources, "_list_models", return_value=[_provider_models("anthropic", ["claude-opus-4-8"])]), patch.object(resources, "_list_datasets", return_value=[_DS("kb-1", "Company KB")]), patch.object(resources, "_list_tools", return_value=[tool_record]), ): @@ -232,16 +248,8 @@ def test_authorization_lookup_failure_is_never_reported_ready(caplog: pytest.Log def test_list_tenant_resources_logs_when_a_source_fails(caplog: pytest.LogCaptureFixture) -> None: from services.dify_builder.agent import resources - @dataclass - class _Model: - model: str - - class _ProvResp: - def __init__(self, p: str, ms: list[str]) -> None: - self.provider, self.models = p, [_Model(m) for m in ms] - with ( - patch.object(resources, "_list_models", return_value=[_ProvResp("anthropic", ["claude-opus-4-8"])]), + patch.object(resources, "_list_models", return_value=[_provider_models("anthropic", ["claude-opus-4-8"])]), patch.object(resources, "_list_datasets", side_effect=RuntimeError("db down")), patch.object(resources, "_list_tools", return_value=[]), caplog.at_level(logging.WARNING, logger="services.dify_builder.agent.resources"), @@ -537,19 +545,13 @@ def test_a_plugin_daemon_outage_keeps_hardcoded_tools_in_the_inventory( def test_models_and_datasets_keep_their_semantics_for_an_actor(monkeypatch: pytest.MonkeyPatch) -> None: from services.dify_builder.agent import resources - @dataclass - class _Model: - model: str - - class _ProvResp: - def __init__(self, p: str, ms: list[str]) -> None: - self.provider, self.models = p, [_Model(m) for m in ms] - class _DS: def __init__(self, i: str, n: str) -> None: self.id, self.name = i, n - monkeypatch.setattr(resources, "_list_models", lambda _tenant_id: [_ProvResp("anthropic", ["claude-opus-4-8"])]) + monkeypatch.setattr( + resources, "_list_models", lambda _tenant_id: [_provider_models("anthropic", ["claude-opus-4-8"])] + ) monkeypatch.setattr(resources, "_list_datasets", lambda _tenant_id: [_DS("kb-1", "Company KB")]) monkeypatch.setattr(resources, "_list_tools", lambda _tenant_id: []) diff --git a/api/tests/unit_tests/services/dify_builder/test_revision.py b/api/tests/unit_tests/services/dify_builder/test_revision.py index 5fcfd0db2c6..25bdb56f052 100644 --- a/api/tests/unit_tests/services/dify_builder/test_revision.py +++ b/api/tests/unit_tests/services/dify_builder/test_revision.py @@ -2,7 +2,7 @@ import json from collections.abc import Callable from copy import deepcopy from operator import itemgetter -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -12,10 +12,11 @@ from core.dify_builder.state import PcState from graphon.variables import SecretVariable, StringVariable from models.workflow import Workflow from services.app.console_gateway import _CHATFLOW_STARTER_GRAPH -from services.dify_builder.application.ports import Repository from services.dify_builder.graph_ops import apply_connect, apply_create_node from services.dify_builder.revision import executable_graph_revision, execution_revision, merge_canvas_presentation -from services.dify_builder.service import DifyBuilderService, SessionLock +from services.dify_builder.service import DifyBuilderService +from tests.unit_tests.core.dify_builder.fakes import InMemoryRepository +from tests.unit_tests.services.dify_builder.test_service import FakeSessionLock @pytest.fixture @@ -174,7 +175,7 @@ def _save_canvas_layout(workflow: Workflow) -> None: [(PcState.FIX_AWAIT_APPROVAL, "approve_repair"), (PcState.FIX_AWAIT_VERIFY, "run_verify")], ) def test_actions_accept_canvas_save_after_builder_writes_graph( - builder_workflow: Workflow, state: PcState, kind: str + mocker, builder_workflow: Workflow, state: PcState, kind: str ) -> None: session = Session( id="session-1", @@ -185,29 +186,35 @@ def test_actions_accept_canvas_save_after_builder_writes_graph( current_state=state, version=1, ) - repo = MagicMock(spec=Repository) - repo.get_session.return_value = ( - session, - DifyBuilderContext( - last_snapshot_hash=execution_revision(builder_workflow), - staged_repair=[ - MutationIntent( - op="set_node_config", - args={"node_id": "code", "path": "code", "value": "def main(): return {'result': 'repaired'}"}, - ) - ] - if kind == "approve_repair" - else [], + repo = InMemoryRepository() + mocker.patch.object( + repo, + "get_session", + return_value=( + session, + DifyBuilderContext( + last_snapshot_hash=execution_revision(builder_workflow), + staged_repair=[ + MutationIntent( + op="set_node_config", + args={"node_id": "code", "path": "code", "value": "def main(): return {'result': 'repaired'}"}, + ) + ] + if kind == "approve_repair" + else [], + ), ), ) - repo.get_latest_conversation_item.return_value = None - lock = MagicMock(spec=SessionLock) - lock.exists.return_value = False - enqueued = MagicMock() + lock = FakeSessionLock() + enqueued = [] + + def enqueue(*args): + enqueued.append(args) + service = DifyBuilderService( repo, lock, - enqueued, + enqueue, get_app_revision_fn=lambda _app_id, _actor: execution_revision(builder_workflow), ) actor = Actor(account_id=session.owner_account_id, tenant_id=session.tenant_id) @@ -225,7 +232,7 @@ def test_actions_accept_canvas_save_after_builder_writes_graph( assert result.app_revision is not None assert result.app_revision.conflicted is False assert result.app_revision.current == view.app_revision.current - enqueued.assert_called_once_with(session.id, action, actor, lock.acquire.return_value) + assert enqueued == [(session.id, action, actor, lock._held[session.id])] # Even if the client fetches the latest revision, an actual code edit must # still conflict with the configuration that Builder planned against. @@ -238,10 +245,12 @@ def test_actions_accept_canvas_save_after_builder_writes_graph( action.base_app_revision = execution_revision(builder_workflow) with pytest.raises(ConflictError, match="draft changed outside Builder"): service.submit_action(session.id, actor, action) - assert enqueued.call_count == 1 + assert len(enqueued) == 1 -def test_chatflow_requirements_accept_canvas_node_reordering() -> None: +def test_chatflow_requirements_accept_canvas_node_reordering( + mocker, +) -> None: workflow = Workflow( tenant_id="tenant-1", graph=json.dumps(_CHATFLOW_STARTER_GRAPH), @@ -258,19 +267,25 @@ def test_chatflow_requirements_accept_canvas_node_reordering() -> None: current_state=PcState.BUILD_GOAL_ANALYSIS, version=2, ) - repo = MagicMock(spec=Repository) - repo.get_session.return_value = ( - session, - DifyBuilderContext(last_snapshot_hash=execution_revision(workflow)), + repo = InMemoryRepository() + mocker.patch.object( + repo, + "get_session", + return_value=( + session, + DifyBuilderContext(last_snapshot_hash=execution_revision(workflow)), + ), ) - repo.get_latest_conversation_item.return_value = None - lock = MagicMock(spec=SessionLock) - lock.exists.return_value = False - enqueued = MagicMock() + lock = FakeSessionLock() + enqueued = [] + + def enqueue(*args): + enqueued.append(args) + service = DifyBuilderService( repo, lock, - enqueued, + enqueue, get_app_revision_fn=lambda _app_id, _actor: execution_revision(workflow), ) actor = Actor(account_id=session.owner_account_id, tenant_id=session.tenant_id) @@ -294,7 +309,7 @@ def test_chatflow_requirements_accept_canvas_node_reordering() -> None: assert result.app_revision is not None assert result.app_revision.conflicted is False assert result.app_revision.current == view.app_revision.current - enqueued.assert_called_once_with(session.id, action, actor, lock.acquire.return_value) + assert enqueued == [(session.id, action, actor, lock._held[session.id])] llm["data"]["prompt_template"][0]["text"] = "An actual configuration edit" workflow.graph = json.dumps(graph) @@ -303,7 +318,7 @@ def test_chatflow_requirements_accept_canvas_node_reordering() -> None: action.base_app_revision = execution_revision(workflow) with pytest.raises(ConflictError, match="draft changed outside Builder"): service.submit_action(session.id, actor, action) - assert enqueued.call_count == 1 + assert len(enqueued) == 1 def test_canvas_hydration_and_transient_state_preserve_revision(workflow: Workflow) -> None: diff --git a/api/tests/unit_tests/services/dify_builder/test_run_mapping.py b/api/tests/unit_tests/services/dify_builder/test_run_mapping.py index 0e9c3a94ed2..324634e7f06 100644 --- a/api/tests/unit_tests/services/dify_builder/test_run_mapping.py +++ b/api/tests/unit_tests/services/dify_builder/test_run_mapping.py @@ -4,18 +4,17 @@ strings; their mappings and per-node rows supply presentation data, not trusted execution evidence or publication authority. These mapping functions are pure — no DB, no services, no I/O — so tests use -``SimpleNamespace`` stand-ins for the node-execution rows instead of the -real ``WorkflowNodeExecutionModel``. +real ``WorkflowRun`` and ``WorkflowNodeExecutionModel`` rows without persistence. """ import json from dataclasses import replace -from types import SimpleNamespace import pytest from core.dify_builder.models import Graph, NodeEvent, NodeOutput, Run from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus +from models.workflow import WorkflowNodeExecutionModel, WorkflowRun from services.dify_builder.application.verification import ( is_execution_policy_blocker, publication_decision, @@ -46,15 +45,16 @@ def _node_exec( error: str | None = None, outputs_dict: dict[str, object] | None = None, inputs_dict: dict[str, object] | None = None, -) -> SimpleNamespace: - return SimpleNamespace( +) -> WorkflowNodeExecutionModel: + return WorkflowNodeExecutionModel( node_id=node_id, node_type=node_type, title=title, status=status, error=error, - outputs_dict=outputs_dict or {}, - inputs_dict=inputs_dict or {}, + outputs=json.dumps(outputs_dict or {}), + inputs=json.dumps(inputs_dict or {}), + offload_data=[], ) @@ -387,7 +387,7 @@ def test_a_frame_with_no_run_id_yields_empty_string() -> None: def test_run_row_maps_to_the_shape_map_run_result_expects() -> None: - row = SimpleNamespace( + row = WorkflowRun( id="run-1", status=WorkflowExecutionStatus.SUCCEEDED, error=None, @@ -569,7 +569,16 @@ def test_result_mapping_cannot_promote_untrusted_safety_claims(source: str, stat projected = run_result_data_from_terminal_chunk(chunk) assert projected is not None else: - projected = run_result_data_from_run_row(SimpleNamespace(**data, outputs_dict=data["outputs"])) + projected = run_result_data_from_run_row( + WorkflowRun( + id=data["id"], + status=data["status"], + outputs=json.dumps(data["outputs"]), + error=data["error"], + elapsed_time=data["elapsed_time"], + total_tokens=data["total_tokens"], + ) + ) run = map_run_result(projected, [_node_exec("end", "end", "End", WorkflowNodeExecutionStatus.SUCCEEDED)]) assert run.dify_run_id == "run-1" diff --git a/api/tests/unit_tests/services/dify_builder/test_service.py b/api/tests/unit_tests/services/dify_builder/test_service.py index 27ac177833c..5fd889c2314 100644 --- a/api/tests/unit_tests/services/dify_builder/test_service.py +++ b/api/tests/unit_tests/services/dify_builder/test_service.py @@ -1613,7 +1613,11 @@ def test_create_stream_closes_subscription_when_state_serialization_fails( monkeypatch.setattr(service_module, "asdict", lambda _view: (_ for _ in ()).throw(RuntimeError("projection"))) with pytest.raises(RuntimeError, match="projection"): - getattr(svc, method_name)(app_id=APP_ID, actor=actor, **kwargs) + { + "create_build_session_stream": svc.create_build_session_stream, + "create_edit_session_stream": svc.create_edit_session_stream, + "create_fix_session_stream": svc.create_fix_session_stream, + }[method_name](app_id=APP_ID, actor=actor, **kwargs) assert subscription.closed is True diff --git a/api/tests/unit_tests/services/dify_builder/test_service_model_config.py b/api/tests/unit_tests/services/dify_builder/test_service_model_config.py index 6fea83dc3e6..d2117edc661 100644 --- a/api/tests/unit_tests/services/dify_builder/test_service_model_config.py +++ b/api/tests/unit_tests/services/dify_builder/test_service_model_config.py @@ -1,5 +1,4 @@ from typing import Never, override -from unittest.mock import Mock import pytest @@ -43,12 +42,27 @@ class _FakeLock: return True +class SchedulingBoundary: + def __init__(self, *, deny_access: bool = False): + self.deny_access = deny_access + + def authorize(self, _actor, _app_id, _access): + if self.deny_access: + raise NotFoundError() + + def enqueue(self, *_args, **_kwargs): + raise AssertionError("unavailable model must not dispatch") + + def subscribe(self, _session_id): + raise AssertionError("unavailable model must not subscribe") + + @pytest.fixture def actor() -> Actor: return Actor(account_id="acc-1", tenant_id="t1") -def _service(repo: Mock | InMemoryRepository) -> DifyBuilderService: +def _service(repo: InMemoryRepository) -> DifyBuilderService: # enqueue is a no-op; dispatch still acquires/releases the fake lock. return DifyBuilderService(repo, _FakeLock(), lambda *a, **k: None) # noqa: ARG005 @@ -64,9 +78,8 @@ def test_build_session_stamps_model_config(monkeypatch: pytest.MonkeyPatch, acto assert repo.created_fc.model_config == MC -def test_build_session_freezes_default_model_config(monkeypatch: pytest.MonkeyPatch, actor: Actor) -> None: - validate = Mock(return_value=MC) - monkeypatch.setattr(service_mod, "validate_model_config", validate) +def test_build_session_freezes_default_model_config(mocker, monkeypatch: pytest.MonkeyPatch, actor: Actor) -> None: + validate = mocker.patch.object(service_mod, "validate_model_config", return_value=MC) repo = _FakeRepo() svc = _service(repo) monkeypatch.setattr(svc, "get_session_view", lambda sid, a: "VIEW") # noqa: ARG005 @@ -89,14 +102,17 @@ def test_invalid_model_config_raises_before_persist(monkeypatch: pytest.MonkeyPa @pytest.mark.parametrize("scenario", ["build", "edit", "fix"]) -def test_authorization_precedes_model_resolution(monkeypatch: pytest.MonkeyPatch, actor: Actor, scenario: str) -> None: - validate = Mock(return_value=MC) - monkeypatch.setattr(service_mod, "validate_model_config", validate) - repo = Mock() - svc = DifyBuilderService(repo, Mock(), Mock(), authorize_app_fn=Mock(side_effect=NotFoundError())) +def test_authorization_precedes_model_resolution(mocker, actor: Actor, scenario: str) -> None: + validate = mocker.patch.object(service_mod, "validate_model_config", return_value=MC) + repo = InMemoryRepository() + mocker.spy(repo, "create_session") + boundary = SchedulingBoundary(deny_access=True) + svc = DifyBuilderService(repo, _FakeLock(), boundary.enqueue, authorize_app_fn=boundary.authorize) kwargs = {"failed_run_id": "run-1"} if scenario == "fix" else {"goal_text": "Build it"} with pytest.raises(NotFoundError): - getattr(svc, f"create_{scenario}_session")(app_id="app-1", actor=actor, **kwargs) + {"build": svc.create_build_session, "edit": svc.create_edit_session, "fix": svc.create_fix_session}[scenario]( + app_id="app-1", actor=actor, **kwargs + ) validate.assert_not_called() repo.create_session.assert_not_called() @@ -105,18 +121,30 @@ def test_authorization_precedes_model_resolution(monkeypatch: pytest.MonkeyPatch @pytest.mark.parametrize("streaming", [False, True]) @pytest.mark.parametrize("config", [None, MC]) def test_unavailable_model_rejected_before_session_subscription_or_dispatch( - monkeypatch: pytest.MonkeyPatch, actor: Actor, scenario: str, streaming: bool, config: dict[str, object] | None + mocker, actor: Actor, scenario: str, streaming: bool, config: dict[str, object] | None ) -> None: - validate = Mock(side_effect=ModelUnavailableError("unavailable")) - monkeypatch.setattr(service_mod, "validate_model_config", validate) - repo = Mock() - lock = Mock() - enqueue = Mock() - subscribe = Mock() - authorize = Mock() + validate = mocker.patch.object( + service_mod, "validate_model_config", side_effect=ModelUnavailableError("unavailable") + ) + repo = InMemoryRepository() + mocker.spy(repo, "create_session") + mocker.spy(repo, "save_run") + lock = _FakeLock() + mocker.spy(lock, "acquire") + boundary = SchedulingBoundary() + enqueue = mocker.spy(boundary, "enqueue") + subscribe = mocker.spy(boundary, "subscribe") + authorize = mocker.spy(boundary, "authorize") svc = DifyBuilderService(repo, lock, enqueue, subscribe_fn=subscribe, authorize_app_fn=authorize) mode = "fix" if scenario == "checklist" else scenario - create = getattr(svc, f"create_{mode}_session{'_stream' if streaming else ''}") + create = { + ("build", False): svc.create_build_session, + ("edit", False): svc.create_edit_session, + ("fix", False): svc.create_fix_session, + ("build", True): svc.create_build_session_stream, + ("edit", True): svc.create_edit_session_stream, + ("fix", True): svc.create_fix_session_stream, + }[(mode, streaming)] kwargs = {"goal_text": "Build it"} if mode != "fix" else {"failed_run_id": "run-1"} if scenario == "checklist": from core.dify_builder.models import ChecklistError diff --git a/api/tests/unit_tests/services/dify_builder/test_session_lock.py b/api/tests/unit_tests/services/dify_builder/test_session_lock.py index 819afac5cc6..d973fb99f83 100644 --- a/api/tests/unit_tests/services/dify_builder/test_session_lock.py +++ b/api/tests/unit_tests/services/dify_builder/test_session_lock.py @@ -15,6 +15,7 @@ import pytest from configs import dify_config from extensions.redis_names import serialize_redis_name from services.dify_builder import session_lock +from tests.unit_tests.config_override import apply_config_overrides SESSION_ID = "11111111-1111-1111-1111-111111111111" @@ -183,7 +184,7 @@ def test_release_frees_the_lock_when_redis_key_prefix_is_set(monkeypatch: pytest that serialization, release()'s eval reads the unprefixed key (always nil), the compare-del never matches, and the lock is never freed until its TTL expires. """ - monkeypatch.setattr(dify_config, "REDIS_KEY_PREFIX", "px") + apply_config_overrides(monkeypatch, REDIS_KEY_PREFIX="px") fake = FakeRedis() with patch("services.dify_builder.session_lock.redis_client", fake): token = session_lock.acquire(SESSION_ID) diff --git a/api/tests/unit_tests/services/dify_builder/test_session_ui_localization.py b/api/tests/unit_tests/services/dify_builder/test_session_ui_localization.py index ec2f51da7dc..9fa62adf5d2 100644 --- a/api/tests/unit_tests/services/dify_builder/test_session_ui_localization.py +++ b/api/tests/unit_tests/services/dify_builder/test_session_ui_localization.py @@ -2,7 +2,6 @@ import json from collections.abc import Sequence -from types import SimpleNamespace from typing import Never import pytest @@ -17,6 +16,7 @@ from runtime_contracts.builder.presentation import copy_text from services.dify_builder.serde import context_from_dict, context_to_dict from services.dify_builder.service import DifyBuilderService from tests.unit_tests.core.dify_builder.fakes import InMemoryRepository +from tests.unit_tests.services.dify_builder.test_service import FakeSessionLock def _service( @@ -27,7 +27,7 @@ def _service( app_id="app", tenant_id="tenant", owner_account_id="account", entry_mode=EntryMode.BUILD, current_state=state ) repo.create_session(session, context, list(items)) - return DifyBuilderService(repo, SimpleNamespace(exists=lambda _: False), lambda *_: None), session + return DifyBuilderService(repo, FakeSessionLock(), lambda *_: None), session def _actor() -> Actor: 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 f2b6cba30ea..0e70e597048 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 @@ -39,7 +39,7 @@ from tests.unit_tests.services.dify_builder.test_execution_policy_service import @pytest.fixture(autouse=True) -def no_jinja_compilation(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: +def no_jinja_compilation(mocker) -> Iterator[None]: """Restricted rendering interprets finite AST only; lex/parse stay real.""" from jinja2 import Environment, Template, debug @@ -50,8 +50,9 @@ def no_jinja_compilation(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: (debug, ("rewrite_traceback_stack", "fake_traceback")), ): for name in names: - spy = Mock(side_effect=AssertionError(f"Jinja compilation/rendering reached: {name}")) - monkeypatch.setattr(owner, name, spy) + spy = mocker.patch.object( + owner, name, side_effect=AssertionError(f"Jinja compilation/rendering reached: {name}") + ) spies.append(spy) yield # Assert even if a renderer catches the forbidden call and reports failure. diff --git a/api/tests/unit_tests/services/dify_builder/test_trace_diagnostics.py b/api/tests/unit_tests/services/dify_builder/test_trace_diagnostics.py index 37489668f7b..7297423827f 100644 --- a/api/tests/unit_tests/services/dify_builder/test_trace_diagnostics.py +++ b/api/tests/unit_tests/services/dify_builder/test_trace_diagnostics.py @@ -1,33 +1,64 @@ """Safe diagnostics must survive the real cognition boundary without response data.""" import json -from types import SimpleNamespace import pytest +from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage from services.dify_builder import trace_diagnostics as diagnostics from services.dify_builder.agent import build, llm from services.dify_builder.agent.llm_agent import LlmBuilderAgent +from services.dify_builder.agent.resources import TenantResources from tests.unit_tests.core.dify_builder.test_runner import _new_env, _session +from tests.unit_tests.core.model_fixtures import make_model_instance from tests.unit_tests.services.dify_builder.test_engine_on_sql_repo import engine, repo # noqa: F401 -def test_provider_failure_is_scoped_safe_and_recoverable(monkeypatch): - class BrokenModel: - def invoke_llm(self, **_kwargs): - raise ConnectionRefusedError("secret-token https://host/?key=secret") +@pytest.fixture +def diagnostic_model(monkeypatch): + """Keep the actual model owner and control only its provider invocation.""" + def create(text="{}", *, failure=None, stream_failure=None, metadata=True): + model = make_model_instance(provider="langgenius/openai/openai", model="gpt-4") + + def invoke(**_kwargs): + if failure is not None: + raise failure + if stream_failure is not None: + + def chunks(): + raise stream_failure + yield + + return chunks() + return LLMResult(model="gpt-4", message=AssistantPromptMessage(content=text), usage=LLMUsage.empty_usage()) + + monkeypatch.setattr(model, "invoke_llm", invoke) + if not metadata: + del model.provider + del model.model_name + return model + + return create + + +def test_provider_failure_is_scoped_safe_and_recoverable(monkeypatch, diagnostic_model): monkeypatch.setattr( - build, "planning_catalogue", lambda *_a, **_k: SimpleNamespace(tools=[], agents=[], agent_source_error=None) + build, "planning_catalogue", lambda *_a, **_k: TenantResources(models=[], datasets=[], tools=[]) ) agent = LlmBuilderAgent("tenant") - agent._model_instance = BrokenModel() + agent._model_instance = diagnostic_model( + failure=ConnectionRefusedError("secret-token https://host/?key=secret"), metadata=False + ) records = [] agent.set_diagnostic_callback(records.append) assert agent.propose_plan_v1({}, goal_text="private prompt") is None provider = next(record for record in records if record["code"] == "provider_invocation_failed") assert provider["span_id"] == "propose-plan-v1" assert provider["exception_type"] == "ConnectionRefusedError" + assert "provider" not in provider + assert "model" not in provider assert "secret" not in json.dumps(records) assert "private prompt" not in json.dumps(records) @@ -43,11 +74,9 @@ def test_resolution_failure_and_callback_failure_do_not_crash(monkeypatch): agent.analyze_goal("goal") -def test_json_and_schema_have_distinct_codes(): +def test_json_and_schema_have_distinct_codes(diagnostic_model): records = [] - model = SimpleNamespace( - invoke_llm=lambda **_: SimpleNamespace(message=SimpleNamespace(get_text_content=lambda: "{}")) - ) + model = diagnostic_model() with diagnostics.scope(records.append, "plan"): assert build.propose_plan_v1(model, {}) is None assert any(r["code"] == "response_schema_invalid" for r in records) @@ -55,9 +84,7 @@ def test_json_and_schema_have_distinct_codes(): with diagnostics.scope(records.append, "json"): with pytest.raises(llm.LlmError): llm.invoke_json( - SimpleNamespace( - invoke_llm=lambda **_: SimpleNamespace(message=SimpleNamespace(get_text_content=lambda: "[]")) - ), + diagnostic_model("[]"), system="", user="", ) @@ -100,7 +127,7 @@ def test_wrapped_plugin_connection_refusal_is_classified_without_echo(): assert "secret" not in json.dumps(records) -def test_cas_emission_and_reload_preserve_additive_safe_diagnostics(repo, monkeypatch): # noqa: F811 +def test_cas_emission_and_reload_preserve_additive_safe_diagnostics(repo, monkeypatch, diagnostic_model): # noqa: F811 from core.dify_builder.contract import AssistantTurnItem, ExecutionProgress from core.dify_builder.models import Action, DifyBuilderContext, Turn from core.dify_builder.state import PcState @@ -110,12 +137,10 @@ def test_cas_emission_and_reload_preserve_additive_safe_diagnostics(repo, monkey env, _ = _new_env() env.repo = repo agent = LlmBuilderAgent("tenant") - agent._model_instance = SimpleNamespace( - invoke_llm=lambda **_: (_ for _ in ()).throw(RuntimeError("connection refused http://internal?token=secret")) - ) + agent._model_instance = diagnostic_model(failure=RuntimeError("connection refused http://internal?token=secret")) env.agent = agent monkeypatch.setattr( - build, "planning_catalogue", lambda *_a, **_k: SimpleNamespace(tools=[], agents=[], agent_source_error=None) + build, "planning_catalogue", lambda *_a, **_k: TenantResources(models=[], datasets=[], tools=[]) ) events = [] env.emit_item = events.append @@ -164,13 +189,9 @@ def test_context_scope_does_not_leak_between_nested_calls(): assert [r["span_id"] for r in inner] == ["inner"] -def test_resolved_model_identifiers_are_bounded_without_extra_config(): +def test_resolved_model_identifiers_are_bounded_without_extra_config(diagnostic_model): agent = LlmBuilderAgent("tenant", {"provider": "langgenius/openai/openai", "name": "gpt-4", "api_key": "secret"}) - agent._model_instance = SimpleNamespace( - provider="langgenius/openai/openai", - model_name="gpt-4", - invoke_llm=lambda **_: (_ for _ in ()).throw(ConnectionRefusedError()), - ) + agent._model_instance = diagnostic_model(failure=ConnectionRefusedError()) records = [] agent.set_diagnostic_callback(records.append) agent.analyze_goal("goal") @@ -191,12 +212,10 @@ def test_previous_operation_callback_cannot_contaminate_new_operation(): assert env.diagnostics == [] -def test_edit_fix_and_mock_response_shapes_are_observed(): +def test_edit_fix_and_mock_response_shapes_are_observed(diagnostic_model): from services.dify_builder.agent import edit, fix, mock_inputs - model = SimpleNamespace( - invoke_llm=lambda **_: SimpleNamespace(message=SimpleNamespace(get_text_content=lambda: "{}")) - ) + model = diagnostic_model() records = [] with diagnostics.scope(records.append, "edit"): assert edit._invoke_intents(model, "", "", None) is None @@ -226,15 +245,11 @@ def test_graph_generation_retry_evidence_survives_success(monkeypatch): assert "secret" not in json.dumps(records) -def test_stream_iterator_failure_retains_scope(): - def stream(): - raise TimeoutError("secret") - yield - +def test_stream_iterator_failure_retains_scope(diagnostic_model): records = [] with diagnostics.scope(records.append, "respond-to-message"): with pytest.raises(TimeoutError): - list(llm.invoke_text_stream(SimpleNamespace(invoke_llm=lambda **_: stream()), system="", user="")) + list(llm.invoke_text_stream(diagnostic_model(stream_failure=TimeoutError("secret")), system="", user="")) assert records[0]["reason"] == "timeout" assert records[0]["span_id"] == "respond-to-message" assert "secret" not in json.dumps(records) @@ -291,11 +306,11 @@ def test_http_session_snapshot_retains_safe_diagnostics(): assert result["diagnostics"] == [record] -def test_language_detection_failure_is_scoped_and_keeps_existing_fallback(): +def test_language_detection_failure_is_scoped_and_keeps_existing_fallback(diagnostic_model): from services.dify_builder.agent.localize import LanguageDetector records = [] - model = SimpleNamespace(invoke_llm=lambda **_: (_ for _ in ()).throw(ConnectionRefusedError("secret"))) + model = diagnostic_model(failure=ConnectionRefusedError("secret")) with diagnostics.scope(records.append, "detect-language"): assert LanguageDetector(lambda: model).detect_language("请生成工作流") == "zh-Hans" assert any(r["code"] == "provider_invocation_failed" for r in records) diff --git a/api/tests/unit_tests/services/dify_builder/test_wiring.py b/api/tests/unit_tests/services/dify_builder/test_wiring.py index cf7ae2b8c83..d612b54b3ca 100644 --- a/api/tests/unit_tests/services/dify_builder/test_wiring.py +++ b/api/tests/unit_tests/services/dify_builder/test_wiring.py @@ -2,12 +2,11 @@ import dataclasses import json from collections.abc import Callable, Iterator from pathlib import Path -from types import SimpleNamespace from typing import NamedTuple -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from sqlalchemy import create_engine, event, insert, select +from sqlalchemy import Engine, create_engine, event, insert, select from sqlalchemy.orm import Session, scoped_session, sessionmaker from werkzeug.exceptions import Forbidden @@ -15,7 +14,6 @@ from core.dify_builder.contract import RunStatus from core.dify_builder.errors import BusyError, ConflictError, ModelUnavailableError, NotFoundError from core.dify_builder.models import Action, Actor from core.rbac import RBACPermission, RBACResourceScope -from extensions.application_services.rbac import build_rbac_resource_queries from models import TenantAccountRole from models.account import TenantAccountJoin from models.agent import Agent, AgentScope, AgentSource, AgentStatus @@ -24,14 +22,21 @@ from models.model import App, AppMode from services.dify_builder import wiring from services.dify_builder.service import AppAccess, SessionView from services.enterprise.rbac_service import RBACService +from services.entities.app_entities import AppAgentBinding from services.rbac.resource_queries import ResourceQueryService +from tests.unit_tests.config_override import apply_config_overrides -def _rbac_queries(monkeypatch: pytest.MonkeyPatch) -> MagicMock: - queries = MagicMock(spec=ResourceQueryService) - queries.get_app_agent_binding.return_value = None - queries.get_app_maintainer.return_value = None - monkeypatch.setattr(wiring, "application_services", lambda: SimpleNamespace(rbac=SimpleNamespace(queries=queries))) +@dataclasses.dataclass(frozen=True) +class _WiringDatabase: + session: scoped_session[Session] + engine: Engine + + +def _rbac_queries(mocker) -> ResourceQueryService: + queries = wiring.application_services().rbac.queries + mocker.patch.object(queries, "get_app_agent_binding", return_value=None) + mocker.patch.object(queries, "get_app_maintainer", return_value=None) return queries @@ -195,10 +200,19 @@ def authorizer_db(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[_ TypeBase.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) registry = scoped_session(factory) - database = SimpleNamespace(session=registry, engine=engine) + database = _WiringDatabase(session=registry, engine=engine) monkeypatch.setattr(wiring, "db", database) - queries = build_rbac_resource_queries(database_client=factory) - monkeypatch.setattr(wiring, "application_services", lambda: SimpleNamespace(rbac=SimpleNamespace(queries=queries))) + from configs import dify_config + from extensions.ext_application_services import build_application_services + from extensions.ext_redis import redis_client + + services = build_application_services( + database_client=factory, + deployment_edition=dify_config.DEPLOYMENT_EDITION, + initialization_password="", + redis=redis_client, + ) + monkeypatch.setattr(wiring, "application_services", lambda: services) opened: list[int] = [0] def begin(_conn: object) -> None: @@ -235,12 +249,11 @@ def _seed_app(db: _AuthorizerDb, *, role: TenantAccountRole | None, app_tenant: def test_authorize_app_legacy_requires_tenant_editor_role( - monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb + mocker, monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb ) -> None: _seed_app(authorizer_db, role=TenantAccountRole.EDITOR) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", False) - check = MagicMock() - monkeypatch.setattr(wiring, "check_app_permissions", check) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) + check = mocker.patch.object(wiring, "check_app_permissions") wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.EDIT) @@ -249,7 +262,7 @@ def test_authorize_app_legacy_requires_tenant_editor_role( def test_authorize_app_legacy_rejects_non_editor(monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb) -> None: _seed_app(authorizer_db, role=TenantAccountRole.NORMAL) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) with pytest.raises(Forbidden): wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.EDIT) @@ -264,13 +277,16 @@ def test_authorize_app_legacy_rejects_non_editor(monkeypatch: pytest.MonkeyPatch ], ) def test_authorize_app_rbac_enforces_base_and_operation_permission( - monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb, access: AppAccess, scenes: list[RBACPermission] + mocker, + monkeypatch: pytest.MonkeyPatch, + authorizer_db: _AuthorizerDb, + access: AppAccess, + scenes: list[RBACPermission], ) -> None: _seed_app(authorizer_db, role=TenantAccountRole.NORMAL) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", True) - queries = _rbac_queries(monkeypatch) - check = MagicMock(return_value=True) - monkeypatch.setattr(RBACService.CheckAccess, "check", check) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) + queries = _rbac_queries(mocker) + check = mocker.patch.object(RBACService.CheckAccess, "check", return_value=True) wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", access) @@ -287,13 +303,12 @@ def test_authorize_app_rbac_enforces_base_and_operation_permission( @pytest.mark.parametrize("edit_allowed", [False, True]) def test_authorize_app_rbac_requires_both_edit_and_run( - monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb, edit_allowed: bool + mocker, monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb, edit_allowed: bool ) -> None: _seed_app(authorizer_db, role=TenantAccountRole.NORMAL) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", True) - _rbac_queries(monkeypatch) - check = MagicMock(side_effect=[edit_allowed, False]) - monkeypatch.setattr(RBACService.CheckAccess, "check", check) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) + _rbac_queries(mocker) + check = mocker.patch.object(RBACService.CheckAccess, "check", side_effect=[edit_allowed, False]) with pytest.raises(Forbidden): wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.TEST_AND_RUN) @@ -302,14 +317,13 @@ def test_authorize_app_rbac_requires_both_edit_and_run( def test_authorize_app_rbac_recognizes_the_app_maintainer( - monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb + mocker, monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb ) -> None: _seed_app(authorizer_db, role=TenantAccountRole.NORMAL) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", True) - queries = _rbac_queries(monkeypatch) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) + queries = _rbac_queries(mocker) queries.get_app_maintainer.return_value = "acc-1" - check = MagicMock() - monkeypatch.setattr(RBACService.CheckAccess, "check", check) + check = mocker.patch.object(RBACService.CheckAccess, "check") wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.RELEASE) @@ -317,14 +331,13 @@ def test_authorize_app_rbac_recognizes_the_app_maintainer( def test_authorize_app_rbac_refuses_agent_backed_apps( - monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb + mocker, monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb ) -> None: _seed_app(authorizer_db, role=TenantAccountRole.NORMAL) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", True) - queries = _rbac_queries(monkeypatch) - queries.get_app_agent_binding.return_value = MagicMock() - check = MagicMock() - monkeypatch.setattr(RBACService.CheckAccess, "check", check) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) + queries = _rbac_queries(mocker) + queries.get_app_agent_binding.return_value = AppAgentBinding(id="bound-agent", workflow_only=False) + check = mocker.patch.object(RBACService.CheckAccess, "check") with pytest.raises(NotFoundError): wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.EDIT) @@ -347,7 +360,7 @@ def test_authorize_app_rejects_account_outside_tenant( monkeypatch: pytest.MonkeyPatch, authorizer_db: _AuthorizerDb ) -> None: _seed_app(authorizer_db, role=None) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) with pytest.raises(Forbidden): wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.EDIT) @@ -360,7 +373,7 @@ def test_authorize_app_leaves_no_sql_open_for_the_callers_external_work( ) -> None: """The production locator and evaluator leave no SQL for the caller's next phase.""" _seed_app(authorizer_db, role=TenantAccountRole.EDITOR) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", rbac) + apply_config_overrides(monkeypatch, RBAC_ENABLED=rbac) monkeypatch.setattr(RBACService.CheckAccess, "check", lambda *_args, **_kwargs: True) wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.TEST_AND_RUN) @@ -387,7 +400,7 @@ def test_authorize_app_never_ends_a_callers_transaction( ) else: scoped.add(TenantAccountJoin(tenant_id="ten-2", account_id="acc-1", role=TenantAccountRole.EDITOR)) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.EDIT) @@ -412,7 +425,7 @@ def test_actual_enabled_rbac_checks_run_after_all_locator_sql_ends( ) -> None: """Closing SQL after enforcement is too late: witness the actual external permission call.""" _seed_app(authorizer_db, role=TenantAccountRole.NORMAL) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) checked: list[tuple[str, str, RBACPermission, object, str | None, bool, int]] = [] def check( @@ -482,7 +495,7 @@ def test_actual_enabled_rbac_preserves_plain_app_agent_distinction( backing_app_id="app-1" if scope is AgentScope.WORKFLOW_ONLY else None, ) ) - monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) checked: list[RBACPermission] = [] def check(*_args: object, scene: RBACPermission, **_kwargs: object) -> bool: diff --git a/api/tests/unit_tests/services/test_agent_tool_inner_service.py b/api/tests/unit_tests/services/test_agent_tool_inner_service.py index 3983849a999..c5f2d4ba23c 100644 --- a/api/tests/unit_tests/services/test_agent_tool_inner_service.py +++ b/api/tests/unit_tests/services/test_agent_tool_inner_service.py @@ -15,6 +15,7 @@ from core.tools.errors import ( ) from models.enums import AppStatus from models.model import App, AppMode +from models.tools import ToolFile from services.agent_tool_inner_service import AgentToolInnerService from services.entities.agent_tool_inner import AgentToolInvokeRequest from services.errors.agent_tool_inner import AgentToolInnerServiceError @@ -394,7 +395,6 @@ def test_non_plugin_runtime_keeps_existing_configured_precedence(provider_type, def test_api_output_observation_and_file_messages_use_existing_owners( output_kind, expected_type, observation, sqlite_session ): - from types import SimpleNamespace from core.workflow.file_reference import build_file_reference from graphon.file import File, FileTransferMethod, FileType @@ -436,11 +436,15 @@ def test_api_output_observation_and_file_messages_use_existing_owners( ), ) tool = _ordinary_plugin([], {}) - stored = SimpleNamespace( - id="output-id", + stored = ToolFile( + user_id="user", + tenant_id="tenant", + conversation_id=None, + file_key="file", name="sample.png" if output_kind == "image" else "sample.txt", mimetype="image/png" if output_kind == "image" else "text/plain", ) + stored.id = "output-id" with ( patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", return_value=tool), patch("core.tools.plugin_tool.tool.PluginToolManager.invoke", return_value=iter([message])), diff --git a/api/tests/unit_tests/services/test_app_generate_service_agent_context.py b/api/tests/unit_tests/services/test_app_generate_service_agent_context.py index 561f45abaf2..44280e52fed 100644 --- a/api/tests/unit_tests/services/test_app_generate_service_agent_context.py +++ b/api/tests/unit_tests/services/test_app_generate_service_agent_context.py @@ -1,13 +1,13 @@ """Only explicit server injection selects the confirmed Workflow Agent profile.""" -from unittest.mock import MagicMock - import pytest from sqlalchemy.orm import Session, sessionmaker import services.app_generate_service as module from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.features.rate_limiting import RateLimit from models.model import Account, App, AppMode, EndUser +from models.workflow import Workflow from services.app_generate_service import AppGenerateService from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError from tests.unit_tests.services.dify_builder.native_testing.test_agent_context import marker @@ -17,7 +17,12 @@ from tests.unit_tests.services.dify_builder.native_testing.test_agent_context im def test_confirmed_agent_refuses_unsupported_entry_before_guardrails(monkeypatch, case): from tests.unit_tests.core.app.apps.test_builder_execution_admission import context - guardrails = MagicMock(side_effect=AssertionError("refuse before dispatch")) + calls = [] + + def guardrails(**_kwargs): + calls.append("dispatch") + raise AssertionError("refuse before dispatch") + monkeypatch.setattr(AppGenerateService, "_run_with_guardrails", guardrails) app = App(id="app", tenant_id="tenant", mode=AppMode.ADVANCED_CHAT if case == "advanced_chat" else AppMode.WORKFLOW) if case == "agent_mode": @@ -25,7 +30,9 @@ def test_confirmed_agent_refuses_unsupported_entry_before_guardrails(monkeypatch with Session() as session, pytest.raises(BuilderExecutionPolicyError, match="unsupported_execution_transport"): AppGenerateService.generate( app_model=app, - user=MagicMock(spec=EndUser if case == "end_user" else Account), + user=EndUser(id="user", tenant_id="tenant", app_id="app", type="browser", session_id="session") + if case == "end_user" + else Account(name="Actor", email="actor@example.invalid"), args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API if case == "published" else InvokeFrom.DEBUGGER, session=session, @@ -33,27 +40,29 @@ def test_confirmed_agent_refuses_unsupported_entry_before_guardrails(monkeypatch builder_execution=context() if case == "restricted" else None, builder_agent_execution=marker(), ) - guardrails.assert_not_called() + assert calls == [] @pytest.mark.parametrize("server_context", [False, True]) def test_confirmed_agent_server_context_and_preallocated_run_reach_workflow(monkeypatch, server_context): app = App(id="app", tenant_id="tenant", mode=AppMode.WORKFLOW) monkeypatch.setattr(app, "is_agent_with_session", lambda **_: False) - monkeypatch.setattr(AppGenerateService, "_get_workflow", lambda *_, **__: MagicMock(created_by="actor")) + monkeypatch.setattr( + AppGenerateService, "_get_workflow", lambda *_, **__: Workflow(id="workflow", created_by="actor") + ) monkeypatch.setattr(AppGenerateService, "_admit_normal_draft_tools", lambda **_: None) monkeypatch.setattr(AppGenerateService, "_ensure_workflow_service_mode_available", lambda **_: None) monkeypatch.setattr(module.session_factory, "get_session_maker", sessionmaker) monkeypatch.setattr( AppGenerateService, "_run_with_guardrails", - lambda **kw: kw["action"](MagicMock(generate=lambda stream, *_, **__: stream), "request"), + lambda **kw: kw["action"](RateLimit("app", 0), RateLimit("app", 0).enter("request")), ) captured = {} def generate(**kwargs): captured.update(kwargs) - return iter(()) + yield from () monkeypatch.setattr(module.WorkflowAppGenerator, "generate", lambda _, **kw: generate(**kw)) context = marker() if server_context else None @@ -61,7 +70,7 @@ def test_confirmed_agent_server_context_and_preallocated_run_reach_workflow(monk list( AppGenerateService.generate( app_model=app, - user=MagicMock(spec=Account), + user=Account(name="Actor", email="actor@example.invalid"), args={"inputs": {}, "builder_agent_execution": marker().model_dump(mode="json")}, invoke_from=InvokeFrom.DEBUGGER, session=session, @@ -76,23 +85,27 @@ def test_confirmed_agent_server_context_and_preallocated_run_reach_workflow(monk def test_effective_agent_mode_cannot_drop_context(monkeypatch): - app = MagicMock(spec=App) - app.mode = AppMode.WORKFLOW - app.is_agent_with_session.return_value = True - generator = MagicMock(side_effect=AssertionError("AgentChat must not lose the marker")) + app = App(id="app", tenant_id="tenant", mode=AppMode.WORKFLOW) + monkeypatch.setattr(app, "is_agent_with_session", lambda **_: True) + calls = [] + + def generator(*_args, **_kwargs): + calls.append("generate") + raise AssertionError("AgentChat must not lose the marker") + monkeypatch.setattr(module.AgentChatAppGenerator, "generate", generator) with pytest.raises(BuilderExecutionPolicyError): AppGenerateService._dispatch_generate( app_model=app, - user=MagicMock(spec=Account), + user=Account(name="Actor", email="actor@example.invalid"), args={"inputs": {}}, invoke_from=InvokeFrom.DEBUGGER, streaming=True, root_node_id=None, - session=MagicMock(), - rate_limit=MagicMock(), + session=Session(), + rate_limit=RateLimit("app", 0), request_id="request", workflow_execution_mode="in_process", builder_agent_execution=marker(), ) - generator.assert_not_called() + assert calls == [] diff --git a/api/tests/unit_tests/services/test_app_generate_service_in_process.py b/api/tests/unit_tests/services/test_app_generate_service_in_process.py index 38ebbf2417e..4b1b6c65995 100644 --- a/api/tests/unit_tests/services/test_app_generate_service_in_process.py +++ b/api/tests/unit_tests/services/test_app_generate_service_in_process.py @@ -2,7 +2,6 @@ import json from collections.abc import Callable, Generator -from unittest.mock import MagicMock import pytest from sqlalchemy.orm import Session, sessionmaker @@ -12,31 +11,48 @@ from core.app.entities.app_invoke_entities import InvokeFrom from core.app.features.rate_limiting.rate_limit import RateLimit from enums import DeploymentEdition from models.model import Account, App, AppMode +from models.workflow import Workflow from services.app_generate_service import AppGenerateService +def _account() -> Account: + account = Account(name="Owner", email="owner@example.invalid") + account.id = "account-1" + return account + + +class _RefusalAdmission: + """Record the refusal-only admission boundary used before dispatch.""" + + def __init__(self) -> None: + self.refusals: list[tuple[Exception, bool]] = [] + + def on_refusal(self, error: Exception, *, preworker: bool = False) -> None: + self.refusals.append((error, preworker)) + + @pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @pytest.mark.parametrize("ending", ["complete", "close", "error"]) def test_in_process_stream_does_not_enqueue_a_child_task_and_releases_its_rate_limit( - monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], mode: AppMode, ending: str + mocker, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], mode: AppMode, ending: str ) -> None: config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) - app = MagicMock(spec=App) - app.id = "app-1" - app.mode = mode - app.is_agent_with_session.return_value = False - user = MagicMock(spec=Account) - workflow = MagicMock(created_by="account-1") - monkeypatch.setattr(AppGenerateService, "_get_workflow", MagicMock(return_value=workflow)) + app = App(id="app-1", tenant_id="tenant", mode=mode) + mocker.patch.object(app, "is_agent_with_session", return_value=False) + user = _account() + workflow = Workflow(created_by="account-1") + mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) monkeypatch.setattr(AppGenerateService, "_get_max_active_requests", lambda _: 0) monkeypatch.setattr(module.session_factory, "get_session_maker", sessionmaker) - limiter = RateLimit("in-process-stream-test", 0) - limiter.enter = MagicMock(return_value="request-1") - limiter.exit = MagicMock() - monkeypatch.setattr(module, "RateLimit", MagicMock(return_value=limiter)) - enqueue = MagicMock(side_effect=AssertionError("the parent task owns the only worker slot")) - monkeypatch.setattr(module.workflow_based_app_execution_task, "delay", enqueue) + limiter = RateLimit(app.id, 0) + mocker.patch.object(limiter, "enter", return_value="request-1") + mocker.patch.object(limiter, "exit") + enqueue = mocker.patch.object( + module.workflow_based_app_execution_task, + "delay", + side_effect=AssertionError("the parent task owns the only worker slot"), + ) closed: list[bool] = [] def stream() -> Generator[dict[str, object]]: @@ -48,9 +64,8 @@ def test_in_process_stream_does_not_enqueue_a_child_task_and_releases_its_rate_l finally: closed.append(True) - generate = MagicMock(side_effect=lambda **_: stream()) generator_type = module.WorkflowAppGenerator if mode == AppMode.WORKFLOW else module.AdvancedChatAppGenerator - monkeypatch.setattr(generator_type, "generate", generate) + generate = mocker.patch.object(generator_type, "generate", side_effect=lambda **_: stream()) with Session() as session: response = AppGenerateService.generate( @@ -87,7 +102,7 @@ def test_in_process_stream_does_not_enqueue_a_child_task_and_releases_its_rate_l def test_posted_live_mode_cannot_select_builder_policy( - monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] + mocker, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] ) -> None: from tests.unit_tests.core.app.apps.test_builder_execution_admission import context @@ -95,20 +110,20 @@ def test_posted_live_mode_cannot_select_builder_policy( app = App(id="app", tenant_id="tenant", mode="workflow") user = Account(name="Owner", email="owner@example.invalid") user.id = "account" - workflow = MagicMock(created_by="account") + workflow = Workflow(created_by="account") marker = context() - monkeypatch.setattr(AppGenerateService, "_get_workflow", MagicMock(return_value=workflow)) - monkeypatch.setattr(module, "restricted_admission_snapshot", MagicMock()) + mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) + mocker.patch.object(module, "restricted_admission_snapshot") monkeypatch.setattr(app, "is_agent_with_session", lambda **_: False) monkeypatch.setattr(AppGenerateService, "_ensure_workflow_service_mode_available", lambda **_: None) monkeypatch.setattr(AppGenerateService, "_get_max_active_requests", lambda _: 0) monkeypatch.setattr(module.session_factory, "get_session_maker", sessionmaker) - limiter = RateLimit("posted-live-mode-test", 0) - limiter.enter = MagicMock(return_value="request") - limiter.exit = MagicMock() - monkeypatch.setattr(module, "RateLimit", MagicMock(return_value=limiter)) - generate = MagicMock(side_effect=lambda **_: (event for event in ())) - monkeypatch.setattr(module.WorkflowAppGenerator, "generate", generate) + limiter = RateLimit(app.id, 0) + mocker.patch.object(limiter, "enter", return_value="request") + mocker.patch.object(limiter, "exit") + generate = mocker.patch.object( + module.WorkflowAppGenerator, "generate", side_effect=lambda **_: (event for event in ()) + ) for policy in (None, marker): with Session() as session: response = AppGenerateService.generate( @@ -119,7 +134,7 @@ def test_posted_live_mode_cannot_select_builder_policy( session=session, workflow_execution_mode="in_process", builder_execution=policy, - builder_execution_admit=MagicMock() if policy else None, + builder_execution_admit=_RefusalAdmission() if policy else None, ) list(response) assert generate.call_args.kwargs["builder_execution"] is policy @@ -128,79 +143,80 @@ def test_posted_live_mode_cannot_select_builder_policy( @pytest.mark.parametrize("mode", ["advanced-chat", "agent", "agent-chat"]) -def test_restricted_dispatch_rejects_other_modes_before_guardrails( - monkeypatch: pytest.MonkeyPatch, mode: AppMode -) -> None: +def test_restricted_dispatch_rejects_other_modes_before_guardrails(mocker, mode: AppMode) -> None: from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError from tests.unit_tests.core.app.apps.test_builder_execution_admission import context - guardrails = MagicMock(side_effect=AssertionError("must refuse before native dispatch")) - monkeypatch.setattr(AppGenerateService, "_run_with_guardrails", guardrails) + guardrails = mocker.patch.object( + AppGenerateService, + "_run_with_guardrails", + side_effect=AssertionError("must refuse before native dispatch"), + ) with Session() as session, pytest.raises(BuilderExecutionPolicyError): AppGenerateService.generate( App(id="app", tenant_id="tenant", mode=mode), - MagicMock(spec=Account), + _account(), {"inputs": {}}, InvokeFrom.DEBUGGER, session=session, workflow_execution_mode="in_process", builder_execution=context(), - builder_execution_admit=MagicMock(), + builder_execution_admit=_RefusalAdmission(), ) guardrails.assert_not_called() -def test_restricted_marker_cannot_enter_agent_dispatch_of_workflow_app(monkeypatch: pytest.MonkeyPatch) -> None: +def test_restricted_marker_cannot_enter_agent_dispatch_of_workflow_app(mocker) -> None: from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError from tests.unit_tests.core.app.apps.test_builder_execution_admission import context - app = MagicMock(spec=App) - app.mode = "workflow" - app.is_agent_with_session.return_value = True - agent = MagicMock() - monkeypatch.setattr(module.AgentChatAppGenerator, "generate", agent) + app = App(id="app", tenant_id="tenant", mode="workflow") + mocker.patch.object(app, "is_agent_with_session", return_value=True) + agent = mocker.patch.object(module.AgentChatAppGenerator, "generate") with pytest.raises(BuilderExecutionPolicyError): AppGenerateService._dispatch_generate( app_model=app, - user=MagicMock(spec=Account), + user=_account(), args={"inputs": {}}, invoke_from=InvokeFrom.DEBUGGER, streaming=True, root_node_id=None, - session=MagicMock(), - rate_limit=MagicMock(), + session=Session(), + rate_limit=RateLimit(app.id, 0), request_id="request", workflow_execution_mode="in_process", builder_execution=context(), - builder_execution_admit=MagicMock(), + builder_execution_admit=_RefusalAdmission(), ) agent.assert_not_called() @pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @pytest.mark.parametrize("transport", ["in_process", "celery"]) -def test_pre_generator_tool_admission_failure_has_qualified_preparation_boundary(monkeypatch, mode, transport): +def test_pre_generator_tool_admission_failure_has_qualified_preparation_boundary(mocker, monkeypatch, mode, transport): from core.tools.errors import ToolProviderCredentialValidationError from services.dify_builder.native_launch_observation import NativeLaunchObservation - app = MagicMock(spec=App) - app.mode = mode - app.is_agent_with_session.return_value = False - user = MagicMock(spec=Account) - monkeypatch.setattr(AppGenerateService, "_get_workflow", MagicMock(return_value=MagicMock())) + app = App(id="app-1", tenant_id="tenant", mode=mode) + mocker.patch.object(app, "is_agent_with_session", return_value=False) + user = _account() + mocker.patch.object(AppGenerateService, "_get_workflow", return_value=Workflow(created_by=user.id)) monkeypatch.setattr( AppGenerateService, "_run_with_guardrails", - staticmethod(lambda **kwargs: kwargs["action"](MagicMock(), "request")), + staticmethod(lambda **kwargs: kwargs["action"](RateLimit(app.id, 0), "request")), ) - monkeypatch.setattr( + mocker.patch.object( AppGenerateService, "_admit_normal_draft_tools", - MagicMock(side_effect=ToolProviderCredentialValidationError("metadata service unavailable")), + side_effect=ToolProviderCredentialValidationError("metadata service unavailable"), + ) + generator = mocker.patch.object( + module.WorkflowAppGenerator, "generate", side_effect=AssertionError("no worker dispatch") + ) + chat_generator = mocker.patch.object( + module.AdvancedChatAppGenerator, "generate", side_effect=AssertionError("no worker dispatch") ) - generator = MagicMock(side_effect=AssertionError("no worker dispatch")) - monkeypatch.setattr(module.WorkflowAppGenerator, "generate", generator) - monkeypatch.setattr(module.AdvancedChatAppGenerator, "generate", generator) observation = NativeLaunchObservation() with ( observation.track(), @@ -212,3 +228,4 @@ def test_pre_generator_tool_admission_failure_has_qualified_preparation_boundary ) assert observation.failed_before_dispatch is (transport == "in_process") generator.assert_not_called() + chat_generator.assert_not_called() 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 e25fcf82674..40371e3f51f 100644 --- a/api/tests/unit_tests/services/test_draft_tool_admission.py +++ b/api/tests/unit_tests/services/test_draft_tool_admission.py @@ -7,13 +7,13 @@ the setup description cannot supply permission; setting up the same row can. import json from collections.abc import Callable, Iterator, Sequence +from dataclasses import dataclass, field from datetime import datetime -from types import SimpleNamespace from typing import Never, Self import httpx import pytest -from sqlalchemy import select, text +from sqlalchemy import Engine, Select, select, text from sqlalchemy.engine import Connection from sqlalchemy.orm import ORMExecuteState, Session, SessionTransaction, sessionmaker @@ -88,13 +88,67 @@ class LocalRateLimit: return generator +@dataclass +class NativeDraftCase: + app: App + user: Account + workflow: Workflow + tools: DraftToolInventory + + +@dataclass +class DraftDatabase: + engine: Engine + session: Session | None = None + + +@dataclass +class RefreshBoundary: + action: Callable[[], None] | None = None + credential_owner: str = ACCOUNT_A + + +@dataclass +class NativeOAuthCase: + resolve: Callable[[], Tool] + case: NativeDraftCase + engine: Engine + credential_id: str + crypto_calls: list[str] + refresh_calls: list[str] + cache_deletions: list[str] + forbidden: list[str] + refresh_boundary: RefreshBoundary + + +@dataclass +class ResumeEvidence: + run_id: str + task_id: str + saved_blob: bytes = b"" + redis_calls: list[tuple[str, ...]] = field(default_factory=list) + native_calls: list[str] = field(default_factory=list) + pause_writes: list[object] = field(default_factory=list) + storage_loads: list[str] = field(default_factory=list) + run: object = None + entity: object = None + + +@dataclass +class FailureResponseTopic: + messages: list[bytes] = field(default_factory=list) + + def publish(self, value: bytes) -> None: + self.messages.append(value) + + @pytest.fixture def native_draft( draft_tools: DraftToolInventory, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], -) -> SimpleNamespace: +) -> NativeDraftCase: import core.tools.tool_manager as runtime_module import services.tools.ordinary_tool_credentials as credential_owner @@ -159,10 +213,10 @@ def native_draft( ) sqlite_session.add_all([app, workflow]) sqlite_session.commit() - return SimpleNamespace(app=app, user=user, workflow=workflow, tools=draft_tools) + return NativeDraftCase(app=app, user=user, workflow=workflow, tools=draft_tools) -def set_tool(case: SimpleNamespace, session: Session, **values: object) -> None: +def set_tool(case: NativeDraftCase, session: Session, **values: object) -> None: graph = case.workflow.graph_dict graph["nodes"][1]["data"].update(values) case.workflow.graph = json.dumps(graph) @@ -180,7 +234,7 @@ ENTRYPOINTS = [ ] -def nest_tool_in_container(case: SimpleNamespace, session: Session, kind: str) -> None: +def nest_tool_in_container(case: NativeDraftCase, session: Session, kind: str) -> None: """Use a real container id and enclosing Tool rather than a nonexistent node.""" graph = case.workflow.graph_dict tool = next(node for node in graph["nodes"] if node["id"] == "t") @@ -222,7 +276,7 @@ def nest_tool_in_container(case: SimpleNamespace, session: Session, kind: str) - def invoke( - case: SimpleNamespace, + case: NativeDraftCase, session: Session, monkeypatch: pytest.MonkeyPatch, entrypoint: str, @@ -286,7 +340,7 @@ def invoke( @pytest.mark.parametrize("entrypoint", ENTRYPOINTS) @pytest.mark.parametrize("description", ["", "User removed or changed the reminder; ready now!"]) def test_draft_execution_without_credentials_refuses_before_native_dispatch_even_without_note( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, entrypoint: str, @@ -301,7 +355,7 @@ def test_draft_execution_without_credentials_refuses_before_native_dispatch_even @pytest.mark.parametrize("entrypoint", [*ENTRYPOINTS, "publish"]) @pytest.mark.parametrize("hidden_selection", ["default", "oldest", "legacy-short-default"]) def test_native_default_hidden_credential_is_not_replaced_by_some_visible_credential( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, entrypoint: str, @@ -339,7 +393,7 @@ def test_native_default_hidden_credential_is_not_replaced_by_some_visible_creden @pytest.mark.parametrize("selection", ["hidden", "foreign", "wrong-provider", "deleted", "revoked-grant", "malformed"]) @pytest.mark.parametrize("operation", ["workflow-full", "node", "publish"]) def test_exact_selected_credential_requires_the_complete_actor_provider_tenant_chain( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, selection: str, @@ -379,7 +433,7 @@ def test_exact_selected_credential_requires_the_complete_actor_provider_tenant_c @pytest.mark.parametrize("entrypoint", ENTRYPOINTS) @pytest.mark.parametrize("configured", ["explicit", "default", "credential-free"]) def test_configured_and_credential_free_drafts_reach_native_dispatch_without_removing_the_note( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, entrypoint: str, @@ -416,7 +470,7 @@ def test_configured_and_credential_free_drafts_reach_native_dispatch_without_rem def test_setup_enables_the_same_stored_draft_without_regeneration( - native_draft: SimpleNamespace, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch ) -> None: graph_before = native_draft.workflow.graph with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"): @@ -434,7 +488,7 @@ def test_setup_enables_the_same_stored_draft_without_regeneration( ) @pytest.mark.parametrize("operation", [*ENTRYPOINTS, "publish"]) def test_fresh_query_or_installation_uncertainty_refuses_draft_admission( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, failure: str, @@ -460,7 +514,7 @@ def test_fresh_query_or_installation_uncertainty_refuses_draft_admission( def test_publish_without_credentials_and_plugin_manager_disabled_creates_no_version_or_pointer( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, ) -> None: service = WorkflowService(sessionmaker(bind=native_draft.tools.engine)) @@ -479,7 +533,7 @@ def test_publish_without_credentials_and_plugin_manager_disabled_creates_no_vers @pytest.mark.parametrize("configured", ["explicit", "default", "credential-free"]) def test_publish_configured_or_credential_free_draft_with_plugin_manager_disabled( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, configured: str, ) -> None: @@ -504,7 +558,7 @@ def test_publish_configured_or_credential_free_draft_with_plugin_manager_disable @pytest.mark.parametrize("invoke_from", [InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP]) def test_published_execution_keeps_normal_credential_delegation( - native_draft: SimpleNamespace, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, invoke_from: InvokeFrom + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, invoke_from: InvokeFrom ) -> None: native_draft.workflow.version = "2026-10-12 12:00:00" native_draft.app.workflow_id = DRAFT_ID @@ -536,7 +590,7 @@ def test_published_execution_keeps_normal_credential_delegation( @pytest.mark.parametrize("owner", ["console", "builder"]) def test_native_publish_owners_read_declarations_before_writes_or_draft_locks( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, owner: str, @@ -557,7 +611,7 @@ def test_native_publish_owners_read_declarations_before_writes_or_draft_locks( add_credential(sqlite_session, tenant_id=TENANT_ID, provider=AUTH_PROVIDER, user_id=ACCOUNT_A) sqlite_session.add(native_draft.user) sqlite_session.commit() - database = SimpleNamespace(engine=native_draft.tools.engine) + database = DraftDatabase(engine=native_draft.tools.engine) monkeypatch.setattr(console_owner, "db", database) monkeypatch.setattr(builder_owner, "db", database) guarded_sessions: set[Session] = set() @@ -568,7 +622,8 @@ def test_native_publish_owners_read_declarations_before_writes_or_draft_locks( def on_orm_execute(state: ORMExecuteState) -> None: if ( state.session.bind is native_draft.tools.engine - and getattr(state.statement, "_for_update_arg", None) is not None + and isinstance(state.statement, Select) + and state.statement._for_update_arg is not None ): guarded_sessions.add(state.session) @@ -649,7 +704,7 @@ def test_native_publish_owners_read_declarations_before_writes_or_draft_locks( @pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) def test_queued_draft_rechecks_revoked_selection_before_native_dispatch( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, mode: AppMode, @@ -682,13 +737,14 @@ def test_queued_draft_rechecks_revoked_selection_before_native_dispatch( assert grant is not None grant.has_permission = False sqlite_session.commit() - monkeypatch.setattr(worker, "db", SimpleNamespace(engine=native_draft.tools.engine)) + monkeypatch.setattr(worker, "db", DraftDatabase(engine=native_draft.tools.engine)) monkeypatch.setattr(worker.WorkflowAppGenerator, "generate", dispatch) monkeypatch.setattr(worker.AdvancedChatAppGenerator, "generate", dispatch) + response_topic = FailureResponseTopic() monkeypatch.setattr( worker.MessageBasedAppGenerator, "get_response_topic", - lambda *_args: SimpleNamespace(publish=lambda _value: None), + lambda *_args: response_topic, ) with pytest.raises(ToolProviderCredentialValidationError, match="Tool|Saved draft execution"): worker.workflow_based_app_execution_task.run(payload) @@ -697,7 +753,7 @@ def test_queued_draft_rechecks_revoked_selection_before_native_dispatch( @pytest.mark.parametrize("default", [True, False]) def test_ambiguous_native_default_cannot_mask_hidden_credential( - native_draft: SimpleNamespace, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, default: bool + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, default: bool ) -> None: visible = add_credential( sqlite_session, @@ -728,7 +784,7 @@ def test_ambiguous_native_default_cannot_mask_hidden_credential( def native_tool_node( - case: SimpleNamespace, *, workflow_id: str | None = None, credential_bindings: dict[str, str] | None = None + case: NativeDraftCase, *, workflow_id: str | None = None, credential_bindings: dict[str, str] | None = None ) -> tuple[ToolNode, VariablePool]: """Construct the real Tool through the production native factory and scope.""" from core.app.entities.app_invoke_entities import UserFrom, build_dify_run_context @@ -759,7 +815,7 @@ def native_tool_node( @pytest.mark.parametrize("change", ["revoked", "hidden-default", "wrong-provider", "after-metadata"]) def test_native_tool_rechecks_selection_after_run_admission( - native_draft: SimpleNamespace, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, change: str + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, change: str ) -> None: """A native node cannot decrypt a selection whose access changed since admission.""" from sqlalchemy import event @@ -807,7 +863,7 @@ def test_native_tool_rechecks_selection_after_run_admission( def revoke_before_native_value_read(state: ORMExecuteState) -> None: if change == "after-metadata" and not revoked_between_reads: - descriptions = getattr(state.statement, "column_descriptions", ()) + descriptions = state.statement.column_descriptions if isinstance(state.statement, Select) else () if descriptions and descriptions[0].get("expr") is BuiltinToolProvider: revoked_between_reads.append(True) assert grant is not None @@ -825,7 +881,7 @@ def test_native_tool_rechecks_selection_after_run_admission( def test_published_child_inherited_debugger_keeps_native_tool_delegation( - native_draft: SimpleNamespace, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch ) -> None: """Actual published child scope preserves delegation even with inherited DEBUGGER.""" from graphon.nodes.tool.exc import ToolRuntimeResolutionError @@ -856,7 +912,7 @@ def test_published_child_inherited_debugger_keeps_native_tool_delegation( import core.tools.tool_manager as tool_manager_module monkeypatch.setattr( - tool_manager_module, "db", SimpleNamespace(engine=native_draft.tools.engine, session=sqlite_session) + tool_manager_module, "db", DraftDatabase(engine=native_draft.tools.engine, session=sqlite_session) ) node, pool = native_tool_node(native_draft, workflow_id=published.id) with pytest.raises( @@ -870,7 +926,7 @@ def test_published_child_inherited_debugger_keeps_native_tool_delegation( @pytest.mark.parametrize("scope", ["deleted", "foreign"]) def test_native_tool_unknown_workflow_scope_refuses_before_crypto( - native_draft: SimpleNamespace, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, scope: str + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, scope: str ) -> None: from graphon.nodes.tool.exc import ToolRuntimeResolutionError @@ -889,10 +945,10 @@ def test_native_tool_unknown_workflow_scope_refuses_before_crypto( @pytest.fixture def native_draft_oauth( - native_draft: SimpleNamespace, + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, -) -> Iterator[SimpleNamespace]: +) -> Iterator[NativeDraftCase]: """Authorized synthetic OAuth effects through real native/SQL ownership. Actual stored draft ownership, installed declaration, credential visibility, @@ -965,7 +1021,7 @@ def native_draft_oauth( sqlite_session.commit() set_tool(native_draft, sqlite_session, credential_id=credential.id) credential_id = credential.id - native_database = SimpleNamespace(engine=native_draft.tools.engine, session=sqlite_session) + native_database = DraftDatabase(engine=native_draft.tools.engine, session=sqlite_session) monkeypatch.setattr(runtime_module, "db", native_database) monkeypatch.setattr(credential_owner, "db", native_database) monkeypatch.setattr(client_module, "db", native_database) @@ -974,7 +1030,7 @@ def native_draft_oauth( crypto_calls: list[str] = [] refresh_calls: list[str] = [] cache_deletions: list[str] = [] - refresh_boundary = SimpleNamespace(action=None, credential_owner=ACCOUNT_A) + refresh_boundary = RefreshBoundary() class SyntheticCrypto: @staticmethod @@ -1062,7 +1118,7 @@ def native_draft_oauth( draft_tool_app_id=APP_ID, ) - yield SimpleNamespace( + yield NativeOAuthCase( resolve=resolve_runtime, case=native_draft, engine=native_draft.tools.engine, @@ -1078,7 +1134,7 @@ def native_draft_oauth( def test_authorized_native_draft_oauth_refresh_persists_rotated_credentials_and_expiry( - native_draft_oauth: SimpleNamespace, + native_draft_oauth: NativeDraftCase, ) -> None: """Native rotation survives fresh SQL and a second resolution without refresh.""" from models.tools import BuiltinToolProvider @@ -1119,7 +1175,7 @@ def test_authorized_native_draft_oauth_refresh_persists_rotated_credentials_and_ @pytest.mark.parametrize("change", ["revoked", "rotated"]) def test_native_draft_oauth_refresh_refuses_concurrent_selection_change( - native_draft_oauth: SimpleNamespace, change: str + native_draft_oauth: NativeDraftCase, change: str ) -> None: """Real current permission/token updates cannot be overwritten after OAuth I/O.""" from core.tools.errors import ToolProviderCredentialValidationError @@ -1221,8 +1277,8 @@ def test_native_draft_oauth_refresh_refuses_concurrent_selection_change( @pytest.fixture def paused_native_draft( - native_draft: SimpleNamespace, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch -) -> Iterator[SimpleNamespace]: + native_draft: NativeDraftCase, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch +) -> Iterator[NativeDraftCase]: """Real saved run/pause/context; only blob, Redis and final resume effects are external.""" from decimal import Decimal @@ -1245,17 +1301,7 @@ def paused_native_draft( conversation_id = "eeeeeeee-0000-4000-8000-000000000002" task_id = "eeeeeeee-0000-4000-8000-000000000003" state_key = "resume-draft-tool-state.json" - state = SimpleNamespace( - saved_blob=b"", - redis_calls=[], - native_calls=[], - pause_writes=[], - storage_loads=[], - run_id=run_id, - task_id=task_id, - run=None, - entity=None, - ) + state = ResumeEvidence(run_id=run_id, task_id=task_id) class BlobBoundary: @staticmethod @@ -1309,14 +1355,16 @@ def paused_native_draft( def execute(self) -> list[object]: return [ - self.redis.values.get(key, []) if operation == "lrange" else getattr(self.redis, operation)(key) + self.redis.values.get(key, []) + if operation == "lrange" + else {"get": self.redis.get, "delete": self.redis.delete}[operation](key) for operation, key in self.operations ] redis = RedisBoundary() monkeypatch.setattr(cancellation_owner, "redis_client", redis) monkeypatch.setattr(pause_owner, "storage", BlobBoundary()) - monkeypatch.setattr(worker, "db", SimpleNamespace(engine=native_draft.tools.engine)) + monkeypatch.setattr(worker, "db", DraftDatabase(engine=native_draft.tools.engine)) def observe_pause_write( _connection: Connection, _cursor: object, statement: str, _params: object, _context: object, _many: bool @@ -1472,8 +1520,8 @@ def paused_native_draft( @pytest.mark.parametrize("mode", ["workflow", "advanced-chat"]) @pytest.mark.parametrize("draft_edit", ["removed-tool", "changed-credential"]) def test_resume_refuses_saved_revoked_tool_before_any_resume_effect( - paused_native_draft: SimpleNamespace, - native_draft: SimpleNamespace, + paused_native_draft: ResumeEvidence, + native_draft: NativeDraftCase, sqlite_session: Session, mode: AppMode, draft_edit: str, @@ -1518,8 +1566,8 @@ def test_resume_refuses_saved_revoked_tool_before_any_resume_effect( @pytest.mark.parametrize("mode", ["workflow", "advanced-chat"]) def test_resume_configured_saved_graph_ignores_later_unconfigured_draft_tool( - paused_native_draft: SimpleNamespace, - native_draft: SimpleNamespace, + paused_native_draft: ResumeEvidence, + native_draft: NativeDraftCase, sqlite_session: Session, mode: AppMode, ) -> None: @@ -1561,8 +1609,8 @@ def test_resume_configured_saved_graph_ignores_later_unconfigured_draft_tool( "saved_graph", [None, "not JSON", "[]", "{}"], ids=["absent", "malformed", "array", "missing-nodes"] ) def test_resume_unverifiable_saved_graph_refuses_before_resume_effects( - paused_native_draft: SimpleNamespace, - native_draft: SimpleNamespace, + paused_native_draft: ResumeEvidence, + native_draft: NativeDraftCase, sqlite_session: Session, mode: AppMode, saved_graph: Graph | str | None, @@ -1580,8 +1628,8 @@ def test_resume_unverifiable_saved_graph_refuses_before_resume_effects( @pytest.mark.parametrize("mode", ["workflow", "advanced-chat"]) @pytest.mark.parametrize("scope", ["run-tenant", "context-actor", "context-workflow", "context-run"]) def test_resume_saved_graph_requires_actual_run_and_invocation_owners( - paused_native_draft: SimpleNamespace, - native_draft: SimpleNamespace, + paused_native_draft: ResumeEvidence, + native_draft: NativeDraftCase, sqlite_session: Session, mode: AppMode, scope: str, @@ -1635,7 +1683,7 @@ def test_builder_native_tool_refuses_changed_confirmed_credential_before_crypto( rotated = [] def rotate_before_value_read(state): - descriptions = getattr(state.statement, "column_descriptions", ()) + descriptions = state.statement.column_descriptions if isinstance(state.statement, Select) else () if ( change == "rotation-before-read" and not rotated diff --git a/api/tests/unit_tests/services/test_feature_service_dify_builder.py b/api/tests/unit_tests/services/test_feature_service_dify_builder.py index 5c8cfc40997..02a1a6ff411 100644 --- a/api/tests/unit_tests/services/test_feature_service_dify_builder.py +++ b/api/tests/unit_tests/services/test_feature_service_dify_builder.py @@ -1,11 +1,11 @@ import pytest -from services import feature_service as feature_service_module from services.feature_service import FeatureService +from tests.unit_tests.config_override import apply_config_overrides def test_dify_builder_flag_follows_config_true(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "DIFY_BUILDER_ENABLED", True) + apply_config_overrides(monkeypatch, DIFY_BUILDER_ENABLED=True) features = FeatureService.get_features("tenant-1") @@ -13,7 +13,7 @@ def test_dify_builder_flag_follows_config_true(monkeypatch: pytest.MonkeyPatch) def test_dify_builder_flag_defaults_false(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "DIFY_BUILDER_ENABLED", False) + apply_config_overrides(monkeypatch, DIFY_BUILDER_ENABLED=False) features = FeatureService.get_features("tenant-1") @@ -21,7 +21,7 @@ def test_dify_builder_flag_defaults_false(monkeypatch: pytest.MonkeyPatch) -> No def test_feature_service_surfaces_skill_learning_policy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "DIFY_BUILDER_SKILL_LEARNING_POLICY", "automatic") + apply_config_overrides(monkeypatch, DIFY_BUILDER_SKILL_LEARNING_POLICY="automatic") features = FeatureService.get_features("tenant-1") @@ -29,7 +29,7 @@ def test_feature_service_surfaces_skill_learning_policy(monkeypatch: pytest.Monk def test_skill_learning_policy_defaults_ask(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "DIFY_BUILDER_SKILL_LEARNING_POLICY", "ask") + apply_config_overrides(monkeypatch, DIFY_BUILDER_SKILL_LEARNING_POLICY="ask") features = FeatureService.get_features("tenant-1") diff --git a/api/tests/unit_tests/services/test_workflow_draft_sync_service.py b/api/tests/unit_tests/services/test_workflow_draft_sync_service.py index 35c10e1fa7d..22b2ce41970 100644 --- a/api/tests/unit_tests/services/test_workflow_draft_sync_service.py +++ b/api/tests/unit_tests/services/test_workflow_draft_sync_service.py @@ -1,10 +1,12 @@ import base64 import json from datetime import datetime -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest +from socketio import Server from socketio.exceptions import TimeoutError as SocketIOTimeoutError +from sqlalchemy import delete, event from sqlalchemy.orm import Session from models.workflow import Workflow @@ -21,6 +23,8 @@ def workflow() -> Workflow: app_id="app-1", tenant_id="tenant-1", version=Workflow.VERSION_DRAFT, + type="workflow", + created_by="u-1", graph=json.dumps({"nodes": [{"id": "new"}], "edges": []}), features="{}", updated_at=datetime(2026, 9, 20, 12, 0, 0), @@ -28,8 +32,12 @@ def workflow() -> Workflow: @pytest.fixture -def setup(workflow: Workflow) -> tuple[WorkflowDraftSyncService, Mock, Mock, Mock, MagicMock, ServerDraftChange]: - repository = Mock(spec=WorkflowDraftSyncRepository) +def setup( + workflow: Workflow, sqlite_session: Session, mocker +) -> tuple[WorkflowDraftSyncService, Mock, Mock, Mock, Session, ServerDraftChange]: + repository = WorkflowDraftSyncRepository() + for name in ("announce", "claim", "accept", "get", "release", "clear"): + mocker.patch.object(repository, name) repository.announce.return_value = False repository.claim.return_value = True repository.accept.return_value = True @@ -41,23 +49,36 @@ def setup(workflow: Workflow) -> tuple[WorkflowDraftSyncService, Mock, Mock, Moc previous_graph={"nodes": [{"id": "old"}], "edges": []}, ) repository.get.return_value = change - collaboration_repository = Mock(spec=WorkflowCollaborationRepository) + collaboration_repository = WorkflowCollaborationRepository() + for name in ("get_sid_mapping", "get_current_leader", "get_session_sids"): + mocker.patch.object(collaboration_repository, name) collaboration_repository.get_sid_mapping.return_value = {"workflow_id": "app-1", "user_id": "u-1"} collaboration_repository.get_current_leader.return_value = "leader" collaboration_repository.get_session_sids.return_value = ["follower", "leader"] - collaboration = Mock(spec=WorkflowCollaborationService) - collaboration.is_session_active.return_value = True - socketio = Mock() + socketio = Server(async_mode="threading") + for name in ("get_session", "call", "emit"): + mocker.patch.object(socketio, name) + collaboration = WorkflowCollaborationService(collaboration_repository, socketio) + mocker.patch.object(collaboration, "is_session_active", return_value=True) socketio.get_session.return_value = {"tenant_id": "tenant-1"} socketio.call.return_value = {"revision": change.revision, "update": b"accepted-update"} - session = MagicMock(spec=Session) - session.scalar.return_value = workflow + session = sqlite_session + session.add(workflow) + session.commit() + session.info["completed_transactions"] = [] + + def record_transaction_end(_session, transaction): + if transaction.parent is None: + session.info["completed_transactions"].append(transaction) + + event.listen(session, "after_transaction_end", record_transaction_end) + mocker.spy(session, "scalar") service = WorkflowDraftSyncService(repository, collaboration_repository, collaboration, socketio) return service, repository, collaboration_repository, socketio, session, change def test_broadcasts_one_accepted_update_including_the_writer_and_replays_it_on_retry( - setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, MagicMock, ServerDraftChange], + setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, Session, ServerDraftChange], ) -> None: service, repository, _collaboration_repository, socketio, session, change = setup first, status = service.sync("follower", session=session) @@ -65,7 +86,8 @@ def test_broadcasts_one_accepted_update_including_the_writer_and_replays_it_on_r assert first["update"] == b"accepted-update" socketio.emit.assert_called_once_with("server_draft_update", first, room="app-1") assert socketio.call.call_args.kwargs["to"] == "leader" - assert session.begin.return_value.__exit__.call_count == 1 + assert len(session.info["completed_transactions"]) == 1 + assert not session.in_transaction() repository.get.return_value = change.model_copy(update={"update": base64.b64encode(b"accepted-update").decode()}) second, status = service.sync("follower", session=session) @@ -76,7 +98,7 @@ def test_broadcasts_one_accepted_update_including_the_writer_and_replays_it_on_r def test_retries_another_ready_client_after_the_selected_writer_times_out( - setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, MagicMock, ServerDraftChange], + setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, Session, ServerDraftChange], ) -> None: service, _repository, _collaboration_repository, socketio, session, change = setup socketio.call.side_effect = [SocketIOTimeoutError(), {"revision": change.revision, "update": b"replacement"}] @@ -90,7 +112,7 @@ def test_retries_another_ready_client_after_the_selected_writer_times_out( def test_drops_a_proposal_when_its_lease_or_draft_version_was_superseded( - setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, MagicMock, ServerDraftChange], + setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, Session, ServerDraftChange], ) -> None: service, repository, _collaboration_repository, socketio, session, _change = setup repository.accept.return_value = False @@ -104,7 +126,7 @@ def test_drops_a_proposal_when_its_lease_or_draft_version_was_superseded( def test_skips_unready_browsers_to_reach_a_ready_writer( - setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, MagicMock, ServerDraftChange], + setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, Session, ServerDraftChange], ) -> None: service, _repository, collaboration_repository, socketio, session, change = setup collaboration_repository.get_session_sids.return_value = ["leader", "reconnecting", "ready"] @@ -122,7 +144,7 @@ def test_skips_unready_browsers_to_reach_a_ready_writer( def test_does_not_choose_another_writer_while_a_request_is_in_flight( - setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, MagicMock, ServerDraftChange], + setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, Session, ServerDraftChange], ) -> None: service, repository, _collaboration_repository, socketio, session, _change = setup repository.claim.return_value = False @@ -134,7 +156,7 @@ def test_does_not_choose_another_writer_while_a_request_is_in_flight( def test_rejects_unjoined_clients_and_scopes_draft_reads_to_the_socket_tenant( - setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, MagicMock, ServerDraftChange], + setup: tuple[WorkflowDraftSyncService, Mock, Mock, Mock, Session, ServerDraftChange], ) -> None: service, _repository, collaboration_repository, socketio, session, _change = setup collaboration_repository.get_sid_mapping.return_value = None @@ -143,7 +165,8 @@ def test_rejects_unjoined_clients_and_scopes_draft_reads_to_the_socket_tenant( socketio.get_session.assert_not_called() collaboration_repository.get_sid_mapping.return_value = {"workflow_id": "app-1", "user_id": "u-1"} - session.scalar.return_value = None + session.execute(delete(Workflow)) + session.commit() assert service.sync("follower", session=session)[1] == 404 statement = session.scalar.call_args.args[0] assert set(statement.compile().params.values()) == {"app-1", "tenant-1", "draft"} diff --git a/api/tests/unit_tests/services/tools/test_builder_credential_reference.py b/api/tests/unit_tests/services/tools/test_builder_credential_reference.py index e6b11f03395..3c4d3073289 100644 --- a/api/tests/unit_tests/services/tools/test_builder_credential_reference.py +++ b/api/tests/unit_tests/services/tools/test_builder_credential_reference.py @@ -173,7 +173,7 @@ class World: @pytest.fixture(autouse=True) -def forbidden_effects(monkeypatch: pytest.MonkeyPatch) -> dict[str, Mock]: +def forbidden_effects(mocker) -> dict[str, Mock]: """Selection is metadata only: any secret/runtime/provider owner call fails the test.""" from core.helper import encrypter from core.helper.provider_encryption import ProviderConfigEncrypter @@ -206,9 +206,8 @@ def forbidden_effects(monkeypatch: pytest.MonkeyPatch) -> dict[str, Mock]: ] spies: dict[str, Mock] = {} for owner, name in targets: - spy = Mock(side_effect=AssertionError("unexpected effect")) - monkeypatch.setattr(owner, name, spy) - spies[f"{getattr(owner, '__name__', owner)}.{name}"] = spy + spy = mocker.patch.object(owner, name, side_effect=AssertionError("unexpected effect")) + spies[f"{type(owner).__name__}.{name}"] = spy return spies @@ -550,16 +549,16 @@ def test_uncanonicalizable_row_provider_is_unavailable(world: World) -> None: refused(world, "credential_reference_unavailable") -@pytest.mark.parametrize("credential_id", [str(uuid4()), "", "x" * 129]) +@pytest.mark.parametrize("credential_id", [pytest.param(str(uuid4()), id="unknown-uuid"), "", "x" * 129]) def test_unknown_or_malformed_credential_id_is_unavailable(world: World, credential_id: str) -> None: refused(world, "credential_reference_unavailable", credential_id=credential_id) -def _session_that_must_not_query() -> Mock: +def _session_that_must_not_query(mocker) -> Session: # Any SQL would reach PostgreSQL's native UUID column; refusal must precede it. - db = Mock(spec=Session) + db = Session() for name in ("scalar", "scalars", "execute", "get", "query"): - getattr(db, name).side_effect = AssertionError("unexpected query") + mocker.patch.object(db, name, side_effect=AssertionError("unexpected query")) return db @@ -582,8 +581,8 @@ VALID_UUID = "6f1c3b0e-8a3d-4c2b-9f4e-2d7a1b5c9e10" "6f1c3b0e-8a3d-4c2b-9f4e-2d7a1b5c9e10'; --", ], ) -def test_noncanonical_credential_uuid_refuses_before_any_query(credential_id: str) -> None: - db = _session_that_must_not_query() +def test_noncanonical_credential_uuid_refuses_before_any_query(credential_id: str, mocker) -> None: + db = _session_that_must_not_query(mocker) with pytest.raises(BuilderExecutionPolicyError) as raised: BuiltinToolManageService.resolve_builder_reference( db=db, @@ -598,10 +597,10 @@ def test_noncanonical_credential_uuid_refuses_before_any_query(credential_id: st db.scalar.assert_not_called() -def test_canonical_credential_uuid_reaches_the_query_without_validating_actor_ids() -> None: +def test_canonical_credential_uuid_reaches_the_query_without_validating_actor_ids(mocker) -> None: # Actor/tenant identities are not credential UUIDs; only credential_id is checked. - db = Mock(spec=Session) - db.scalar.return_value = None + db = Session() + mocker.patch.object(db, "scalar", return_value=None) with pytest.raises(BuilderExecutionPolicyError, match="credential_reference_unavailable"): BuiltinToolManageService.resolve_builder_reference( db=db, diff --git a/api/tests/unit_tests/services/tools/test_ordinary_plugin_credentials.py b/api/tests/unit_tests/services/tools/test_ordinary_plugin_credentials.py index 64127e4a423..8d134ae4bc0 100644 --- a/api/tests/unit_tests/services/tools/test_ordinary_plugin_credentials.py +++ b/api/tests/unit_tests/services/tools/test_ordinary_plugin_credentials.py @@ -1,24 +1,61 @@ """Ordinary plugin runtime contracts; synthetic values and no provider I/O.""" -from types import SimpleNamespace +from dataclasses import dataclass +from types import ModuleType from unittest.mock import Mock import pytest +from sqlalchemy import Engine +from sqlalchemy.orm import Session, sessionmaker -from core.plugin.entities.plugin_daemon import CredentialType +from core.dify_builder.real_call_consent import ConfirmedToolBinding +from core.helper.provider_cache import NoOpProviderCredentialCache +from core.helper.provider_encryption import ProviderConfigEncrypter +from core.plugin.entities.plugin_daemon import CredentialType, PluginOAuthCredentialsResponse +from core.plugin.impl.oauth import OAuthHandler +from core.plugin.impl.tool import PluginToolManager from core.tools.entities.tool_entities import ToolEntity, ToolProviderEntityWithPlugin, ToolProviderType from core.tools.errors import ToolProviderCredentialValidationError from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.tool_manager import ToolManager +from models.tools import BuiltinToolProvider +from runtime_contracts.builder.execution import ConfirmedToolExecutionContext, context_digest from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError +from services.dify_builder.real_call_consent_service import BuilderRealCallConsentService, ServiceConfirmedToolGuard PROVIDER = "langgenius/search/search" CREDENTIAL = "00000000-0000-0000-0000-000000000002" CANARY = "synthetic-owner-canary" +@dataclass +class _CredentialDatabase: + engine: Engine + session: Session + + +@dataclass +class _CredentialOwner: + controller: PluginToolProviderController + row: BuiltinToolProvider + session: Session + credential_reads: list[str] + encrypter: ProviderConfigEncrypter + cache: NoOpProviderCredentialCache + policy: Mock + dispatch: Mock + module: ModuleType + plugin_lookup: Mock + builtin_lookup: Mock + + +@dataclass +class _RefreshUpdateResult: + rowcount: int | None + + @pytest.fixture -def owner(monkeypatch): +def owner(mocker, monkeypatch, sqlite_session, sqlite_engine): from core.tools import tool_manager as module try: @@ -32,8 +69,7 @@ def owner(monkeypatch): "parameters": [], } ) - controller = object.__new__(PluginToolProviderController) - controller.entity = ToolProviderEntityWithPlugin.model_validate( + provider_entity = ToolProviderEntityWithPlugin.model_validate( { "identity": { "icon": "", @@ -46,45 +82,54 @@ def owner(monkeypatch): "credentials_schema": [{"name": "api_key", "type": "secret-input"}], } ) - controller.tenant_id = "tenant-1" - controller.plugin_id = "langgenius/search" - controller.plugin_unique_identifier = "langgenius/search:1@hash" - plugin_lookup = Mock(return_value=controller) - monkeypatch.setattr(ToolManager, "get_plugin_provider", plugin_lookup) - builtin_lookup = Mock(return_value=controller) - monkeypatch.setattr(ToolManager, "get_builtin_provider", builtin_lookup) - row = SimpleNamespace( - id=CREDENTIAL, + controller = PluginToolProviderController( + entity=provider_entity, + tenant_id="tenant-1", + plugin_id="langgenius/search", + plugin_unique_identifier="langgenius/search:1@hash", + ) + plugin_lookup = mocker.patch.object(ToolManager, "get_plugin_provider", return_value=controller) + builtin_lookup = mocker.patch.object(ToolManager, "get_builtin_provider", return_value=controller) + from sqlalchemy import event + + from models.tools import BuiltinToolProvider + + row = BuiltinToolProvider( tenant_id="tenant-1", provider=PROVIDER, user_id="user-1", - credentials={"api_key": "ciphertext"}, + name="Search credentials", encrypted_credentials='{"api_key":"ciphertext"}', credential_type=CredentialType.API_KEY, expires_at=-1, ) - session = Mock() - session.scalar.return_value = row - context = Mock() - context.__enter__ = Mock(return_value=session) - context.__exit__ = Mock(return_value=False) - database = SimpleNamespace(session=session, engine=object()) + row.id = CREDENTIAL + session = sqlite_session + session.add(row) + session.commit() + credential_reads = [] + + def record_credential_read(_connection, _cursor, statement, _parameters, _context, _executemany): + if statement.lstrip().upper().startswith("SELECT") and "tool_builtin_providers" in statement: + credential_reads.append(statement) + + event.listen(sqlite_engine, "before_cursor_execute", record_credential_read) + database = _CredentialDatabase(session=session, engine=sqlite_engine) monkeypatch.setattr(module, "db", database) monkeypatch.setattr(credential_module, "db", database) - monkeypatch.setattr(credential_module, "Session", lambda *_args, **_kwargs: context) - encrypter = Mock() - encrypter.decrypt.return_value = {"api_key": CANARY} - encrypter.encrypt.return_value = {"api_key": "rotated-ciphertext"} - cache = Mock() + cache = NoOpProviderCredentialCache() + mocker.patch.object(cache, "delete") + encrypter = ProviderConfigEncrypter(tenant_id="tenant-1", config=[], provider_config_cache=cache) + mocker.patch.object(encrypter, "decrypt", return_value={"api_key": CANARY}) + mocker.patch.object(encrypter, "encrypt", return_value={"api_key": "rotated-ciphertext"}) monkeypatch.setattr(credential_module, "create_provider_encrypter", lambda **_kwargs: (encrypter, cache)) - policy = Mock() - monkeypatch.setattr("core.helper.credential_utils.runtime_check_credential_policy_compliance", policy) - dispatch = Mock(return_value=iter(())) - monkeypatch.setattr("core.tools.plugin_tool.tool.PluginToolManager.invoke", dispatch) - return SimpleNamespace( + policy = mocker.patch("core.helper.credential_utils.runtime_check_credential_policy_compliance") + dispatch = mocker.patch.object(PluginToolManager, "invoke", return_value=iter(())) + return _CredentialOwner( controller=controller, row=row, session=session, + credential_reads=credential_reads, encrypter=encrypter, cache=cache, policy=policy, @@ -133,6 +178,7 @@ def test_explicit_reference_reaches_actual_plugin_runtime(owner, provider_type): ) def test_mismatched_stored_identity_refuses_before_decryption(owner, field, value): setattr(owner.row, field, value) + owner.session.commit() with pytest.raises(ToolProviderCredentialValidationError): runtime() owner.encrypter.decrypt.assert_not_called() @@ -153,14 +199,43 @@ def test_default_reference_resolves_credentials(owner): def test_no_auth_uses_empty_credentials_without_read(owner): owner.controller.entity.credentials_schema = [] assert runtime().runtime.credentials == {} - owner.session.scalar.assert_not_called() + assert owner.credential_reads == [] owner.encrypter.decrypt.assert_not_called() -def test_confirmed_plugin_rejected_before_credential_or_provider_work(owner, monkeypatch): - lookup = Mock(side_effect=AssertionError("provider lookup")) - monkeypatch.setattr(ToolManager, "get_plugin_provider", lookup) - guard = SimpleNamespace(binding=SimpleNamespace(context=SimpleNamespace(tenant_id="tenant-1"))) +def test_confirmed_plugin_rejected_before_credential_or_provider_work(owner, mocker): + lookup = mocker.patch.object(ToolManager, "get_plugin_provider", side_effect=AssertionError("provider lookup")) + context = ConfirmedToolExecutionContext( + policy_version="builder-confirmed-gmail-read-v1", + request_id="request", + tenant_id="tenant-1", + app_id="app", + workflow_id="workflow", + actor_id="actor", + session_id="session", + test_input_id="input", + execution_revision="a" * 64, + graph_revision="b" * 64, + submitted_inputs_digest="c" * 64, + effective_inputs_digest="d" * 64, + context_digest="e" * 64, + mode="confirmed_tool", + sandbox_profile="disabled", + admitted_nodes=(), + consent_id="consent", + scope_digest="f" * 64, + command_id="command", + ) + context = context.model_copy(update={"context_digest": context_digest(context)}) + guard = ServiceConfirmedToolGuard( + BuilderRealCallConsentService(sessionmaker(bind=owner.session.get_bind())), + ConfirmedToolBinding( + context=context, + node_id="tool", + native_run_id="run", + native_node_execution_id="execution", + ), + ) with pytest.raises(BuilderExecutionPolicyError): runtime(confirmed_guard=guard) lookup.assert_not_called() @@ -168,15 +243,17 @@ def test_confirmed_plugin_rejected_before_credential_or_provider_work(owner, mon def test_native_json_rejection_precedes_credential_work(owner): - validator = Mock(side_effect=AssertionError("validator should not be reached")) + def validator(_provider, _tool): + raise AssertionError("validator should not be reached") + with pytest.raises(BuilderExecutionPolicyError): runtime(json_provider_validator=validator) - owner.session.scalar.assert_not_called() + assert owner.credential_reads == [] owner.encrypter.decrypt.assert_not_called() @pytest.fixture -def persisted_credentials(sqlite_engine): +def persisted_credentials(sqlite_engine, owner): from sqlalchemy.orm import Session from models.base import TypeBase @@ -186,20 +263,22 @@ def persisted_credentials(sqlite_engine): TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[BuiltinToolProvider.__tablename__]]) with Session(sqlite_engine, expire_on_commit=False) as session: row = _builtin_provider( - provider_id=CREDENTIAL, + provider_id=owner.row.id, tenant_id="tenant-1", provider=PROVIDER, credential_type=CredentialType.OAUTH2, expires_at=1, ) - session.add(row) + session.merge(row) session.commit() - yield SimpleNamespace(engine=sqlite_engine, session=_CallableSessionProxy(session)) + yield _CredentialDatabase(engine=sqlite_engine, session=_CallableSessionProxy(session)) @pytest.mark.parametrize("provider_type", [ToolProviderType.PLUGIN, ToolProviderType.BUILT_IN]) @pytest.mark.parametrize("credential_id", [None, CREDENTIAL]) -def test_refresh_persists_actual_selected_row(owner, persisted_credentials, monkeypatch, provider_type, credential_id): +def test_refresh_persists_actual_selected_row( + owner, persisted_credentials, mocker, monkeypatch, provider_type, credential_id +): from models.tools import BuiltinToolProvider monkeypatch.setattr(owner.module, "db", persisted_credentials) @@ -210,8 +289,11 @@ def test_refresh_persists_actual_selected_row(owner, persisted_credentials, monk monkeypatch.setattr( "services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client", lambda *_args: {} ) - refresh = Mock(return_value=SimpleNamespace(credentials={"api_key": "rotated"}, expires_at=9999999999)) - monkeypatch.setattr("core.plugin.impl.oauth.OAuthHandler.refresh_credentials", refresh) + refresh = mocker.patch.object( + OAuthHandler, + "refresh_credentials", + return_value=PluginOAuthCredentialsResponse(credentials={"api_key": "rotated"}, expires_at=9999999999), + ) result = runtime(provider_type=provider_type, credential_id=credential_id) assert result.runtime.credentials == {"api_key": "rotated"} with Session(persisted_credentials.engine) as session: @@ -225,7 +307,7 @@ def test_refresh_persists_actual_selected_row(owner, persisted_credentials, monk @pytest.mark.parametrize("conflict", ["rotation", "delete", "owner", "expiry"]) def test_refresh_conflict_refuses_runtime_without_cache_invalidation( - owner, persisted_credentials, monkeypatch, conflict + owner, persisted_credentials, mocker, monkeypatch, conflict ): from sqlalchemy import delete, update from sqlalchemy.orm import Session @@ -251,10 +333,9 @@ def test_refresh_conflict_refuses_runtime_without_cache_invalidation( session.execute( update(BuiltinToolProvider).where(BuiltinToolProvider.id == CREDENTIAL).values(**values) ) - return SimpleNamespace(credentials={"api_key": "rotated"}, expires_at=9999999999) + return PluginOAuthCredentialsResponse(credentials={"api_key": "rotated"}, expires_at=9999999999) - refresh = Mock(side_effect=rotate) - monkeypatch.setattr("core.plugin.impl.oauth.OAuthHandler.refresh_credentials", refresh) + refresh = mocker.patch.object(OAuthHandler, "refresh_credentials", side_effect=rotate) with pytest.raises(ToolProviderCredentialValidationError): runtime(credential_id=None) refresh.assert_called_once() @@ -264,16 +345,19 @@ def test_refresh_conflict_refuses_runtime_without_cache_invalidation( def test_workspace_credential_delegation_does_not_compare_runtime_user_to_creator(owner): owner.row.user_id = "workspace-credential-creator" + owner.session.commit() tool = runtime() assert tool.runtime.user_id == "user-1" assert tool.runtime.credentials == {"api_key": CANARY} -def test_policy_refusal_precedes_decrypt_refresh_or_dispatch(owner, monkeypatch): +def test_policy_refusal_precedes_decrypt_refresh_or_dispatch(owner, mocker): owner.row.expires_at = 1 + owner.session.commit() owner.policy.side_effect = ValueError("policy refused") - refresh = Mock(side_effect=AssertionError("refresh must not run")) - monkeypatch.setattr("core.plugin.impl.oauth.OAuthHandler.refresh_credentials", refresh) + refresh = mocker.patch.object( + OAuthHandler, "refresh_credentials", side_effect=AssertionError("refresh must not run") + ) with pytest.raises(ValueError, match="policy refused"): runtime() owner.encrypter.decrypt.assert_not_called() @@ -281,13 +365,15 @@ def test_policy_refusal_precedes_decrypt_refresh_or_dispatch(owner, monkeypatch) owner.dispatch.assert_not_called() -def test_ordinary_refresh_failure_refuses_without_cache_invalidation(owner, monkeypatch): +def test_ordinary_refresh_failure_refuses_without_cache_invalidation(owner, mocker, monkeypatch): owner.row.expires_at = 1 + owner.session.commit() monkeypatch.setattr( "services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client", lambda *_args: {} ) - refresh = Mock(side_effect=ValueError("synthetic refresh refusal")) - monkeypatch.setattr("core.plugin.impl.oauth.OAuthHandler.refresh_credentials", refresh) + refresh = mocker.patch.object( + OAuthHandler, "refresh_credentials", side_effect=ValueError("synthetic refresh refusal") + ) with pytest.raises(ToolProviderCredentialValidationError, match="could not be refreshed"): runtime() refresh.assert_called_once() @@ -295,7 +381,9 @@ def test_ordinary_refresh_failure_refuses_without_cache_invalidation(owner, monk owner.dispatch.assert_not_called() -def test_hardcoded_refresh_retains_original_state_across_session_expiration(owner, persisted_credentials, monkeypatch): +def test_hardcoded_refresh_retains_original_state_across_session_expiration( + owner, persisted_credentials, mocker, monkeypatch +): from sqlalchemy import update from sqlalchemy.orm import Session @@ -306,12 +394,8 @@ def test_hardcoded_refresh_retains_original_state_across_session_expiration(owne persisted_credentials.session.expire_all() monkeypatch.setattr(owner.module, "db", persisted_credentials) monkeypatch.setattr(owner.module, "Session", Session) - controller = SimpleNamespace( - get_tool=owner.controller.get_tool, - need_credentials=True, - get_credentials_schema_by_type=owner.controller.get_credentials_schema_by_type, - ) - monkeypatch.setattr(ToolManager, "get_builtin_provider", lambda *_args: controller) + controller = owner.controller + owner.builtin_lookup.return_value = controller monkeypatch.setattr( "services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client", lambda *_args: {} ) @@ -322,10 +406,9 @@ def test_hardcoded_refresh_retains_original_state_across_session_expiration(owne # A collaborator expiring the ordinary scoped session must not replace # the original ciphertext bound before external refresh. persisted_credentials.session.expire_all() - return SimpleNamespace(credentials={"api_key": "rotated"}, expires_at=9999999999) + return PluginOAuthCredentialsResponse(credentials={"api_key": "rotated"}, expires_at=9999999999) - refresh = Mock(side_effect=rotate) - monkeypatch.setattr("core.plugin.impl.oauth.OAuthHandler.refresh_credentials", refresh) + refresh = mocker.patch.object(OAuthHandler, "refresh_credentials", side_effect=rotate) with pytest.raises(ToolProviderCredentialValidationError, match="changed during refresh"): ToolManager.get_tool_runtime( provider_type=ToolProviderType.BUILT_IN, @@ -386,7 +469,7 @@ class _RefreshWriteSession: def execute(self, statement): self.statement = statement self.events.append("update") - return SimpleNamespace(rowcount=self.rowcount) + return _RefreshUpdateResult(rowcount=self.rowcount) @pytest.mark.parametrize("backend", ["postgresql", "mysql", "oceanbase", "seekdb"]) @@ -424,16 +507,18 @@ def test_refresh_update_compiles_for_supported_database_configuration(owner, mon @pytest.mark.parametrize("rowcount", [1, 0, -1, None, 2]) -def test_refresh_requires_exactly_one_affected_row_before_cache_and_dispatch(owner, monkeypatch, rowcount): +def test_refresh_requires_exactly_one_affected_row_before_cache_and_dispatch(owner, mocker, monkeypatch, rowcount): from contextlib import contextmanager owner.row.expires_at = 1 + owner.session.commit() events = [] writer = _RefreshWriteSession(rowcount, events) @contextmanager def read_session(): yield owner.session + owner.session.close() events.append("read-closed") sessions = iter([read_session(), writer]) @@ -441,12 +526,14 @@ def test_refresh_requires_exactly_one_affected_row_before_cache_and_dispatch(own monkeypatch.setattr( "services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client", lambda *_args: {} ) - refresh = Mock( + refresh = mocker.patch.object( + OAuthHandler, + "refresh_credentials", side_effect=lambda **_kwargs: ( - events.append("refresh") or SimpleNamespace(credentials={"api_key": "rotated"}, expires_at=9999999999) - ) + events.append("refresh") + or PluginOAuthCredentialsResponse(credentials={"api_key": "rotated"}, expires_at=9999999999) + ), ) - monkeypatch.setattr("core.plugin.impl.oauth.OAuthHandler.refresh_credentials", refresh) owner.cache.delete.side_effect = lambda: events.append("cache-delete") owner.dispatch.side_effect = lambda **_kwargs: events.append("dispatch") or iter(()) if rowcount == 1: @@ -462,16 +549,18 @@ def test_refresh_requires_exactly_one_affected_row_before_cache_and_dispatch(own refresh.assert_called_once() -def test_refresh_commit_uncertainty_refuses_cache_invalidation_and_dispatch(owner, monkeypatch): +def test_refresh_commit_uncertainty_refuses_cache_invalidation_and_dispatch(owner, mocker, monkeypatch): from contextlib import contextmanager owner.row.expires_at = 1 + owner.session.commit() events = [] writer = _RefreshWriteSession(1, events, commit_fails=True) @contextmanager def read_session(): yield owner.session + owner.session.close() events.append("read-closed") sessions = iter([read_session(), writer]) @@ -479,12 +568,14 @@ def test_refresh_commit_uncertainty_refuses_cache_invalidation_and_dispatch(owne monkeypatch.setattr( "services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client", lambda *_args: {} ) - refresh = Mock( + refresh = mocker.patch.object( + OAuthHandler, + "refresh_credentials", side_effect=lambda **_kwargs: ( - events.append("refresh") or SimpleNamespace(credentials={"api_key": "rotated"}, expires_at=9999999999) - ) + events.append("refresh") + or PluginOAuthCredentialsResponse(credentials={"api_key": "rotated"}, expires_at=9999999999) + ), ) - monkeypatch.setattr("core.plugin.impl.oauth.OAuthHandler.refresh_credentials", refresh) with pytest.raises(ToolProviderCredentialValidationError, match="could not be persisted"): runtime() assert events == ["read-closed", "refresh", "begin", "update", "rollback"] diff --git a/api/tests/unit_tests/tasks/test_ops_trace_task.py b/api/tests/unit_tests/tasks/test_ops_trace_task.py index 9ebfd0e5950..d2e0c87dd1a 100644 --- a/api/tests/unit_tests/tasks/test_ops_trace_task.py +++ b/api/tests/unit_tests/tasks/test_ops_trace_task.py @@ -306,11 +306,18 @@ def test_process_trace_tasks_deletes_payload_and_counts_exhausted_retryable_disp @pytest.mark.parametrize("marker", [True, "false", 0, None, {}]) -def test_restricted_or_malformed_trace_policy_never_resolves_provider_but_keeps_enterprise(marker): - provider = MagicMock(side_effect=AssertionError("external provider resolution forbidden")) - enterprise = MagicMock() - modules = _install_trace_manager(MagicMock(), enterprise_enabled=True, enterprise_trace_cls=enterprise) - modules["core.ops.ops_trace_manager"].OpsTraceManager.get_ops_trace_instance = provider +def test_restricted_or_malformed_trace_policy_never_resolves_provider_but_keeps_enterprise(marker, mocker): + class EnterpriseTraceBoundary: + def trace(self, _data): + raise AssertionError("trace transport must be patched") + + modules = _install_trace_manager(None, enterprise_enabled=True, enterprise_trace_cls=EnterpriseTraceBoundary) + provider = mocker.patch.object( + modules["core.ops.ops_trace_manager"].OpsTraceManager, + "get_ops_trace_instance", + side_effect=AssertionError("external provider resolution forbidden"), + ) + enterprise_trace = mocker.patch.object(EnterpriseTraceBoundary, "trace") with ( patch.dict(sys.modules, modules), patch( @@ -327,5 +334,5 @@ def test_restricted_or_malformed_trace_policy_never_resolves_provider_but_keeps_ ): _run_task({"app_id": "app", "file_id": "trace"}) provider.assert_not_called() - enterprise.return_value.trace.assert_called_once_with({}) + enterprise_trace.assert_called_once_with({}) delete.assert_called_once() diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index d7263194756..9d886a52dd1 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -81,7 +81,7 @@ def test_delete_workflow_agent_node_bindings_is_scoped_to_tenant_and_app(sqlite_ assert sqlite_session.get(WorkflowAgentNodeBinding, other_tenant_id) is not None -def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest.MonkeyPatch) -> None: +def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest.MonkeyPatch, mocker) -> None: events: list[str] = [] apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) other_cleanup_names = ( @@ -117,14 +117,18 @@ def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest delete_bindings = MagicMock(side_effect=lambda *_args: events.append("bindings")) delete_workflows = MagicMock(side_effect=lambda *_args: events.append("workflows")) - delete_execution_requests = MagicMock(side_effect=lambda *_args: events.append("execution_requests")) - delete_consents = MagicMock(side_effect=lambda *_args: events.append("consents")) + delete_execution_requests = mocker.patch.object( + remove_app_task_module, + "_delete_builder_execution_requests", + side_effect=lambda *_args: events.append("execution_requests"), + ) + delete_consents = mocker.patch.object( + remove_app_task_module, + "_delete_builder_real_call_consents", + side_effect=lambda *_args: events.append("consents"), + ) monkeypatch.setattr(remove_app_task_module, "_delete_workflow_agent_node_bindings", delete_bindings) monkeypatch.setattr(remove_app_task_module, "_delete_app_workflows", delete_workflows) - monkeypatch.setattr( - remove_app_task_module, "_delete_builder_execution_requests", delete_execution_requests, raising=False - ) - monkeypatch.setattr(remove_app_task_module, "_delete_builder_real_call_consents", delete_consents, raising=False) remove_app_task_module.remove_app_and_related_data_task.run(tenant_id="tenant-1", app_id="app-1") diff --git a/api/tests/workflow_empty_draft_test_utils.py b/api/tests/workflow_empty_draft_test_utils.py index 6423229c5de..00d47b23d71 100644 --- a/api/tests/workflow_empty_draft_test_utils.py +++ b/api/tests/workflow_empty_draft_test_utils.py @@ -5,7 +5,6 @@ import json from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime -from types import SimpleNamespace from uuid import uuid4 import pytest @@ -108,6 +107,14 @@ def workflow_rows(factory: sessionmaker[Session]) -> list[tuple[object, ...]]: ] +@dataclass +class EmptyDraftDatabase: + owner_session: Session + + def session(self) -> Session: + return self.owner_session + + def post_empty_draft( app: Flask, monkeypatch: pytest.MonkeyPatch, @@ -127,7 +134,7 @@ def post_empty_draft( handle and service repository factory are bound to the test database; parsing, validation, locking, hash checks and writes are real. """ - monkeypatch.setattr(workflow_module, "db", SimpleNamespace(session=lambda: session)) + monkeypatch.setattr(workflow_module, "db", EmptyDraftDatabase(session)) service = WorkflowService(sessionmaker(bind=session.get_bind(), expire_on_commit=False)) monkeypatch.setattr(workflow_module, "WorkflowService", lambda: service) target = app_model if app_model is not None else owner.app diff --git a/dev/start-worker b/dev/start-worker index 366a5ef0a35..66981539c1a 100755 --- a/dev/start-worker +++ b/dev/start-worker @@ -85,7 +85,7 @@ while [[ $# -gt 0 ]]; do done SCRIPT_DIR="$(dirname "$(realpath "$0")")" -cd "$SCRIPT_DIR/../api" +cd "$SCRIPT_DIR/../api" || exit 1 if [[ -n "${ENV_FILE}" ]]; then if [[ ! -f "${ENV_FILE}" ]]; then @@ -96,6 +96,8 @@ if [[ -n "${ENV_FILE}" ]]; then echo "Loading environment variables from ${ENV_FILE}" # Export everything sourced from the env file set -a + # Environment assignments follow the repository's documented API env schema. + # shellcheck source=../api/.env.example source "${ENV_FILE}" set +a fi @@ -124,4 +126,4 @@ echo " Log Level: ${LOGLEVEL}" uv run \ celery -A app.celery worker \ - -P ${POOL} -c ${CONCURRENCY} --loglevel ${LOGLEVEL} -Q ${QUEUES} + -P "${POOL}" -c "${CONCURRENCY}" --loglevel "${LOGLEVEL}" -Q "${QUEUES}" diff --git a/dify-agent-runtime/docker/native-test-seccomp.json b/dify-agent-runtime/docker/native-test-seccomp.json index a9312133e16..c87792deb6c 100644 --- a/dify-agent-runtime/docker/native-test-seccomp.json +++ b/dify-agent-runtime/docker/native-test-seccomp.json @@ -1,10 +1,7 @@ { "defaultAction": "SCMP_ACT_ERRNO", "defaultErrnoRet": 1, - "architectures": [ - "SCMP_ARCH_X86_64", - "SCMP_ARCH_AARCH64" - ], + "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_AARCH64"], "syscalls": [ { "names": [ diff --git a/dify-agent-runtime/tests/test_native_code_environment.py b/dify-agent-runtime/tests/test_native_code_environment.py index 881b850315e..ca0745fe7c6 100644 --- a/dify-agent-runtime/tests/test_native_code_environment.py +++ b/dify-agent-runtime/tests/test_native_code_environment.py @@ -5,11 +5,13 @@ import json import os import runpy import shutil +import subprocess import sys import tempfile import unittest +from importlib.machinery import ModuleSpec from pathlib import Path -from types import SimpleNamespace +from types import ModuleType from unittest.mock import patch PROBE = Path(__file__).resolve().parents[1] / "docker/native-code-environment.py" @@ -26,14 +28,28 @@ class EnvironmentProbeTests(unittest.TestCase): ownership = patch.dict( trusted.__globals__, _trusted=lambda info, directory=False: trusted( - SimpleNamespace(st_uid=0, st_mode=info.st_mode), directory + os.stat_result( + ( + info.st_mode, + info.st_ino, + info.st_dev, + info.st_nlink, + 0, + info.st_gid, + info.st_size, + info.st_atime, + info.st_mtime, + info.st_ctime, + ) + ), + directory, ), ) ownership.start() self.addCleanup(ownership.stop) self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name) + self.root = Path(self.temp.name).resolve() self.fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY) self.addCleanup(os.close, self.fd) @@ -118,29 +134,33 @@ class EnvironmentProbeTests(unittest.TestCase): def test_file_count_and_byte_limits_refuse(self): (self.root / "pkg").mkdir() (self.root / "pkg/a").write_bytes(b"abc") - with patch.dict( - self.probe["ContentManifest"].add_tree.__globals__, MAX_FILE_BYTES=2 + with ( + patch.dict( + self.probe["ContentManifest"].add_tree.__globals__, MAX_FILE_BYTES=2 + ), + self.assertRaises(self.probe["Refusal"]), ): - with self.assertRaises(self.probe["Refusal"]): - self.manifest().add_tree("pkg") - with patch.dict( - self.probe["ContentManifest"].add_tree.__globals__, MAX_FILES=0 + self.manifest().add_tree("pkg") + with ( + patch.dict(self.probe["ContentManifest"].add_tree.__globals__, MAX_FILES=0), + self.assertRaises(self.probe["Refusal"]), ): - with self.assertRaises(self.probe["Refusal"]): - self.manifest().add_tree("pkg") + self.manifest().add_tree("pkg") def test_untrusted_ownership_refused(self): with self.assertRaises(self.probe["Refusal"]): - self.trusted(SimpleNamespace(st_uid=1000, st_mode=0o100644)) - self.trusted(SimpleNamespace(st_uid=0, st_mode=0o100644)) + self.trusted(os.stat_result((0o100644, 0, 0, 1, 1000, 1000, 0, 0, 0, 0))) + self.trusted(os.stat_result((0o100644, 0, 0, 1, 0, 0, 0, 0, 0, 0))) def test_empty_directory_traversal_is_globally_bounded(self): (self.root / "pkg/one/two").mkdir(parents=True) - with patch.dict( - self.probe["ContentManifest"].add_tree.__globals__, MAX_ENTRIES=2 + with ( + patch.dict( + self.probe["ContentManifest"].add_tree.__globals__, MAX_ENTRIES=2 + ), + self.assertRaises(self.probe["Refusal"]), ): - with self.assertRaises(self.probe["Refusal"]): - self.manifest().add_tree("pkg") + self.manifest().add_tree("pkg") def test_file_mutation_during_hash_is_refused(self): target = self.root / "mutable" @@ -152,9 +172,11 @@ class EnvironmentProbeTests(unittest.TestCase): target.write_bytes(b"second") return content - with patch("os.read", side_effect=read_and_mutate): - with self.assertRaises(self.probe["Refusal"]): - self.manifest().add_file("mutable") + with ( + patch("os.read", side_effect=read_and_mutate), + self.assertRaises(self.probe["Refusal"]), + ): + self.manifest().add_file("mutable") def test_wrong_package_origin_refused(self): self.probe["require_origin"]( @@ -207,6 +229,10 @@ class EnvironmentProbeTests(unittest.TestCase): path = site / name path.mkdir(parents=True, exist_ok=True) (path / "__init__.py").write_bytes(b"original measured package") + for name, version in self.profile()["packages"].items(): + (site / f"{name.lower()}-{version}.dist-info/METADATA").write_text( + f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n" + ) suffix = ".cpython-313-x86_64-linux-gnu.so" (site / ("markupsafe/_speedups" + suffix)).write_bytes(b"synthetic extension") # Prepare replacement trees before the first snapshot so the hook only @@ -241,18 +267,14 @@ class EnvironmentProbeTests(unittest.TestCase): loader = self.probe["importlib"].machinery.ExtensionFileLoader( name, origin(name) ) - return SimpleNamespace(origin=origin(name), loader=loader) + return ModuleSpec(name=name, loader=loader, origin=origin(name)) def import_module(name): if import_hook is not None and name == "jinja2": import_hook(site) - return SimpleNamespace(__file__=origin(name)) - - def distribution(path): - name = "Jinja2" if path.name.startswith("jinja2-") else "MarkupSafe" - return SimpleNamespace( - metadata={"Name": name}, version=self.profile()["packages"][name] - ) + module = ModuleType(name) + module.__file__ = origin(name) + return module def node_version(_binary): if node_hook is not None: @@ -266,10 +288,10 @@ class EnvironmentProbeTests(unittest.TestCase): _root_fd=root_fd, node_version=node_version, ), + patch.object(sys, "platform", "linux"), patch.object(sys, "prefix", str(self.root)), patch.object(sys, "base_prefix", str(self.root)), patch.object(sys, "executable", str(self.root / "bin/python3.13")), - patch.object(sys, "flags", SimpleNamespace(isolated=True)), patch("platform.python_version", return_value="3.13.7"), patch("platform.python_implementation", return_value="CPython"), patch("platform.machine", return_value="x86_64"), @@ -277,17 +299,59 @@ class EnvironmentProbeTests(unittest.TestCase): "sysconfig.get_config_var", side_effect=lambda key: suffix if key == "EXT_SUFFIX" else False, ), - patch("importlib.metadata.PathDistribution", side_effect=distribution), patch("importlib.util.find_spec", side_effect=find_spec), patch("importlib.import_module", side_effect=import_module), ): return collect() + def test_nonisolated_interpreter_refuses_before_dependency_discovery(self): + if sys.flags.isolated: + completed = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve()), + f"{type(self).__name__}.{self._testMethodName}", + "-v", + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual( + completed.returncode, 0, completed.stdout + completed.stderr + ) + self.assertIn("\nOK\n", completed.stderr) + return + with self.assertRaisesRegex(self.probe["Refusal"], "unexpected_python_origin"): + self.synthetic_collection() + + def execute_with_isolated_interpreter(self): + """Run the same unittest method with actual CPython isolated flags.""" + if sys.flags.isolated: + return False + selected = f"{type(self).__name__}.{self._testMethodName}" + completed = subprocess.run( + [sys.executable, "-I", str(Path(__file__).resolve()), selected, "-v"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + self.assertIn("Ran 1 test", completed.stderr) + self.assertIn("\nOK\n", completed.stderr) + return True + def test_synthetic_collection_with_stable_ancestors(self): + if self.execute_with_isolated_interpreter(): + return result = self.synthetic_collection() self.assertEqual(result["status"], "observed") def test_import_ancestor_substitution_and_restoration_refuses(self): + if self.execute_with_isolated_interpreter(): + return seen = [] def import_boundary(site): @@ -312,6 +376,8 @@ class EnvironmentProbeTests(unittest.TestCase): ) def test_import_ancestor_symlink_substitution_and_restoration_refuses(self): + if self.execute_with_isolated_interpreter(): + return seen = [] def import_boundary(site): @@ -331,6 +397,8 @@ class EnvironmentProbeTests(unittest.TestCase): self.assertEqual(seen, [b"replacement package"]) def test_node_ancestor_substitution_and_restoration_refuses(self): + if self.execute_with_isolated_interpreter(): + return events = [] def node_boundary(directory): @@ -365,9 +433,11 @@ class EnvironmentProbeTests(unittest.TestCase): NODE_OPTIONS="--bad-option", ): self.assertEqual(node_version(path), "v24.20.0") - with patch.dict(node_version.__globals__, NODE_TIMEOUT_SECONDS=0.1): - with self.assertRaises(self.probe["Refusal"]): - node_version(self.fake_node("import time; time.sleep(5)\n")) + with ( + patch.dict(node_version.__globals__, NODE_TIMEOUT_SECONDS=0.1), + self.assertRaises(self.probe["Refusal"]), + ): + node_version(self.fake_node("import time; time.sleep(5)\n")) with self.assertRaises(self.probe["Refusal"]): node_version(self.fake_node("import os; os.write(1, b'x' * 100000)\n")) with self.assertRaises(self.probe["Refusal"]): @@ -375,14 +445,16 @@ class EnvironmentProbeTests(unittest.TestCase): def test_refusal_output_contains_no_secrets_or_capability_claims(self): observe = self.probe["observe"] - with patch.dict(os.environ, SECRET_SENTINEL="never-output-this-value"): - with patch.dict( + with ( + patch.dict(os.environ, SECRET_SENTINEL="never-output-this-value"), + patch.dict( observe.__globals__, collect_environment=lambda: (_ for _ in ()).throw( self.probe["Refusal"]("unsupported_python") ), - ): - result = observe() + ), + ): + result = observe() self.assertEqual( result, { diff --git a/dify-agent/src/dify_agent/adapters/native_testing/client.py b/dify-agent/src/dify_agent/adapters/native_testing/client.py index 1db8330e6d9..6b8e45a6072 100644 --- a/dify-agent/src/dify_agent/adapters/native_testing/client.py +++ b/dify-agent/src/dify_agent/adapters/native_testing/client.py @@ -17,14 +17,14 @@ from dify_agent.protocol.native_testing import ( AgentEffectReceipt, AgentNativeTestRef, AgentPreparedConfigResponse, + AgentPrepareIntentPayload, AgentPrepareRunPayload, AgentPrepareRunResponse, AgentReadPreparedConfigPayload, - AgentPrepareIntentPayload, - AgentStoreResultPayload, - AgentStoredResultResponse, AgentReadResultPayload, AgentReadResultResponse, + AgentStoredResultResponse, + AgentStoreResultPayload, NativeDTO, executable_request_digest, ) @@ -135,12 +135,14 @@ class HttpAgentPolicyClient: except (httpx.HTTPError, ValueError): raise AgentPolicyTransportUnavailable("native_policy_transport_unavailable") from None - async def _post_preparation(self, path: str, payload: NativeDTO, *, response_limit: int) -> bytes: + async def _post_preparation( + self, path: str, payload: AgentPrepareRunPayload | AgentReadPreparedConfigPayload, *, response_limit: int + ) -> bytes: body = payload.model_dump_json().encode() if len(body) > MAX_AGENT_CONTROL_BYTES: raise AgentPolicyTransportUnavailable("native_preparation_unavailable") - parent = getattr(payload, "parent_node", None) - if parent is None or (self.ref is not None and self.ref != parent.ref): + parent = payload.parent_node + if self.ref is not None and self.ref != parent.ref: raise AgentPolicyTransportUnavailable("native_policy_owner_mismatch") self.ref = parent.ref url, headers = agent_api_endpoint( diff --git a/dify-agent/src/dify_agent/layers/runtime/layer.py b/dify-agent/src/dify_agent/layers/runtime/layer.py index b9c43ba479f..31143e972f7 100644 --- a/dify-agent/src/dify_agent/layers/runtime/layer.py +++ b/dify-agent/src/dify_agent/layers/runtime/layer.py @@ -37,8 +37,7 @@ class Capability(AbstractCapability[Deps]): native = native_binding_identity(config.backend_binding_ref) confirmed = ( ctx.deps.native_effects is not None - and getattr(getattr(ctx.deps.native_effects, "continuation", None), "native_test_profile", None) - == "confirmed" + and ctx.deps.native_effects.continuation.native_test_profile == "confirmed" ) if confirmed and native is not None: raise NativePolicyStopped("native_runtime_lineage_mismatch") diff --git a/dify-agent/src/dify_agent/runtime/run_scheduler.py b/dify-agent/src/dify_agent/runtime/run_scheduler.py index 06a67c81555..283f1b5b393 100644 --- a/dify-agent/src/dify_agent/runtime/run_scheduler.py +++ b/dify-agent/src/dify_agent/runtime/run_scheduler.py @@ -15,10 +15,10 @@ structured-output schemas, or session snapshots become asynchronous import asyncio import logging -from dataclasses import replace from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager -from typing import Protocol +from dataclasses import replace +from typing import Protocol, runtime_checkable from dify_agent.adapters.native_testing.transport import ( AgentRuntimeHTTPClient, @@ -26,22 +26,30 @@ from dify_agent.adapters.native_testing.transport import ( bind_runtime_identity, ) from dify_agent.protocol.native_testing import ( - AgentRuntimeIdentity, - AgentNativeTestRef, - ReadAgentRuntimeIdentity, NATIVE_TEST_CAPABILITIES, + AgentNativeTestRef, + AgentRuntimeIdentity, AgentRuntimeIdentitySeed, ContinuationClaim, ContinueRunRequest, NativeTestCapabilities, NativeTestProtocolUnsupported, + ReadAgentRuntimeIdentity, executable_request_digest, require_native_test_capabilities, ) -from dify_agent.protocol.schemas import AgentRunUsage, CancelRunRequest, CancelRunResponse, CreateRunRequest, RunStatus -from dify_agent.protocol.snapshot import SessionSnapshot from dify_agent.protocol.run_recovery import LookupRunRequest, create_run_fingerprint +from dify_agent.protocol.schemas import ( + AgentRunUsage, + CancelRunRequest, + CancelRunResponse, + CreateRunRequest, + CreateRunResponse, + RunStatus, +) +from dify_agent.protocol.snapshot import SessionSnapshot from dify_agent.runtime.cancellation import RunCancellationIntent +from dify_agent.runtime.confirmed_continuation import ConfirmedNativeContinuationOwner from dify_agent.runtime.context import Services from dify_agent.runtime.event_coalescer import ( DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS, @@ -71,6 +79,17 @@ class RunCancellationConflictError(RuntimeError): """Raised when a run exists but a different terminal state already won.""" +@runtime_checkable +class IdempotentRunStore(Protocol): + """Existing keyed-create storage API needed for capability advertisement.""" + + async def claim_run( + self, *, selection: LookupRunRequest, identity: AgentRuntimeIdentitySeed | None = None + ) -> RunCreateClaim: ... + + async def lookup_run(self, selection: LookupRunRequest) -> CreateRunResponse: ... + + class RunStore(RunEventSink, Protocol): """Persistence boundary needed by the scheduler.""" @@ -182,7 +201,8 @@ class RunScheduler: native_owner is not None and (services.inner_api_key or services.plugin_daemon_api_key) and not ( - getattr(native_owner, "profile", None) == "confirmed" + isinstance(native_owner, ConfirmedNativeContinuationOwner) + and native_owner.profile == "confirmed" and isinstance(services.confirmed_api_http_client, AgentRuntimeHTTPClient) ) ): @@ -207,8 +227,9 @@ class RunScheduler: capabilities = self.native_owner.capabilities if self.native_owner is not None else NATIVE_TEST_CAPABILITIES if ( self.services.confirmed_api_http_client is not None - and callable(getattr(self.store, "claim_run", None)) - and callable(getattr(self.store, "lookup_run", None)) + and isinstance(self.store, IdempotentRunStore) + and callable(self.store.claim_run) + and callable(self.store.lookup_run) ): return capabilities.model_copy(update={"features": capabilities.features | {"create-idempotency-v1"}}) return capabilities diff --git a/dify-agent/src/dify_agent/runtime_backend/native_test_scope.py b/dify-agent/src/dify_agent/runtime_backend/native_test_scope.py index 62604e9b1cc..07e5eb8cce3 100644 --- a/dify-agent/src/dify_agent/runtime_backend/native_test_scope.py +++ b/dify-agent/src/dify_agent/runtime_backend/native_test_scope.py @@ -28,8 +28,12 @@ class ShellJobRef(BaseModel): def validate_job_identity(*, saved: ShellJobRef, actual: ShellJobRef) -> None: - fields = ("binding_ref", "backend_revision", "workspace_generation", "job_id") - if any(getattr(saved, key) != getattr(actual, key) for key in fields): + if (saved.binding_ref, saved.backend_revision, saved.workspace_generation, saved.job_id) != ( + actual.binding_ref, + actual.backend_revision, + actual.workspace_generation, + actual.job_id, + ): raise AgentJobUnavailable("native_job_identity_mismatch") diff --git a/dify-agent/tests/local/dify_agent/adapters/llm/test_model_receipt.py b/dify-agent/tests/local/dify_agent/adapters/llm/test_model_receipt.py index 1374663ce5b..01809a54a0b 100644 --- a/dify-agent/tests/local/dify_agent/adapters/llm/test_model_receipt.py +++ b/dify-agent/tests/local/dify_agent/adapters/llm/test_model_receipt.py @@ -2,7 +2,6 @@ import asyncio import json -from types import SimpleNamespace import httpx import pytest @@ -11,14 +10,19 @@ from graphon.model_runtime.entities.message_entities import UserPromptMessage from pydantic_ai.exceptions import UnexpectedModelBehavior from dify_agent.adapters.llm.provider import DifyApiLLMClient -from dify_agent.runtime.native_continuation import NativePolicyStopped +from dify_agent.runtime.native_continuation import NativeContinuation, NativePolicyStopped +from dify_agent.runtime.native_effects import NativeDispatchEnvelope +from tests.local.dify_agent.runtime.test_native_effects import INTENT, PERMIT, REF + from ._test_support import build_stream_response, single_text_chunk from .test_api_provider import _execution_context class ReceiptBoundary: def __init__(self, recorded=None): - self.continuation = SimpleNamespace(operation_cursor=0) + self.continuation = NativeContinuation( + run_id="run", ref=REF, generation=0, config_ref="config", config_digest="b" * 64 + ) self.recorded = recorded self.saved = None @@ -32,7 +36,7 @@ class ReceiptBoundary: def run_provider(chunks, boundary, monkeypatch): # Only the policy boundary is controlled; SDK HTTP/SSE parsing stays real. monkeypatch.setattr( - "dify_agent.runtime.native_effects.current_native_dispatch", lambda: SimpleNamespace(wire=lambda: {}) + "dify_agent.runtime.native_effects.current_native_dispatch", lambda: NativeDispatchEnvelope(REF, INTENT, PERMIT) ) requests = [] diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_runtime_admission.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_runtime_admission.py index 6d09decea85..c9a16e5f897 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_runtime_admission.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_runtime_admission.py @@ -55,7 +55,7 @@ def admission(reader): def test_issuance_requires_actual_run_and_keeps_legacy_unselected(): c = codec() - assert getattr(c.decode_token(c.encode_connection_token(context())), "runtime", None) is None + assert c.decode_token(c.encode_connection_token(context())).runtime is None with pytest.raises(AgentRuntimeTransportError): c.encode_runtime_token(context(), run_id="run", session_id=None) with bind_runtime_identity(identity()): @@ -234,9 +234,9 @@ def test_six_config_routes_only_accept_their_method(method, operation): def test_connect_and_delegated_download_require_same_admitted_owner(): - from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneService, AgentStubControlPlaneError - from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileDownloadRequest + from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler + from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService async def exercise(): c = codec() @@ -358,9 +358,10 @@ def test_expiry_during_final_live_read_refuses(monkeypatch): def test_standalone_strict_refuses_valid_runtime_token_without_live_store(): + from fastapi.testclient import TestClient + from dify_agent.agent_stub.server.app import create_agent_stub_app from dify_agent.server.settings import ServerSettings - from fastapi.testclient import TestClient settings = ServerSettings( server_secret_key=base64.urlsafe_b64encode(b"x" * 32).rstrip(b"=").decode(), _env_file=None @@ -378,7 +379,7 @@ def test_standalone_strict_refuses_valid_runtime_token_without_live_store(): def test_runtime_token_cannot_enter_explicit_legacy_service(): - from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneService, AgentStubControlPlaneError + from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService async def exercise(): c = codec() @@ -392,15 +393,15 @@ def test_runtime_token_cannot_enter_explicit_legacy_service(): @pytest.mark.parametrize("file_operation", [False, True]) def test_strict_timeout_preserves_public_error(file_operation): + from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileUploadRequest from dify_agent.agent_stub.server.agent_stub_config import ( - DifyApiAgentStubConfigRequestHandler, AgentStubConfigRequestError, + DifyApiAgentStubConfigRequestHandler, ) from dify_agent.agent_stub.server.agent_stub_files import ( - DifyApiAgentStubFileRequestHandler, AgentStubFileRequestError, + DifyApiAgentStubFileRequestHandler, ) - from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileUploadRequest async def exercise(): async def reader(_): @@ -437,8 +438,8 @@ def test_strict_timeout_preserves_public_error(file_operation): def test_strict_file_upload_uses_signed_context_and_final_header(): - from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileUploadRequest + from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler async def exercise(): seen = [] @@ -502,14 +503,14 @@ def test_native_claims_refuse_before_reader_or_issuance(): def test_confirmed_stub_token_preserves_claimed_envelope_and_config_get_forwards_it(): - from dify_agent.runtime.native_effects import NativeDispatchEnvelope, _dispatch - from dify_agent.protocol.native_testing import ( - AgentNativeTestRef, - AgentEffectIntent, - AgentOperationKey, - AgentDispatchPermit, - ) from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler + from dify_agent.protocol.native_testing import ( + AgentDispatchPermit, + AgentEffectIntent, + AgentNativeTestRef, + AgentOperationKey, + ) + from dify_agent.runtime.native_effects import NativeDispatchEnvelope, _dispatch ref = AgentNativeTestRef( policy_version="builder-native-test-v1", @@ -593,10 +594,8 @@ def test_confirmed_stub_token_preserves_claimed_envelope_and_config_get_forwards def test_confirmed_stub_durable_reader_requires_exact_active_identity(): - from types import SimpleNamespace - from unittest.mock import AsyncMock - from dify_agent.storage.redis_run_store import RedisRunStore from dify_agent.runtime.native_continuation import NativeContinuationConflict + from dify_agent.storage.redis_run_store import RedisRunStore selected = AgentRuntimeIdentity( run_id="run", @@ -618,11 +617,22 @@ def test_confirmed_stub_durable_reader_requires_exact_active_identity(): ) async def exercise(): - redis = SimpleNamespace(eval=AsyncMock(return_value=selected.model_dump_json())) - store = RedisRunStore(redis) - assert await store.read_active_stub_identity(selected) == selected - redis.eval.return_value = selected.model_copy(update={"agent_generation": 1}).model_dump_json() - with pytest.raises(NativeContinuationConflict): - await store.read_active_stub_identity(selected) + from unittest.mock import patch + + from redis.asyncio import Redis + + redis = Redis() + response = [selected.model_dump_json()] + + async def read(*_args, **_kwargs): + return response[0] + + with patch.object(redis, "eval", side_effect=read): + store = RedisRunStore(redis) + assert await store.read_active_stub_identity(selected) == selected + response[0] = selected.model_copy(update={"agent_generation": 1}).model_dump_json() + with pytest.raises(NativeContinuationConflict): + await store.read_active_stub_identity(selected) + await redis.aclose() asyncio.run(exercise()) diff --git a/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_layer.py b/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_layer.py index 2857b503cad..40411b1f4ad 100644 --- a/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/dify_core_tools/test_layer.py @@ -210,6 +210,7 @@ async def test_non_plugin_core_arguments_remain_unprepared(provider_type, provid @pytest.mark.parametrize("transfer", ["local_file", "tool_file", "datasource_file"]) async def test_ordinary_plugin_canonical_file_uses_current_run_download_owner(transfer): import base64 + from dify_agent.adapters.native_testing.transport import AgentRuntimeHTTPClient, bind_runtime_identity from dify_agent.protocol.native_testing import AgentRuntimeIdentity @@ -275,23 +276,25 @@ async def test_ordinary_plugin_canonical_file_uses_current_run_download_owner(tr @pytest.mark.anyio -async def test_ordinary_plugin_sandbox_path_reuses_shell_stub_upload_then_download(): - from types import SimpleNamespace +async def test_ordinary_plugin_sandbox_path_reuses_shell_stub_upload_then_download(monkeypatch): from dify_agent.layers.dify_plugin.tools_layer import _FILE_UPLOAD_BEGIN, _FILE_UPLOAD_END calls = [] - class ShellBoundary: - async def run_remote_script_complete(self, script, *, timeout, inject_agent_stub_env): - calls.append("upload") - assert "['dify-agent', 'file', 'upload', \"/sandbox/sample.txt\"]" in script - assert timeout == 60.0 and inject_agent_stub_env is True - return SimpleNamespace( - exit_code=0, - output=_FILE_UPLOAD_BEGIN - + json.dumps({"transfer_method": "tool_file", "reference": "uploaded-ref"}) - + _FILE_UPLOAD_END, - ) + from dify_agent.adapters.shell.protocols import CompleteShellCommandResult + from dify_agent.layers.shell.layer import DifyShellLayerConfig, ShellSession + from dify_agent.layers.shell.layer import State as ShellState + + async def upload(self, script, *, timeout, inject_agent_stub_env): + calls.append("upload") + assert "['dify-agent', 'file', 'upload', \"/sandbox/sample.txt\"]" in script + assert timeout == 60.0 and inject_agent_stub_env is True + output = ( + _FILE_UPLOAD_BEGIN + + json.dumps({"transfer_method": "tool_file", "reference": "uploaded-ref"}) + + _FILE_UPLOAD_END + ) + return CompleteShellCommandResult("upload", "exited", True, 0, output, True, None, len(output)) def handler(request): calls.append(request.url.path) @@ -307,7 +310,13 @@ async def test_ordinary_plugin_sandbox_path_reuses_shell_stub_upload_then_downlo async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: deps = _ordinary_deps(_ordinary_config(shell="shell"), client) - deps.resources.shells["shell"] = ShellBoundary() + deps.layers["shell"] = { + "config": DifyShellLayerConfig().model_dump(mode="json"), + "state": ShellState().model_dump(mode="json"), + } + shell = ShellSession("shell", deps) + monkeypatch.setattr(ShellSession, "run_remote_script_complete", upload) + deps.resources.shells["shell"] = shell await invoke_native_tool( deps, Toolset("core_tools"), "search", {"query": "live", "input": "/sandbox/sample.txt"} ) @@ -391,7 +400,7 @@ async def test_shared_direct_select_preparation_currently_loses_multiple_metadat async def test_core_native_early_refusal_precedes_file_preparation_and_io(): from dify_agent.protocol.native_testing import AgentNativeTestRef from dify_agent.runtime.native_continuation import NativeContinuation, NativePolicyStopped - from dify_agent.runtime.native_effects import NativeEffectSession, NativeEffectDispatcher + from dify_agent.runtime.native_effects import NativeEffectDispatcher, NativeEffectSession class UnusedOwner: async def before_call(self, **kwargs): @@ -464,11 +473,11 @@ async def test_plugin_file_current_run_missing_refuses_without_legacy_fallback() ], ) async def test_api_payload_validation_error_is_not_relabelled_as_preparation_error(provider_type, provider_id): - from types import SimpleNamespace from pydantic import ValidationError from dify_agent.layers.dify_core_tools.client import DifyCoreToolsClient + from tests.local.dify_agent.runtime.native_test_entities import run_context config = _ordinary_config(provider_type, provider_id) async with httpx.AsyncClient( @@ -485,4 +494,4 @@ async def test_api_payload_validation_error_is_not_relabelled_as_preparation_err # Extra keys are intentionally retained by preparation. The real API # request model, rather than preparation, owns JSON payload validation. with pytest.raises(ValidationError): - await tool.function(SimpleNamespace(deps=deps), query="live", extra=object()) + await tool.function(run_context(deps), query="live", extra=object()) diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py index b4fa4459dcf..48abc9d5a6b 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -10,16 +10,6 @@ import pytest import dify_agent.layers.shell.layer as shell_layer_module import dify_agent.runtime.command_runner as command_runner_module -from dify_agent.layers.shell import ( - DifyShellEnvVarConfig, - DifyShellLayerConfig, -) -from dify_agent.layers.shell.layer import ( - CompleteRemoteCommandResult, - DEFAULT_TERMINATE_GRACE_SECONDS, - ShellSession, - State, -) from dify_agent.adapters.shell.protocols import ( ShellCommandResult, ShellCommandStatus, @@ -27,6 +17,16 @@ from dify_agent.adapters.shell.protocols import ( ShellProviderError, ) from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.shell import ( + DifyShellEnvVarConfig, + DifyShellLayerConfig, +) +from dify_agent.layers.shell.layer import ( + DEFAULT_TERMINATE_GRACE_SECONDS, + CompleteRemoteCommandResult, + ShellSession, + State, +) from dify_agent.runtime_backend import ( RuntimeLayout, RuntimeLease, @@ -1256,8 +1256,8 @@ def test_redact_output_skips_short_jwe_values() -> None: @pytest.mark.parametrize("remote", [False, True]) def test_native_stub_refuses_before_job_creation(remote: bool) -> None: - from dify_agent.runtime_backend.native_test_scope import NativeScopeLease from dify_agent.runtime.native_effects import NativePolicyStopped + from dify_agent.runtime_backend.native_test_scope import NativeScopeLease commands = FakeCommands() layer, _ = _layer(commands=commands) @@ -1281,8 +1281,8 @@ def test_native_stub_refuses_before_job_creation(remote: bool) -> None: def test_native_required_stub_env_refuses_even_when_unconfigured() -> None: - from dify_agent.runtime_backend.native_test_scope import NativeScopeLease from dify_agent.runtime.native_effects import NativePolicyStopped + from dify_agent.runtime_backend.native_test_scope import NativeScopeLease commands = FakeCommands() layer, _ = _layer(commands=commands) @@ -1299,6 +1299,7 @@ def test_native_required_stub_env_refuses_even_when_unconfigured() -> None: def test_strict_shell_checks_actual_deps_run_before_token_issuance() -> None: import httpx + from dify_agent.adapters.native_testing.transport import ( AgentRuntimeHTTPClient, AgentRuntimeTransportError, @@ -1331,17 +1332,19 @@ def test_strict_shell_checks_actual_deps_run_before_token_issuance() -> None: def test_confirmed_shell_exit_keeps_jobs_without_unapproved_cleanup(): - from types import SimpleNamespace + from pydantic_ai.run import AgentRunResult + from dify_agent.layers.shell.layer import Capability + from tests.local.dify_agent.runtime.native_test_entities import native_effects, run_context commands = FakeCommands() session, _ = _layer(commands=commands) session.deps.layers["shell"]["state"] = State(initialized=True, job_ids=["existing-job"]).model_dump(mode="json") - session.deps.native_effects = SimpleNamespace(continuation=SimpleNamespace(native_test_profile="confirmed")) + session.deps.native_effects = native_effects(session.deps, profile="confirmed") async def handler(): - return SimpleNamespace(output="done") + return AgentRunResult(output="done") - asyncio.run(Capability("shell").wrap_run(SimpleNamespace(deps=session.deps), handler=handler)) + asyncio.run(Capability("shell").wrap_run(run_context(session.deps), handler=handler)) assert commands.delete_calls == [] assert session.runtime_state.job_ids == ["existing-job"] diff --git a/dify-agent/tests/local/dify_agent/runtime/native_test_entities.py b/dify-agent/tests/local/dify_agent/runtime/native_test_entities.py new file mode 100644 index 00000000000..a4ea486d020 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/runtime/native_test_entities.py @@ -0,0 +1,55 @@ +"""Concrete SDK contexts and native runtime entities for focused boundary tests.""" + +from typing import Literal, Never + +from pydantic_ai import RunContext +from pydantic_ai.models.test import TestModel +from pydantic_ai.usage import RunUsage + +from dify_agent.adapters.native_testing.client import HttpAgentPolicyClient +from dify_agent.adapters.shell.protocols import ShellCommandProtocol +from dify_agent.adapters.shell.shellctl import ShellctlCommands, create_default_shellctl_client_factory +from dify_agent.protocol.native_testing import AgentNativeTestRef +from dify_agent.runtime.confirmed_continuation import _APIResultStore +from dify_agent.runtime.context import Deps +from dify_agent.runtime.native_continuation import NativeContinuation +from dify_agent.runtime.native_effects import NativeEffectDispatcher, NativeEffectSession +from dify_agent.runtime_backend.native_test_scope import NativeScopeLease +from dify_agent.runtime_backend.protocols import RuntimeLayout + + +def run_context(deps: Deps) -> RunContext[Deps]: + return RunContext(deps=deps, model=TestModel(), usage=RunUsage()) + + +def native_effects(deps: Deps, *, profile: Literal["isolated", "confirmed"] = "isolated") -> NativeEffectSession: + ref = AgentNativeTestRef( + policy_version="builder-native-test-v1", request_id="request", invocation_id="inv", context_digest="a" * 64 + ) + continuation = NativeContinuation( + run_id="run", ref=ref, generation=0, config_ref="config", config_digest="b" * 64, native_test_profile=profile + ) + client = HttpAgentPolicyClient( + base_url="http://api.invalid", api_key="test", http_client=deps.services.dify_api_http_client, ref=ref + ) + + async def forbidden_intent(**_kwargs: object) -> Never: + raise AssertionError("Unexpected native intent preparation") + + return NativeEffectSession( + continuation=continuation, + dispatcher=NativeEffectDispatcher(policy=client), + result_store=_APIResultStore(client=client, ref=ref), + prepare_intent=forbidden_intent, + ) + + +def native_lease(commands: ShellCommandProtocol | None = None, *, generation: int = 1) -> NativeScopeLease: + client = create_default_shellctl_client_factory(entrypoint="http://runtime.invalid", token="test")() + return NativeScopeLease( + handle="native:binding-1:1", + layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace"), + client=client, + commands=commands if commands is not None else ShellctlCommands(client), + workspace_generation=generation, + ) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_confirmed_continuation_owner.py b/dify-agent/tests/local/dify_agent/runtime/test_confirmed_continuation_owner.py index 3996f365d63..f33896f4d9c 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_confirmed_continuation_owner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_confirmed_continuation_owner.py @@ -111,6 +111,7 @@ async def checkpoint_storage(durable): yield None, "run" return from redis.asyncio import Redis + from dify_agent.storage.redis_run_store import RedisRunStore redis = Redis.from_url(os.environ["DIFY_AGENT_NATIVE_TEST_REDIS_URL"]) @@ -331,7 +332,7 @@ def test_real_sdk_confirmed_owner_wait_restore_and_exact_single_dispatch(decisio def test_confirmed_scheduler_keeps_ordinary_legacy_services_and_uses_strict_run_client(): from dify_agent.runtime.run_scheduler import RunScheduler - from types import SimpleNamespace + from dify_agent.server.schemas import RunRecord async def exercise(): owner_class = owner_type() @@ -352,13 +353,13 @@ def test_confirmed_scheduler_keeps_ordinary_legacy_services_and_uses_strict_run_ store=None, services=services, native_owner=owner_class(http_client=strict, base_url="http://api") ) marked_runner = scheduler._default_runner_factory( - SimpleNamespace(run_id="run"), request(), is_cancelled=lambda: False + RunRecord(run_id="run", status="running"), request(), is_cancelled=lambda: False ) assert marked_runner.services.dify_api_http_client is strict assert marked_runner.services.inner_api_key == "" assert marked_runner.services.plugin_daemon_api_key == "" ordinary_runner = scheduler._default_runner_factory( - SimpleNamespace(run_id="ordinary"), + RunRecord(run_id="ordinary", status="running"), CreateRunRequest(composition={"layers": []}), is_cancelled=lambda: False, ) @@ -429,8 +430,8 @@ def test_confirmed_profile_requires_bounded_create_identity(): def test_ordinary_legacy_run_keeps_its_existing_unbound_transport_scope(): - from dify_agent.runtime.run_scheduler import RunScheduler from dify_agent.adapters.native_testing.transport import AgentRuntimeTransportError, current_runtime_identity + from dify_agent.runtime.run_scheduler import RunScheduler async def exercise(): async with ( @@ -894,10 +895,12 @@ def test_confirmed_scheduler_advances_restored_shell_owner_generation_and_reject @pytest.mark.parametrize("boundary", ["bootstrap", "config_pull"]) @pytest.mark.parametrize("decision", ["dispatch", "recorded", "mismatch", "unknown", "external"]) def test_confirmed_wrap_run_stage_consumes_restored_decision_once(monkeypatch, boundary, decision): - from types import SimpleNamespace from copy import deepcopy - from dify_agent.layers.shell.layer import Capability as ShellCapability, ShellSession - from dify_agent.layers.config.layer import Capability as ConfigCapability, _ConfigContext + + from dify_agent.layers.config.layer import Capability as ConfigCapability + from dify_agent.layers.config.layer import _ConfigContext + from dify_agent.layers.shell.layer import Capability as ShellCapability + from dify_agent.layers.shell.layer import ShellSession from dify_agent.runtime.native_continuation import NativeApprovalSuspended async def exercise(): @@ -1007,7 +1010,18 @@ def test_confirmed_wrap_run_stage_consumes_restored_decision_once(monkeypatch, b async def bootstrap(self, script, *, cwd): calls.append("physical-io") - return SimpleNamespace(exit_code=0, output_complete=True) + from dify_agent.adapters.shell.protocols import CompleteShellCommandResult + + return CompleteShellCommandResult( + job_id="job", + status="exited", + done=True, + exit_code=0, + output="", + output_complete=True, + incomplete_reason=None, + offset=0, + ) async def pull(self, *, script, target_kind): calls.append("physical-io") @@ -1034,15 +1048,19 @@ def test_confirmed_wrap_run_stage_consumes_restored_decision_once(monkeypatch, b run_id="run", native_effects=continuation.capabilities[0].session, ) - deps.resources.leases["runtime"] = SimpleNamespace(layout=SimpleNamespace(workspace_dir="/workspace")) + from pydantic_ai.run import AgentRunResult + + from tests.local.dify_agent.runtime.native_test_entities import native_lease, run_context + + deps.resources.leases["runtime"] = native_lease() async def terminal(): - return SimpleNamespace(output="done") + return AgentRunResult(output="done") async def config(): - return await ConfigCapability("config").wrap_run(SimpleNamespace(deps=deps), handler=terminal) + return await ConfigCapability("config").wrap_run(run_context(deps), handler=terminal) - result = await ShellCapability("shell").wrap_run(SimpleNamespace(deps=deps), handler=config) + result = await ShellCapability("shell").wrap_run(run_context(deps), handler=config) assert result.output == "done" if boundary == "config_pull": assert deps.layers["config"]["state"]["pulled_file_outputs"] == {"proof": "/workspace/proof"} diff --git a/dify-agent/tests/local/dify_agent/runtime/test_native_effects.py b/dify-agent/tests/local/dify_agent/runtime/test_native_effects.py index f9d72e4b8f6..ff737385eed 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_native_effects.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_native_effects.py @@ -5,6 +5,7 @@ import importlib import importlib.util import pytest + from dify_agent.protocol.native_testing import ( AgentApprovalWait, AgentDispatchPermit, @@ -15,6 +16,7 @@ from dify_agent.protocol.native_testing import ( AgentRecordedResult, ) from dify_agent.runtime.native_continuation import NativeApprovalSuspended, NativePolicyStopped +from tests.local.dify_agent.runtime.native_test_entities import run_context REF = AgentNativeTestRef( policy_version="builder-native-test-v1", request_id="request", invocation_id="invocation", context_digest="a" * 64 @@ -197,12 +199,13 @@ def test_opaque_call_discloses_uncertainty(): def test_real_sdk_pre_tool_boundary_checkpoints_wait_before_handler(reason): async def exercise(): import httpx - from dify_agent.runtime.context import Deps, Services - from dify_agent.runtime.native_continuation import NativeContinuation from pydantic_ai import Agent, DeferredToolRequests from pydantic_ai.messages import ModelResponse, ToolCallPart from pydantic_ai.models.function import FunctionModel + from dify_agent.runtime.context import Deps, Services + from dify_agent.runtime.native_continuation import NativeContinuation + module = implementation() assert hasattr(module, "NativeEffectsCapability"), "Real SDK effect capability is missing" policy = Policy(AgentApprovalWait(pending_operation_id="pending", wait_reason=reason)) @@ -265,13 +268,14 @@ def test_real_sdk_pre_tool_boundary_checkpoints_wait_before_handler(reason): def test_actual_tool_layer_defers_with_final_arguments_before_transport(family, file_kind, permit_available): async def exercise(): import httpx + from pydantic_ai import Agent, DeferredToolRequests + from pydantic_ai.messages import ModelResponse, ToolCallPart + from pydantic_ai.models.function import FunctionModel + from dify_agent.layers.dify_core_tools.layer import Toolset as CoreTools from dify_agent.layers.dify_plugin.tools_layer import Toolset as PluginTools from dify_agent.runtime.context import Deps, Services from dify_agent.runtime.native_continuation import NativeContinuation - from pydantic_ai import Agent, DeferredToolRequests - from pydantic_ai.messages import ModelResponse, ToolCallPart - from pydantic_ai.models.function import FunctionModel module = implementation() transport_calls = [] @@ -364,12 +368,13 @@ def test_actual_tool_layer_defers_with_final_arguments_before_transport(family, def test_sdk_permit_is_spent_once_and_result_can_replay_without_handler(): async def exercise(): import httpx - from dify_agent.runtime.context import Deps, Services - from dify_agent.runtime.native_continuation import NativeContinuation from pydantic_ai import Agent, DeferredToolRequests from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart, ToolReturnPart from pydantic_ai.models.function import FunctionModel + from dify_agent.runtime.context import Deps, Services + from dify_agent.runtime.native_continuation import NativeContinuation + module = implementation() calls = [] policy, results = Policy(PERMIT), Results() @@ -452,6 +457,7 @@ def waiting_session(): def test_eager_knowledge_waits_before_http_and_cache_write(): async def exercise(): import httpx + from dify_agent.layers.knowledge.layer import _KnowledgeOperations from tests.local.dify_agent.layers.knowledge.test_layer import config, deps_for @@ -472,22 +478,25 @@ def test_eager_knowledge_waits_before_http_and_cache_write(): asyncio.run(exercise()) -def test_config_pull_waits_before_shell_io(): +def test_config_pull_waits_before_shell_io(monkeypatch): async def exercise(): - from types import SimpleNamespace - from dify_agent.adapters.shell.protocols import CompleteShellCommandResult from dify_agent.layers.config.layer import Config, State, _ConfigContext calls = [] - async def run(script, **kwargs): + async def run(self, script, **kwargs): calls.append(script) return CompleteShellCommandResult("pull", "exited", True, 0, "{}", True, None, 2) - shell = SimpleNamespace( - deps=SimpleNamespace(native_effects=waiting_session()), run_remote_script=run, name="shell" - ) + from dify_agent.layers.shell.layer import DifyShellLayerConfig, ShellSession + from dify_agent.layers.shell.layer import State as ShellState + from tests.local.dify_agent.module_support import deps_for + + deps = deps_for("shell", DifyShellLayerConfig(), ShellState().model_dump(mode="json")) + deps.native_effects = waiting_session() + shell = ShellSession("shell", deps) + monkeypatch.setattr(ShellSession, "run_remote_script", run) context = _ConfigContext(Config(), State(), shell) with pytest.raises(NativeApprovalSuspended): await context._run_mentioned_pull(script="dify-agent config files pull --json file", target_kind="file") @@ -508,12 +517,13 @@ def test_config_pull_waits_before_shell_io(): def test_shell_native_tool_waits_before_session_access(name, arguments): async def exercise(): import httpx - from dify_agent.layers.shell.layer import Capability - from dify_agent.runtime.context import Deps, Services from pydantic_ai import Agent, DeferredToolRequests from pydantic_ai.messages import ModelResponse, ToolCallPart from pydantic_ai.models.function import FunctionModel + from dify_agent.layers.shell.layer import Capability + from dify_agent.runtime.context import Deps, Services + session = waiting_session() async with httpx.AsyncClient() as client: deps = Deps(layers={}, services=Services(client, client), run_id="run") @@ -535,9 +545,8 @@ def test_shell_native_tool_waits_before_session_access(name, arguments): def test_bootstrap_waits_before_runtime_resource_access(): async def exercise(): - from types import SimpleNamespace - import httpx + from dify_agent.layers.shell.layer import Capability, State from dify_agent.runtime.context import Deps, Services @@ -558,7 +567,7 @@ def test_bootstrap_waits_before_runtime_resource_access(): pytest.fail("run entered before bootstrap approval") with pytest.raises(NativeApprovalSuspended): - await Capability("shell").wrap_run(SimpleNamespace(deps=deps), handler=handler) + await Capability("shell").wrap_run(run_context(deps), handler=handler) assert deps.layers["shell"]["state"]["initialized"] is False asyncio.run(exercise()) @@ -567,6 +576,7 @@ def test_bootstrap_waits_before_runtime_resource_access(): def test_actual_model_client_waits_before_http(): async def exercise(): import httpx + from dify_agent.adapters.llm.provider import DifyApiLLMClient from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig @@ -614,8 +624,9 @@ def test_actual_model_client_waits_before_http(): def test_marked_runner_requires_real_effect_owner_before_loading(monkeypatch): async def exercise(): - import dify_agent.runtime.runner as runner_module import httpx + + import dify_agent.runtime.runner as runner_module from dify_agent.protocol.native_testing import NativeTestCapabilities, NativeTestProtocolUnsupported from dify_agent.protocol.schemas import CreateRunRequest from dify_agent.runtime.context import Services @@ -667,12 +678,13 @@ def test_actual_direct_plugin_claim_precedes_one_daemon_request(claim_allowed): import json import httpx - from dify_agent.adapters.native_testing.client import AgentPolicyTransportUnavailable, HttpAgentPolicyClient - from dify_agent.layers.dify_plugin.tools_layer import Toolset - from dify_agent.runtime.context import Deps, Services from pydantic_ai import Agent from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart, ToolReturnPart from pydantic_ai.models.function import FunctionModel + + from dify_agent.adapters.native_testing.client import AgentPolicyTransportUnavailable, HttpAgentPolicyClient + from dify_agent.layers.dify_plugin.tools_layer import Toolset + from dify_agent.runtime.context import Deps, Services from tests.local.dify_agent.layers.dify_plugin.test_layers import identity events = [] @@ -864,8 +876,6 @@ def test_direct_plugin_target_guard_precedes_materialize_claim_and_dispatch(): async def _exercise_direct_plugin_json_guard(approved, actual, matches, *, changed_target=False): - from types import SimpleNamespace - import httpx from dify_agent.adapters.native_testing.client import HttpAgentPolicyClient @@ -913,7 +923,9 @@ async def _exercise_direct_plugin_json_guard(approved, actual, matches, *, chang effect = INTENT.model_copy(update={"target": target, "parameters": approved}) token = module._dispatch.set(module.NativeDispatchEnvelope(REF, effect, PERMIT)) try: - ctx = SimpleNamespace(deps=SimpleNamespace(native_effects=session)) + from dify_agent.runtime.context import Deps, Services + + ctx = run_context(Deps(layers={}, services=Services(client, client), run_id="run", native_effects=session)) if matches: assert await tool.function(ctx, **actual) == "done" assert events == ["materialize", "claim-dispatch", "invoke"] diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_native_test_scope.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_native_test_scope.py index 8487f5de946..4489cee6212 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_native_test_scope.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_native_test_scope.py @@ -1,13 +1,16 @@ """Native local composition tests; the supervisor kernel proof is a separate CI gate.""" from datetime import UTC, datetime, timedelta -from types import SimpleNamespace import pytest from pydantic import SecretStr +from pydantic_ai.run import AgentRunResult -from dify_agent.runtime_backend.local import LocalExecutionBindingBackend +from dify_agent.runtime_backend.local import LocalExecutionBindingBackend, LocalHomeSnapshotBackend +from dify_agent.runtime_backend.protocols import ExecutionBindingCreateSpec, RuntimeBackendProfile, RuntimeLayout +from shellctl.client.sdk import ShellctlClient from shellctl.shared.schemas import NativeScopeHandle, NativeScopeLimits, NativeScopeSpec +from tests.local.dify_agent.runtime.native_test_entities import native_effects, native_lease, run_context def scope(): @@ -49,7 +52,7 @@ class ScopeClient: def spec(native): - return SimpleNamespace( + return ExecutionBindingCreateSpec( tenant_id="tenant", agent_id="agent", binding_id="binding-1", @@ -119,8 +122,8 @@ async def test_shell_saves_job_identity_and_reconnects_without_replay(): def lease(generation=1): return NativeScopeLease( handle="native:binding-1:1", - layout=SimpleNamespace(home_dir="/home/dify", workspace_dir="/workspace"), - client=SimpleNamespace(), + layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace"), + client=ShellctlClient(base_url="http://runtime.invalid"), commands=commands, workspace_generation=generation, ) @@ -142,7 +145,7 @@ async def test_shell_saves_job_identity_and_reconnects_without_replay(): @pytest.mark.anyio -async def test_marked_runtime_requires_stored_lineage_before_scope_acquire(): +async def test_marked_runtime_requires_stored_lineage_before_scope_acquire(monkeypatch): from unittest.mock import AsyncMock from dify_agent.layers.runtime.configs import DifyRuntimeLayerConfig @@ -151,12 +154,18 @@ async def test_marked_runtime_requires_stored_lineage_before_scope_acquire(): from tests.local.dify_agent.module_support import deps_for deps = deps_for("runtime", DifyRuntimeLayerConfig(backend_binding_ref="native:binding-1:1"), {}) - backend = SimpleNamespace(acquire=AsyncMock(), release=AsyncMock()) - deps.services.runtime_backend_profile = SimpleNamespace(execution_bindings=backend) - deps.native_effects = SimpleNamespace(validate_shell_lineage=None) + backend = LocalExecutionBindingBackend(endpoint="http://runtime.invalid", auth_token="test") + acquire = AsyncMock() + monkeypatch.setattr(LocalExecutionBindingBackend, "acquire", acquire) + monkeypatch.setattr(LocalExecutionBindingBackend, "release", AsyncMock()) + deps.services.runtime_backend_profile = RuntimeBackendProfile( + home_snapshots=LocalHomeSnapshotBackend(endpoint="http://runtime.invalid", auth_token="test"), + execution_bindings=backend, + ) + deps.native_effects = native_effects(deps) with pytest.raises(NativePolicyStopped, match="native_shell_lineage_unavailable"): - await Capability("runtime").wrap_run(SimpleNamespace(deps=deps), handler=AsyncMock()) - backend.acquire.assert_not_called() + await Capability("runtime").wrap_run(run_context(deps), handler=AsyncMock()) + acquire.assert_not_called() @pytest.mark.anyio @@ -173,8 +182,8 @@ async def test_native_missing_job_is_terminal_control_not_retryable_model_text() session, _ = _layer(commands=commands) session.deps.resources.leases["runtime"] = NativeScopeLease( handle="native:binding-1:1", - layout=SimpleNamespace(home_dir="/home/dify", workspace_dir="/workspace"), - client=SimpleNamespace(), + layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace"), + client=ShellctlClient(base_url="http://runtime.invalid"), commands=commands, ) await session._tool_run("read line", timeout=1) @@ -184,21 +193,23 @@ async def test_native_missing_job_is_terminal_control_not_retryable_model_text() @pytest.mark.anyio -@pytest.mark.parametrize( - "module,name", - [ - ("enterprise", "EnterpriseExecutionBindingBackend"), - ("e2b", "E2BExecutionBindingBackend"), - ("openshell", "OpenShellExecutionBindingBackend"), - ], -) -async def test_unqualified_backend_stops_before_provider_access(module, name): - import importlib - +@pytest.mark.parametrize("backend_name", ["enterprise", "e2b", "openshell"]) +async def test_unqualified_backend_stops_before_provider_access(backend_name): + from dify_agent.runtime_backend.e2b import E2BExecutionBindingBackend, E2BSDKControlPlane + from dify_agent.runtime_backend.enterprise import EnterpriseExecutionBindingBackend from dify_agent.runtime_backend.errors import BindingAcquireError, BindingCreateError + from dify_agent.runtime_backend.openshell import OpenShellExecutionBindingBackend, OpenShellSDKControlPlane - backend_type = getattr(importlib.import_module(f"dify_agent.runtime_backend.{module}"), name) - backend = object.__new__(backend_type) + backends = { + "enterprise": EnterpriseExecutionBindingBackend(gateway_endpoint="http://provider.invalid", auth_token="test"), + "e2b": E2BExecutionBindingBackend( + control_plane=E2BSDKControlPlane(api_key="test"), template="test", active_timeout_seconds=60 + ), + "openshell": OpenShellExecutionBindingBackend( + control_plane=OpenShellSDKControlPlane(endpoint="http://provider.invalid") + ), + } + backend = backends[backend_name] with pytest.raises(BindingCreateError, match="native_scope_unavailable"): await backend.create_binding(spec(scope())) with pytest.raises(BindingAcquireError, match="native_scope_unavailable"): @@ -212,7 +223,9 @@ async def test_reconcile_only_inspects_reserved_scope_even_when_missing(): client = ScopeClient() backend = LocalExecutionBindingBackend(endpoint="http://runtime", auth_token="private", native_scope_client=client) request = spec(scope()) - request.native_scope_reconcile = True + from dataclasses import replace + + request = replace(request, native_scope_reconcile=True) result = await backend.create_binding(request) assert result.binding_ref == "native:binding-1:1" assert client.inspected == [("binding-1", 1)] @@ -239,29 +252,27 @@ async def test_wait_keeps_jobs_and_offsets_until_checkpoint_capture(): session, _ = _layer(commands=commands) session.deps.resources.leases["runtime"] = NativeScopeLease( handle="native:binding-1:1", - layout=SimpleNamespace(home_dir="/home/dify", workspace_dir="/workspace"), - client=SimpleNamespace(), + layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace"), + client=ShellctlClient(base_url="http://runtime.invalid"), commands=commands, ) await session._tool_run("read line", timeout=1) state = session.runtime_state state.initialized = True session.save_state(state) - session.deps.native_effects = SimpleNamespace( - continuation=SimpleNamespace(job_refs=(), native_test_profile="isolated") - ) + session.deps.native_effects = native_effects(session.deps) async def wait(): - return SimpleNamespace(output=DeferredToolRequests()) + return AgentRunResult(output=DeferredToolRequests()) - await Capability("shell").wrap_run(SimpleNamespace(deps=session.deps), handler=wait) + await Capability("shell").wrap_run(run_context(session.deps), handler=wait) assert commands.delete_calls == [] assert State.model_validate(session.deps.layers["shell"]["state"]).job_refs["job-8"].output_offset == 12 async def complete(): - return SimpleNamespace(output="done") + return AgentRunResult(output="done") - await Capability("shell").wrap_run(SimpleNamespace(deps=session.deps), handler=complete) + await Capability("shell").wrap_run(run_context(session.deps), handler=complete) assert len(commands.delete_calls) == 1 assert State.model_validate(session.deps.layers["shell"]["state"]).job_refs == {} @@ -279,8 +290,8 @@ async def test_reconnected_input_cannot_substitute_response_job_identity(): session, _ = _layer(commands=commands) session.deps.resources.leases["runtime"] = NativeScopeLease( handle="native:binding-1:1", - layout=SimpleNamespace(home_dir="/home/dify", workspace_dir="/workspace"), - client=SimpleNamespace(), + layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace"), + client=ShellctlClient(base_url="http://runtime.invalid"), commands=commands, ) await session._tool_run("read line", timeout=1) @@ -307,8 +318,10 @@ def app_client(monkeypatch, token, *, configured_stub=False, legacy_stub=False): backend = LocalExecutionBindingBackend( endpoint="http://runtime", auth_token="private", native_scope_client=scope_client, client_factory=factory ) + monkeypatch.setattr(LocalHomeSnapshotBackend, "create_from_runtime", AsyncMock()) profile = RuntimeBackendProfile( - home_snapshots=SimpleNamespace(create_from_runtime=AsyncMock()), execution_bindings=backend + home_snapshots=LocalHomeSnapshotBackend(endpoint="http://runtime.invalid", auth_token="test"), + execution_bindings=backend, ) monkeypatch.setattr(ServerSettings, "build_runtime_backend_profile", lambda self: profile) settings = ServerSettings(api_token=token) @@ -494,7 +507,9 @@ def test_ordinary_download_with_configured_stub_still_uploads_once(monkeypatch, monkeypatch.setattr(AgentStubTokenCodec, "encode_connection_token", issue_token) client, scope_client, factory, _ = app_client(monkeypatch, token, configured_stub=True, legacy_stub=True) commands = _Commands(outputs=[json.dumps({"reference": _REFERENCE, "transfer_method": "tool_file"})]) - runtime_client = SimpleNamespace(close=AsyncMock()) + runtime_client = ShellctlClient(base_url="http://runtime.invalid") + close = AsyncMock() + monkeypatch.setattr(runtime_client, "close", close) lease = ShellctlRuntimeLease( handle="binding-1:workspace-1", layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace"), @@ -515,7 +530,7 @@ def test_ordinary_download_with_configured_stub_still_uploads_once(monkeypatch, assert len(commands.calls) == 1 assert commands.calls[0][0] == "dify-agent file upload --no-download-link /workspace/report.txt" assert commands.deletes == ["job-1"] - runtime_client.close.assert_awaited_once() + close.assert_awaited_once() assert scope_client.inspected == [] assert factory.runs == [] @@ -562,8 +577,8 @@ async def test_native_job_refs_survive_layer_snapshot_and_reconnect_without_run( session, _ = _layer(commands=commands) session.deps.resources.leases["runtime"] = NativeScopeLease( handle=saved.binding_ref, - layout=SimpleNamespace(home_dir="/home/dify", workspace_dir="/workspace"), - client=SimpleNamespace(), + layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace"), + client=ShellctlClient(base_url="http://runtime.invalid"), commands=commands, ) session.save_state(restored) @@ -590,8 +605,8 @@ async def test_native_legacy_state_without_job_identity_never_sends_stdin(): session, _ = _layer(commands=commands) session.deps.resources.leases["runtime"] = NativeScopeLease( handle="native:binding-1:1", - layout=SimpleNamespace(home_dir="/home/dify", workspace_dir="/workspace"), - client=SimpleNamespace(), + layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace"), + client=ShellctlClient(base_url="http://runtime.invalid"), commands=commands, ) session.save_state(State.model_validate({"initialized": True, "job_ids": ["job-8"], "job_offsets": {"job-8": 12}})) @@ -644,33 +659,39 @@ def test_strict_runless_download_refuses_without_owner_before_issuance_or_io(mon assert factory.runs == [] assert scope_client.created == [] assert scope_client.inspected == [] - cast(AsyncMock, getattr(scope_client, "destroy")).assert_not_called() + cast(AsyncMock, scope_client.destroy).assert_not_called() cast(AsyncMock, profile.home_snapshots.create_from_runtime).assert_not_called() @pytest.mark.anyio -async def test_confirmed_runtime_borrows_ordinary_binding_only_after_owner_validation(): +async def test_confirmed_runtime_borrows_ordinary_binding_only_after_owner_validation(monkeypatch): from unittest.mock import AsyncMock + from dify_agent.layers.runtime.configs import DifyRuntimeLayerConfig from dify_agent.layers.runtime.layer import Capability from tests.local.dify_agent.module_support import deps_for deps = deps_for("runtime", DifyRuntimeLayerConfig(backend_binding_ref="ordinary-binding"), {}) calls = [] - lease = SimpleNamespace() + lease = native_lease() - async def acquire(binding): + async def acquire(_self, binding): calls.append(("acquire", binding)) return lease async def validate(**kwargs): calls.append(("validate", kwargs["target"])) - backend = SimpleNamespace(acquire=acquire, release=AsyncMock()) - deps.services.runtime_backend_profile = SimpleNamespace(execution_bindings=backend) - deps.native_effects = SimpleNamespace( - validate_shell_lineage=validate, continuation=SimpleNamespace(ref="ref", native_test_profile="confirmed") + backend = LocalExecutionBindingBackend(endpoint="http://runtime.invalid", auth_token="test") + release = AsyncMock() + monkeypatch.setattr(LocalExecutionBindingBackend, "acquire", acquire) + monkeypatch.setattr(LocalExecutionBindingBackend, "release", release) + deps.services.runtime_backend_profile = RuntimeBackendProfile( + home_snapshots=LocalHomeSnapshotBackend(endpoint="http://runtime.invalid", auth_token="test"), + execution_bindings=backend, ) - await Capability("runtime").wrap_run(SimpleNamespace(deps=deps), handler=AsyncMock(return_value="done")) + deps.native_effects = native_effects(deps, profile="confirmed") + deps.native_effects.validate_shell_lineage = validate + await Capability("runtime").wrap_run(run_context(deps), handler=AsyncMock(return_value="done")) assert calls == [("validate", {"backend_binding_ref": "ordinary-binding"}), ("acquire", "ordinary-binding")] - backend.release.assert_awaited_once_with(lease) + release.assert_awaited_once_with(lease)