From 3f81ec12129f8dbaceeeb74941137d117d06aee6 Mon Sep 17 00:00:00 2001 From: Prajeeth Channa Date: Tue, 16 Jun 2026 21:50:50 -0400 Subject: [PATCH 01/62] test: replace logger patch with caplog in version and rag pipeline tests (#37554) --- .../unit_tests/controllers/console/test_version.py | 7 ++++--- .../services/test_rag_pipeline_task_proxy.py | 11 +++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/test_version.py b/api/tests/unit_tests/controllers/console/test_version.py index 8d8d324be1f..335c8692969 100644 --- a/api/tests/unit_tests/controllers/console/test_version.py +++ b/api/tests/unit_tests/controllers/console/test_version.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import MagicMock, patch import controllers.console.version as version_module @@ -18,15 +19,15 @@ class TestHasNewVersion: ) assert result is False - def test_has_new_version_invalid_version(self): - with patch.object(version_module.logger, "warning") as log_warning: + def test_has_new_version_invalid_version(self, caplog): + with caplog.at_level(logging.WARNING, logger="controllers.console.version"): result = version_module._has_new_version( latest_version="invalid", current_version="1.0.0", ) assert result is False - log_warning.assert_called_once() + assert "Invalid version format" in caplog.text class TestCheckVersionUpdate: diff --git a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py index cfc685e4cbd..ce14d55d4de 100644 --- a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py +++ b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py @@ -1,4 +1,5 @@ import json +import logging from unittest.mock import Mock, patch import pytest @@ -468,16 +469,14 @@ class TestRagPipelineTaskProxy: # Assert proxy._dispatch.assert_called_once() - @patch("services.rag_pipeline.rag_pipeline_task_proxy.logger") - def test_delay_method_with_empty_entities(self, mock_logger): + def test_delay_method_with_empty_entities(self, caplog): """Test delay method with empty rag_pipeline_invoke_entities.""" # Arrange proxy = RagPipelineTaskProxy("tenant-123", "user-456", []) # Act - proxy.delay() + with caplog.at_level(logging.WARNING, logger="services.rag_pipeline.rag_pipeline_task_proxy"): + proxy.delay() # Assert - mock_logger.warning.assert_called_once_with( - "Received empty rag pipeline invoke entities, no tasks delivered: %s %s", "tenant-123", "user-456" - ) + assert "Received empty rag pipeline invoke entities, no tasks delivered: tenant-123 user-456" in caplog.text From 8ca8b3d59ae564e96d3fbe1477a2a5321c116cd1 Mon Sep 17 00:00:00 2001 From: Evan <2869018789@qq.com> Date: Wed, 17 Jun 2026 10:22:39 +0800 Subject: [PATCH 02/62] refactor: replace mock.patch logger with pytest caplog in tests (#37560) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../core/app/layers/test_timeslice_layer.py | 13 ++++++------- .../app/layers/test_trigger_post_layer.py | 9 +++++---- ...test_message_cycle_manager_optimization.py | 9 +++++---- .../rag/embedding/test_cached_embedding.py | 19 ++++++++++--------- .../core/rag/extractor/test_word_extractor.py | 10 +++++----- .../core/rag/splitter/test_text_splitter.py | 7 ++++--- 6 files changed, 35 insertions(+), 32 deletions(-) diff --git a/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py b/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py index 1ac9a4d8c0c..191c103a8ac 100644 --- a/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import Mock, patch from core.app.layers.timeslice_layer import TimeSliceLayer @@ -64,21 +65,19 @@ class TestTimeSliceLayer: scheduler.remove_job.assert_called_once_with("job-1") - def test_checker_job_handles_resource_limit_without_command_channel(self): + def test_checker_job_handles_resource_limit_without_command_channel(self, caplog): scheduler = Mock() scheduler.running = True cfs_plan_scheduler = Mock(plan=Mock()) cfs_plan_scheduler.can_schedule.return_value = SchedulerCommand.RESOURCE_LIMIT_REACHED - with ( - patch("core.app.layers.timeslice_layer.TimeSliceLayer.scheduler", scheduler), - patch("core.app.layers.timeslice_layer.logger") as mock_logger, - ): + with patch("core.app.layers.timeslice_layer.TimeSliceLayer.scheduler", scheduler): layer = TimeSliceLayer(cfs_plan_scheduler=cfs_plan_scheduler) - layer._checker_job("job-1") + with caplog.at_level(logging.ERROR, logger="core.app.layers.timeslice_layer"): + layer._checker_job("job-1") scheduler.remove_job.assert_called_once_with("job-1") - mock_logger.exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) def test_checker_job_sends_pause_command(self): scheduler = Mock() diff --git a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py index f82cf201422..88f4a6cc31e 100644 --- a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py @@ -1,3 +1,4 @@ +import logging from datetime import UTC, datetime, timedelta from types import SimpleNamespace from unittest.mock import Mock, patch @@ -114,7 +115,7 @@ class TestTriggerPostLayer: repo.update.assert_called_once_with(trigger_log) session.commit.assert_called_once() - def test_on_event_handles_missing_trigger_log(self): + def test_on_event_handles_missing_trigger_log(self, caplog): runtime_state = SimpleNamespace( outputs={}, variable_pool=VariablePool.from_bootstrap( @@ -126,7 +127,6 @@ class TestTriggerPostLayer: with ( patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory, patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls, - patch("core.app.layers.trigger_post_layer.logger") as mock_logger, ): session = Mock() mock_session_factory.create_session.return_value.__enter__.return_value = session @@ -142,9 +142,10 @@ class TestTriggerPostLayer: ) layer.initialize(runtime_state, Mock()) - layer.on_event(GraphRunFailedEvent(error="boom")) + with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"): + layer.on_event(GraphRunFailedEvent(error="boom")) - mock_logger.exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) session.commit.assert_not_called() def test_on_event_ignores_non_status_events(self): diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py index 92fe3cbec67..4324fdf8844 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py @@ -1,5 +1,6 @@ """Unit tests for the message cycle manager optimization.""" +import logging from types import SimpleNamespace from unittest.mock import Mock, patch @@ -344,7 +345,7 @@ class TestMessageCycleManagerOptimization: db_session.close.assert_called_once() mock_redis.setex.assert_called_once() - def test_generate_conversation_name_worker_falls_back_when_generation_fails(self, message_cycle_manager): + def test_generate_conversation_name_worker_falls_back_when_generation_fails(self, message_cycle_manager, caplog): """Fallback to truncated query when LLM generation fails.""" flask_app = Flask(__name__) conversation = SimpleNamespace( @@ -362,19 +363,19 @@ class TestMessageCycleManagerOptimization: patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis, patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator, patch("core.app.task_pipeline.message_cycle_manager.dify_config") as mock_dify_config, - patch("core.app.task_pipeline.message_cycle_manager.logger") as mock_logger, ): mock_db.session = db_session mock_redis.get.return_value = None mock_llm_generator.generate_conversation_name.side_effect = RuntimeError("generation failed") mock_dify_config.DEBUG = True - message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query) + with caplog.at_level(logging.ERROR, logger="core.app.task_pipeline.message_cycle_manager"): + message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query) assert conversation.name == (long_query[:47] + "...") db_session.commit.assert_called_once() db_session.close.assert_called_once() - mock_logger.exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) def test_handle_annotation_reply_sets_metadata(self, message_cycle_manager): """Populate task metadata from annotation reply events. diff --git a/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py b/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py index 051a1455aef..364f688c8e4 100644 --- a/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py +++ b/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py @@ -7,6 +7,7 @@ This test file covers the methods not fully tested in test_embedding_service.py: """ import base64 +import logging from decimal import Decimal from unittest.mock import Mock, patch @@ -188,7 +189,7 @@ class TestCacheEmbeddingMultimodalDocuments: assert len(result) == 3 assert result[0] == normalized_cached - def test_embed_multimodal_documents_nan_handling(self, mock_model_instance): + def test_embed_multimodal_documents_nan_handling(self, mock_model_instance, caplog): """Test handling of NaN values in multimodal embeddings.""" cache_embedding = CacheEmbedding(mock_model_instance) documents = [{"file_id": "valid"}, {"file_id": "nan"}] @@ -216,14 +217,14 @@ class TestCacheEmbeddingMultimodalDocuments: mock_session.scalar.return_value = None mock_model_instance.invoke_multimodal_embedding.return_value = embedding_result - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.WARNING, logger="core.rag.embedding.cached_embedding"): result = cache_embedding.embed_multimodal_documents(documents) assert len(result) == 2 assert result[0] is not None assert result[1] is None - mock_logger.warning.assert_called_once() + assert any(record.levelno == logging.WARNING for record in caplog.records) def test_embed_multimodal_documents_large_batch(self, mock_model_instance): """Test embedding large batch of multimodal documents respecting MAX_CHUNKS.""" @@ -463,7 +464,7 @@ class TestCacheEmbeddingQueryErrors: model_instance.credentials = {"api_key": "test-key"} return model_instance - def test_embed_query_api_error_debug_mode(self, mock_model_instance): + def test_embed_query_api_error_debug_mode(self, mock_model_instance, caplog): """Test handling of API errors in debug mode.""" cache_embedding = CacheEmbedding(mock_model_instance) query = "test query" @@ -475,14 +476,14 @@ class TestCacheEmbeddingQueryErrors: with patch("core.rag.embedding.cached_embedding.dify_config") as mock_config: mock_config.DEBUG = True - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="core.rag.embedding.cached_embedding"): with pytest.raises(RuntimeError) as exc_info: cache_embedding.embed_query(query) assert "API Error" in str(exc_info.value) - mock_logger.exception.assert_called() + assert any(record.levelno == logging.ERROR for record in caplog.records) - def test_embed_query_redis_set_error_debug_mode(self, mock_model_instance): + def test_embed_query_redis_set_error_debug_mode(self, mock_model_instance, caplog): """Test handling of Redis set errors in debug mode.""" cache_embedding = CacheEmbedding(mock_model_instance) query = "test query" @@ -514,11 +515,11 @@ class TestCacheEmbeddingQueryErrors: with patch("core.rag.embedding.cached_embedding.dify_config") as mock_config: mock_config.DEBUG = True - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="core.rag.embedding.cached_embedding"): with pytest.raises(RuntimeError): cache_embedding.embed_query(query) - mock_logger.exception.assert_called() + assert any(record.levelno == logging.ERROR for record in caplog.records) class TestCacheEmbeddingInitialization: diff --git a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py index 45d6fc1cd07..e85bb2f68e0 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py @@ -1,6 +1,7 @@ """Primarily used for testing merged cell scenarios""" import io +import logging import os import tempfile from collections import UserDict @@ -548,7 +549,7 @@ def test_parse_docx_reads_real_paragraph_table_order(monkeypatch: pytest.MonkeyP os.remove(tmp_path) -def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monkeypatch: pytest.MonkeyPatch): +def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monkeypatch: pytest.MonkeyPatch, caplog): extractor = object.__new__(WordExtractor) ext_image_id = "ext-image" @@ -709,10 +710,9 @@ def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monke monkeypatch.setattr(we, "Run", FakeRun) monkeypatch.setattr(extractor, "_extract_images_from_docx", lambda doc: image_map) monkeypatch.setattr(extractor, "_table_to_markdown", lambda table, image_map: "TABLE-MARKDOWN") - logger_exception = MagicMock() - monkeypatch.setattr(we.logger, "exception", logger_exception) - content = extractor.parse_docx("dummy.docx") + with caplog.at_level(logging.ERROR, logger="core.rag.extractor.word_extractor"): + content = extractor.parse_docx("dummy.docx") assert "[EXT]" in content assert "[INT]" in content @@ -720,7 +720,7 @@ def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monke assert "[LinkText](https://example.com)" in content assert "BrokenLink" in content assert "TABLE-MARKDOWN" in content - logger_exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) def test_parse_cell_paragraph_hyperlink_in_table_cell_http(): diff --git a/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py b/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py index 976de10d89d..980139192c3 100644 --- a/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py +++ b/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py @@ -126,6 +126,7 @@ Run with coverage: """ import asyncio +import logging import string import sys import types @@ -644,13 +645,13 @@ class TestTextSplitterBasePaths: with pytest.raises(NotImplementedError): asyncio.run(splitter.atransform_documents([Document(page_content="x", metadata={})])) - def test_merge_splits_logs_warning_for_oversized_total(self): + def test_merge_splits_logs_warning_for_oversized_total(self, caplog): """Cover logger.warning path in _merge_splits.""" splitter = RecursiveCharacterTextSplitter(chunk_size=5, chunk_overlap=1) - with patch("core.rag.splitter.text_splitter.logger.warning") as mock_warning: + with caplog.at_level(logging.WARNING, logger="core.rag.splitter.text_splitter"): merged = splitter._merge_splits(["abcdefghij", "b"], "", [10, 1]) assert merged - mock_warning.assert_called_once() + assert any(record.levelno == logging.WARNING for record in caplog.records) # ============================================================================ From 6ab5cf109baa9597e38ec28e19647d0e436413b4 Mon Sep 17 00:00:00 2001 From: Ingram Z Date: Wed, 17 Jun 2026 11:19:26 +0800 Subject: [PATCH 03/62] refactor: optimize free plan workflow run cleanup batching (#37227) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../api_workflow_run_repository.py | 72 ++++ .../sqlalchemy_api_workflow_run_repository.py | 187 +++++++++- ...ear_free_plan_expired_workflow_run_logs.py | 103 ++++-- ...alchemy_workflow_run_cleanup_repository.py | 320 ++++++++++++++++++ ...ear_free_plan_expired_workflow_run_logs.py | 66 ++-- ...ear_free_plan_expired_workflow_run_logs.py | 169 ++++++--- 6 files changed, 812 insertions(+), 105 deletions(-) create mode 100644 api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py index 72b38e79068..2659e550552 100644 --- a/api/repositories/api_workflow_run_repository.py +++ b/api/repositories/api_workflow_run_repository.py @@ -35,6 +35,7 @@ Example: """ from collections.abc import Callable, Sequence +from dataclasses import dataclass from datetime import datetime from typing import Protocol, TypedDict @@ -65,6 +66,21 @@ class RunsWithRelatedCountsDict(TypedDict): pause_reasons: int +@dataclass(frozen=True) +class WorkflowRunCleanupRef: + """ + Lightweight workflow run reference for retention cleanup scans. + + Cleanup jobs use this DTO when they only need cursor, tenant eligibility, and run-id deletion data. Keeping the + query shape explicit prevents free-plan cleanup from hydrating full WorkflowRun models for rows that may be skipped + after billing checks. + """ + + id: str + tenant_id: str + created_at: datetime + + class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ Protocol for service-layer WorkflowRun repository operations. @@ -286,6 +302,36 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def get_cleanup_refs_batch_by_time_range( + self, + start_from: datetime | None, + end_before: datetime, + last_seen: tuple[datetime, str] | None, + batch_size: int, + run_types: Sequence[WorkflowType] | None = None, + tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, + upper_bound: tuple[datetime, str] | None = None, + ) -> Sequence[WorkflowRunCleanupRef]: + """ + Fetch lightweight ended workflow run refs in a time window for cleanup batching. + + Args: + start_from: Optional inclusive lower time boundary. + end_before: Exclusive upper time boundary. + last_seen: Optional exclusive `(created_at, id)` cursor lower bound. + batch_size: Maximum number of refs to return. + run_types: Optional workflow type filter. + tenant_ids: Optional tenant filter. + workflow_ids: Optional workflow ID filter. + upper_bound: Optional inclusive `(created_at, id)` cursor upper bound. Cleanup uses this for a second, + tenant-filtered target query that must stay within the candidate page high-water cursor. + + Returns: + Ordered lightweight cleanup refs containing only id, tenant_id, and created_at. + """ + ... + def get_archived_run_ids( self, session: Session, @@ -370,6 +416,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def delete_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + """ + Delete workflow runs and cleanup-owned related records by workflow run IDs. + + This mirrors delete_runs_with_related() for cleanup callers that do not need full WorkflowRun models. + """ + ... + def get_app_logs_by_run_id( self, session: Session, @@ -417,6 +476,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def count_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + """ + Count workflow runs and cleanup-owned related records by workflow run IDs. + + This mirrors count_runs_with_related() for dry-run cleanup callers that do not need full WorkflowRun models. + """ + ... + def create_workflow_pause( self, workflow_run_id: str, diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index cbc9d03e5eb..98c605f0a17 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -44,7 +44,11 @@ from libs.time_parser import get_time_threshold from models.enums import WorkflowRunTriggeredFrom from models.human_input import HumanInputForm, HumanInputFormRecipient from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun -from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict +from repositories.api_workflow_run_repository import ( + APIWorkflowRunRepository, + RunsWithRelatedCountsDict, + WorkflowRunCleanupRef, +) from repositories.entities.workflow_pause import WorkflowPauseEntity from repositories.types import ( AverageInteractionStats, @@ -420,6 +424,71 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): return session.scalars(stmt).all() + @override + def get_cleanup_refs_batch_by_time_range( + self, + start_from: datetime | None, + end_before: datetime, + last_seen: tuple[datetime, str] | None, + batch_size: int, + run_types: Sequence[WorkflowType] | None = None, + tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, + upper_bound: tuple[datetime, str] | None = None, + ) -> Sequence[WorkflowRunCleanupRef]: + """ + Fetch lightweight ended workflow run refs in a time window for cleanup batching. + + The optional upper_bound is inclusive and is paired with last_seen by free-plan cleanup so a second, + tenant-filtered target query stays within the candidate page already checked against billing. + """ + with self._session_maker() as session: + stmt = ( + select(WorkflowRun.id, WorkflowRun.tenant_id, WorkflowRun.created_at) + .where( + WorkflowRun.created_at < end_before, + WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()), + ) + .order_by(WorkflowRun.created_at.asc(), WorkflowRun.id.asc()) + .limit(batch_size) + ) + if run_types is not None: + if not run_types: + return [] + stmt = stmt.where(WorkflowRun.type.in_(run_types)) + + if start_from: + stmt = stmt.where(WorkflowRun.created_at >= start_from) + + if tenant_ids: + stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids)) + + if workflow_ids: + stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids)) + + if last_seen: + stmt = stmt.where( + tuple_(WorkflowRun.created_at, WorkflowRun.id) + > tuple_( + sa.literal(last_seen[0], type_=sa.DateTime()), + sa.literal(last_seen[1], type_=WorkflowRun.id.type), + ) + ) + + if upper_bound: + stmt = stmt.where( + tuple_(WorkflowRun.created_at, WorkflowRun.id) + <= tuple_( + sa.literal(upper_bound[0], type_=sa.DateTime()), + sa.literal(upper_bound[1], type_=WorkflowRun.id.type), + ) + ) + + return [ + WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at) + for run_id, tenant_id, created_at in session.execute(stmt).all() + ] + @override def get_archived_run_ids( self, @@ -530,6 +599,56 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): "pause_reasons": pause_reasons_deleted, } + @override + def delete_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + if not run_ids: + return self._empty_runs_with_related_counts() + + run_ids = list(run_ids) + with self._session_maker() as session: + if delete_node_executions: + node_executions_deleted, offloads_deleted = delete_node_executions(session, run_ids) + else: + node_executions_deleted, offloads_deleted = 0, 0 + + app_logs_result = session.execute(delete(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))) + app_logs_deleted = cast(CursorResult, app_logs_result).rowcount or 0 + + pause_stmt = select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids)) + pause_ids = session.scalars(pause_stmt).all() + pause_reasons_deleted = 0 + pauses_deleted = 0 + + if pause_ids: + pause_reasons_result = session.execute( + delete(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids)) + ) + pause_reasons_deleted = cast(CursorResult, pause_reasons_result).rowcount or 0 + pauses_result = session.execute(delete(WorkflowPause).where(WorkflowPause.id.in_(pause_ids))) + pauses_deleted = cast(CursorResult, pauses_result).rowcount or 0 + + trigger_logs_deleted = delete_trigger_logs(session, run_ids) if delete_trigger_logs else 0 + + runs_result = session.execute(delete(WorkflowRun).where(WorkflowRun.id.in_(run_ids))) + runs_deleted = cast(CursorResult, runs_result).rowcount or 0 + + session.commit() + + return { + "runs": runs_deleted, + "node_executions": node_executions_deleted, + "offloads": offloads_deleted, + "app_logs": app_logs_deleted, + "trigger_logs": trigger_logs_deleted, + "pauses": pauses_deleted, + "pause_reasons": pause_reasons_deleted, + } + @override def get_app_logs_by_run_id( self, @@ -711,6 +830,72 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): "pause_reasons": int(pause_reasons_count), } + @override + def count_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + if not run_ids: + return self._empty_runs_with_related_counts() + + run_ids = list(run_ids) + with self._session_maker() as session: + if count_node_executions: + node_executions_count, offloads_count = count_node_executions(session, run_ids) + else: + node_executions_count, offloads_count = 0, 0 + + runs_count = ( + session.scalar(select(func.count()).select_from(WorkflowRun).where(WorkflowRun.id.in_(run_ids))) or 0 + ) + app_logs_count = ( + session.scalar( + select(func.count()).select_from(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids)) + ) + or 0 + ) + + pause_ids = session.scalars( + select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids)) + ).all() + pauses_count = len(pause_ids) + pause_reasons_count = 0 + if pause_ids: + pause_reasons_count = ( + session.scalar( + select(func.count()) + .select_from(WorkflowPauseReason) + .where(WorkflowPauseReason.pause_id.in_(pause_ids)) + ) + or 0 + ) + + trigger_logs_count = count_trigger_logs(session, run_ids) if count_trigger_logs else 0 + + return { + "runs": int(runs_count), + "node_executions": node_executions_count, + "offloads": offloads_count, + "app_logs": int(app_logs_count), + "trigger_logs": trigger_logs_count, + "pauses": pauses_count, + "pause_reasons": int(pause_reasons_count), + } + + @staticmethod + def _empty_runs_with_related_counts() -> RunsWithRelatedCountsDict: + return { + "runs": 0, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + @override def create_workflow_pause( self, diff --git a/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py b/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py index 58e8ac57a8f..3652997f8af 100644 --- a/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py +++ b/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py @@ -1,3 +1,10 @@ +"""Cleanup expired workflow run logs for free-plan tenants. + +The cleanup service owns billing eligibility decisions while repositories own database-efficient batch selection and +deletion. Free-plan cleanup intentionally scans lightweight workflow run references first, then re-queries the same +candidate cursor slice with eligible tenant IDs so paid tenants are skipped without hydrating full WorkflowRun models. +""" + import datetime import logging import random @@ -11,8 +18,11 @@ from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from enums.cloud_plan import CloudPlan from extensions.ext_database import db -from models.workflow import WorkflowRun -from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict +from repositories.api_workflow_run_repository import ( + APIWorkflowRunRepository, + RunsWithRelatedCountsDict, + WorkflowRunCleanupRef, +) from repositories.factory import DifyAPIRepositoryFactory from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository from services.billing_service import BillingService, SubscriptionPlan @@ -186,6 +196,13 @@ _RELATED_RECORD_KEYS = ("node_executions", "offloads", "app_logs", "trigger_logs class WorkflowRunCleanup: + """ + Coordinates free-plan workflow run retention cleanup. + + The cleanup cursor advances by candidate refs, not target refs. This keeps pagination stable + when billing filters out paid or unknown tenants before the repository performs the target lookup. + """ + def __init__( self, days: int, @@ -254,26 +271,28 @@ class WorkflowRunCleanup: batch_start = time.monotonic() fetch_start = time.monotonic() - run_rows = self.workflow_run_repo.get_runs_batch_by_time_range( + candidate_last_seen = last_seen + candidate_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range( start_from=self.window_start, end_before=self.window_end, - last_seen=last_seen, + last_seen=candidate_last_seen, batch_size=self.batch_size, ) - if not run_rows: + if not candidate_refs: logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1) break batch_index += 1 - last_seen = (run_rows[-1].created_at, run_rows[-1].id) + candidate_high_water = self._cursor_from_ref(candidate_refs[-1]) + last_seen = candidate_high_water logger.info( - "workflow_run_cleanup (batch #%s): fetched %s rows in %sms", + "workflow_run_cleanup (batch #%s): fetched %s candidate refs in %sms", batch_index, - len(run_rows), + len(candidate_refs), int((time.monotonic() - fetch_start) * 1000), ) - tenant_ids = {row.tenant_id for row in run_rows} + tenant_ids = {ref.tenant_id for ref in candidate_refs} filter_start = time.monotonic() free_tenants = self._filter_free_tenants(tenant_ids) @@ -285,10 +304,28 @@ class WorkflowRunCleanup: int((time.monotonic() - filter_start) * 1000), ) - free_runs = [row for row in run_rows if row.tenant_id in free_tenants] - paid_or_skipped = len(run_rows) - len(free_runs) + target_refs: Sequence[WorkflowRunCleanupRef] = [] + if free_tenants: + target_fetch_start = time.monotonic() + target_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range( + start_from=self.window_start, + end_before=self.window_end, + last_seen=candidate_last_seen, + batch_size=self.batch_size, + tenant_ids=sorted(free_tenants), + upper_bound=candidate_high_water, + ) + logger.info( + "workflow_run_cleanup (batch #%s): fetched %s target refs in %sms", + batch_index, + len(target_refs), + int((time.monotonic() - target_fetch_start) * 1000), + ) - if not free_runs: + target_run_ids = [ref.id for ref in target_refs] + paid_or_skipped = max(len(candidate_refs) - len(target_run_ids), 0) + + if not target_run_ids: skipped_message = ( f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)" ) @@ -299,7 +336,7 @@ class WorkflowRunCleanup: ) ) self._metrics.record_batch( - batch_rows=len(run_rows), + batch_rows=len(candidate_refs), targeted_runs=0, skipped_runs=paid_or_skipped, deleted_runs=0, @@ -309,13 +346,13 @@ class WorkflowRunCleanup: ) continue - total_runs_targeted += len(free_runs) + total_runs_targeted += len(target_run_ids) if self.dry_run: count_start = time.monotonic() - batch_counts = self.workflow_run_repo.count_runs_with_related( - free_runs, - count_node_executions=self._count_node_executions, + batch_counts = self.workflow_run_repo.count_runs_with_related_by_ids( + target_run_ids, + count_node_executions=self._count_node_executions_by_run_ids, count_trigger_logs=self._count_trigger_logs, ) logger.info( @@ -325,10 +362,10 @@ class WorkflowRunCleanup: ) if related_totals is not None: self._accumulate_related_counts(related_totals, batch_counts) - sample_ids = ", ".join(run.id for run in free_runs[:5]) + sample_ids = ", ".join(target_run_ids[:5]) click.echo( click.style( - f"[batch #{batch_index}] would delete {len(free_runs)} runs " + f"[batch #{batch_index}] would delete {len(target_run_ids)} runs " f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown", fg="yellow", ) @@ -339,8 +376,8 @@ class WorkflowRunCleanup: int((time.monotonic() - batch_start) * 1000), ) self._metrics.record_batch( - batch_rows=len(run_rows), - targeted_runs=len(free_runs), + batch_rows=len(candidate_refs), + targeted_runs=len(target_run_ids), skipped_runs=paid_or_skipped, deleted_runs=0, related_counts={ @@ -354,14 +391,14 @@ class WorkflowRunCleanup: try: delete_start = time.monotonic() - counts = self.workflow_run_repo.delete_runs_with_related( - free_runs, - delete_node_executions=self._delete_node_executions, + counts = self.workflow_run_repo.delete_runs_with_related_by_ids( + target_run_ids, + delete_node_executions=self._delete_node_executions_by_run_ids, delete_trigger_logs=self._delete_trigger_logs, ) delete_ms = int((time.monotonic() - delete_start) * 1000) except Exception: - logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0]) + logger.exception("Failed to delete workflow runs batch ending at %s", candidate_high_water[0]) raise total_runs_deleted += counts["runs"] @@ -382,8 +419,8 @@ class WorkflowRunCleanup: int((time.monotonic() - batch_start) * 1000), ) self._metrics.record_batch( - batch_rows=len(run_rows), - targeted_runs=len(free_runs), + batch_rows=len(candidate_refs), + targeted_runs=len(target_run_ids), skipped_runs=paid_or_skipped, deleted_runs=counts["runs"], related_counts={ @@ -439,7 +476,7 @@ class WorkflowRunCleanup: ) def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]: - tenant_id_list = list(tenant_ids) + tenant_id_list = sorted(set(tenant_ids)) if not dify_config.BILLING_ENABLED: return set(tenant_id_list) @@ -553,15 +590,17 @@ class WorkflowRunCleanup: totals["pauses"] += batch.get("pauses", 0) totals["pause_reasons"] += batch.get("pause_reasons", 0) - def _count_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: - run_ids = [run.id for run in runs] + @staticmethod + def _cursor_from_ref(ref: WorkflowRunCleanupRef) -> tuple[datetime.datetime, str]: + return ref.created_at, ref.id + + def _count_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]: repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False) ) return repo.count_by_runs(session, run_ids) - def _delete_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: - run_ids = [run.id for run in runs] + def _delete_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]: repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False) ) diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py new file mode 100644 index 00000000000..a2834dc80aa --- /dev/null +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py @@ -0,0 +1,320 @@ +"""Integration tests for workflow run cleanup repository queries.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import override +from uuid import uuid4 + +from sqlalchemy import Engine, select +from sqlalchemy.orm import Session, sessionmaker + +from graphon.entities import WorkflowExecution +from graphon.entities.pause_reason import PauseReasonType +from graphon.enums import WorkflowExecutionStatus, WorkflowType +from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowPause, WorkflowPauseReason, WorkflowRun +from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + + +class _TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository): + """Concrete repository for tests where save() is not under test.""" + + @override + def save(self, execution: WorkflowExecution) -> None: + return None + + +@dataclass +class _TestScope: + """Per-test identifiers for rows created by cleanup repository tests.""" + + tenant_id: str = field(default_factory=lambda: str(uuid4())) + app_id: str = field(default_factory=lambda: str(uuid4())) + workflow_id: str = field(default_factory=lambda: str(uuid4())) + user_id: str = field(default_factory=lambda: str(uuid4())) + + +def _repository(db_session_with_containers: Session) -> DifyAPISQLAlchemyWorkflowRunRepository: + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + return _TestWorkflowRunRepository(session_maker=sessionmaker(bind=engine, expire_on_commit=False)) + + +def _create_workflow_run( + session: Session, + scope: _TestScope, + *, + status: WorkflowExecutionStatus = WorkflowExecutionStatus.SUCCEEDED, + created_at: datetime, + tenant_id: str | None = None, + workflow_id: str | None = None, + workflow_type: str = WorkflowType.WORKFLOW, +) -> WorkflowRun: + workflow_run = WorkflowRun( + id=str(uuid4()), + tenant_id=tenant_id or scope.tenant_id, + app_id=scope.app_id, + workflow_id=workflow_id or scope.workflow_id, + type=workflow_type, + triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, + version="draft", + graph="{}", + inputs="{}", + status=status, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=scope.user_id, + created_at=created_at, + ) + session.add(workflow_run) + session.commit() + return workflow_run + + +def _add_app_log(session: Session, scope: _TestScope, workflow_run: WorkflowRun) -> None: + session.add( + WorkflowAppLog( + tenant_id=workflow_run.tenant_id, + app_id=scope.app_id, + workflow_id=workflow_run.workflow_id, + workflow_run_id=workflow_run.id, + created_from=WorkflowAppLogCreatedFrom.SERVICE_API, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=scope.user_id, + ) + ) + session.commit() + + +def _add_pause_with_reason(session: Session, scope: _TestScope, workflow_run: WorkflowRun) -> WorkflowPause: + pause = WorkflowPause( + workflow_id=workflow_run.workflow_id, + workflow_run_id=workflow_run.id, + state_object_key=f"workflow-state-{uuid4()}.json", + ) + pause_reason = WorkflowPauseReason( + pause_id=pause.id, + type_=PauseReasonType.SCHEDULED_PAUSE, + message="scheduled pause", + ) + session.add_all([pause, pause_reason]) + session.commit() + return pause + + +class TestGetCleanupRefsBatchByTimeRange: + def test_applies_cursor_window_and_cleanup_filters(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + base = datetime(2024, 1, 1, 12, 0, 0) + + _create_workflow_run(db_session_with_containers, scope, created_at=base - timedelta(minutes=1)) + cursor_run = _create_workflow_run(db_session_with_containers, scope, created_at=base) + first_target = _create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=1)) + second_target = _create_workflow_run( + db_session_with_containers, + scope, + status=WorkflowExecutionStatus.FAILED, + created_at=base + timedelta(minutes=2), + ) + _create_workflow_run( + db_session_with_containers, + scope, + status=WorkflowExecutionStatus.RUNNING, + created_at=base + timedelta(minutes=1), + ) + _create_workflow_run( + db_session_with_containers, + scope, + created_at=base + timedelta(minutes=1), + tenant_id=str(uuid4()), + ) + _create_workflow_run( + db_session_with_containers, + scope, + created_at=base + timedelta(minutes=1), + workflow_id=str(uuid4()), + ) + _create_workflow_run( + db_session_with_containers, + scope, + created_at=base + timedelta(minutes=1), + workflow_type=WorkflowType.CHAT, + ) + _create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=3)) + + refs = repository.get_cleanup_refs_batch_by_time_range( + start_from=base, + end_before=base + timedelta(minutes=4), + last_seen=(cursor_run.created_at, cursor_run.id), + batch_size=10, + run_types=[WorkflowType.WORKFLOW], + tenant_ids=[scope.tenant_id], + workflow_ids=[scope.workflow_id], + upper_bound=(second_target.created_at, second_target.id), + ) + + assert [(ref.id, ref.tenant_id, ref.created_at) for ref in refs] == [ + (first_target.id, scope.tenant_id, first_target.created_at), + (second_target.id, scope.tenant_id, second_target.created_at), + ] + + def test_returns_empty_when_run_type_filter_is_empty(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + + refs = repository.get_cleanup_refs_batch_by_time_range( + start_from=None, + end_before=datetime(2024, 1, 2), + last_seen=None, + batch_size=10, + run_types=[], + ) + + assert refs == [] + + +class TestCountRunsWithRelatedByIds: + def test_counts_existing_runs_and_related_rows(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + missing_run_id = str(uuid4()) + _add_app_log(db_session_with_containers, scope, workflow_run) + _add_pause_with_reason(db_session_with_containers, scope, workflow_run) + counted_node_run_ids: list[str] = [] + counted_trigger_run_ids: list[str] = [] + + counts = repository.count_runs_with_related_by_ids( + [workflow_run.id, missing_run_id], + count_node_executions=lambda _session, run_ids: counted_node_run_ids.extend(run_ids) or (2, 1), + count_trigger_logs=lambda _session, run_ids: counted_trigger_run_ids.extend(run_ids) or 3, + ) + + assert counted_node_run_ids == [workflow_run.id, missing_run_id] + assert counted_trigger_run_ids == [workflow_run.id, missing_run_id] + assert counts == { + "runs": 1, + "node_executions": 2, + "offloads": 1, + "app_logs": 1, + "trigger_logs": 3, + "pauses": 1, + "pause_reasons": 1, + } + + def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + + counts = repository.count_runs_with_related_by_ids([workflow_run.id]) + + assert counts == { + "runs": 1, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + + +class TestDeleteRunsWithRelatedByIds: + def test_deletes_runs_and_related_rows(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + _add_app_log(db_session_with_containers, scope, workflow_run) + pause = _add_pause_with_reason(db_session_with_containers, scope, workflow_run) + pause_id = pause.id + deleted_node_run_ids: list[str] = [] + deleted_trigger_run_ids: list[str] = [] + + counts = repository.delete_runs_with_related_by_ids( + [workflow_run.id], + delete_node_executions=lambda _session, run_ids: deleted_node_run_ids.extend(run_ids) or (2, 1), + delete_trigger_logs=lambda _session, run_ids: deleted_trigger_run_ids.extend(run_ids) or 3, + ) + + assert deleted_node_run_ids == [workflow_run.id] + assert deleted_trigger_run_ids == [workflow_run.id] + assert counts == { + "runs": 1, + "node_executions": 2, + "offloads": 1, + "app_logs": 1, + "trigger_logs": 3, + "pauses": 1, + "pause_reasons": 1, + } + verification_session = Session(bind=db_session_with_containers.get_bind()) + with verification_session: + assert verification_session.get(WorkflowRun, workflow_run.id) is None + assert verification_session.get(WorkflowPause, pause_id) is None + assert ( + verification_session.scalar( + select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id == workflow_run.id) + ) + is None + ) + assert ( + verification_session.scalar(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_id)) + is None + ) + + def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + + counts = repository.delete_runs_with_related_by_ids([workflow_run.id]) + + assert counts == { + "runs": 1, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + + def test_empty_ids_return_empty_counts(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + + assert repository.count_runs_with_related_by_ids([]) == { + "runs": 0, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + assert repository.delete_runs_with_related_by_ids([]) == { + "runs": 0, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } diff --git a/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py index 7d30645d38a..1e15a72f476 100644 --- a/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py @@ -7,15 +7,16 @@ from unittest.mock import MagicMock, patch import pytest +from repositories.api_workflow_run_repository import WorkflowRunCleanupRef from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup -def make_run(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None): - run = MagicMock() - run.tenant_id = tenant_id - run.id = run_id - run.created_at = created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) - return run +def make_ref(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None): + return WorkflowRunCleanupRef( + id=run_id, + tenant_id=tenant_id, + created_at=created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + ) @pytest.fixture @@ -341,28 +342,28 @@ class TestRunDeleteMode: return WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) def test_no_rows_stops_immediately(self, mock_repo): - mock_repo.get_runs_batch_by_time_range.return_value = [] + mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] c = self._make_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.delete_runs_with_related.assert_not_called() + mock_repo.delete_runs_with_related_by_ids.assert_not_called() def test_all_paid_skips_delete(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []] c = self._make_cleanup(mock_repo) # billing disabled -> all free; but let's override _filter_free_tenants to return empty c._filter_free_tenants = MagicMock(return_value=set()) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.delete_runs_with_related.assert_not_called() + mock_repo.delete_runs_with_related_by_ids.assert_not_called() def test_runs_deleted_successfully(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] - mock_repo.delete_runs_with_related.return_value = { + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []] + mock_repo.delete_runs_with_related_by_ids.return_value = { "runs": 1, "node_executions": 0, "offloads": 0, @@ -376,12 +377,12 @@ class TestRunDeleteMode: cfg.BILLING_ENABLED = False with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.time.sleep"): c.run() - mock_repo.delete_runs_with_related.assert_called_once() + mock_repo.delete_runs_with_related_by_ids.assert_called_once() def test_delete_exception_reraises(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] - mock_repo.delete_runs_with_related.side_effect = RuntimeError("db error") + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref]] + mock_repo.delete_runs_with_related_by_ids.side_effect = RuntimeError("db error") c = self._make_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False @@ -389,7 +390,7 @@ class TestRunDeleteMode: c.run() def test_summary_with_window_start(self, mock_repo): - mock_repo.get_runs_batch_by_time_range.return_value = [] + mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 cfg.BILLING_ENABLED = False @@ -421,9 +422,10 @@ class TestRunDryRunMode: ) def test_dry_run_no_delete_called(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] - mock_repo.count_runs_with_related.return_value = { + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []] + mock_repo.count_runs_with_related_by_ids.return_value = { + "runs": 1, "node_executions": 2, "offloads": 0, "app_logs": 0, @@ -435,11 +437,11 @@ class TestRunDryRunMode: with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.delete_runs_with_related.assert_not_called() - mock_repo.count_runs_with_related.assert_called_once() + mock_repo.delete_runs_with_related_by_ids.assert_not_called() + mock_repo.count_runs_with_related_by_ids.assert_called_once() def test_dry_run_summary_with_window_start(self, mock_repo): - mock_repo.get_runs_batch_by_time_range.return_value = [] + mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 cfg.BILLING_ENABLED = False @@ -454,14 +456,14 @@ class TestRunDryRunMode: c.run() def test_dry_run_all_paid_skips_count(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []] c = self._make_dry_cleanup(mock_repo) c._filter_free_tenants = MagicMock(return_value=set()) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.count_runs_with_related.assert_not_called() + mock_repo.count_runs_with_related_by_ids.assert_not_called() # --------------------------------------------------------------------------- @@ -492,7 +494,7 @@ class TestTriggerLogMethods: # --------------------------------------------------------------------------- -# _count_node_executions / _delete_node_executions +# _count_node_executions_by_run_ids / _delete_node_executions_by_run_ids # --------------------------------------------------------------------------- @@ -500,25 +502,23 @@ class TestNodeExecutionMethods: def test_count_node_executions(self, cleanup): session = MagicMock() session.get_bind.return_value = MagicMock() - runs = [make_run("t1", "r1")] with patch( "services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory" ) as factory: repo = factory.create_api_workflow_node_execution_repository.return_value repo.count_by_runs.return_value = (10, 2) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"): - result = cleanup._count_node_executions(session, runs) + result = cleanup._count_node_executions_by_run_ids(session, ["r1"]) assert result == (10, 2) def test_delete_node_executions(self, cleanup): session = MagicMock() session.get_bind.return_value = MagicMock() - runs = [make_run("t1", "r1")] with patch( "services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory" ) as factory: repo = factory.create_api_workflow_node_execution_repository.return_value repo.delete_by_runs.return_value = (5, 1) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"): - result = cleanup._delete_node_executions(session, runs) + result = cleanup._delete_node_executions_by_run_ids(session, ["r1"]) assert result == (5, 1) diff --git a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py index 6bf78d34117..60488beb248 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py @@ -3,38 +3,27 @@ from typing import Any import pytest +from repositories.api_workflow_run_repository import WorkflowRunCleanupRef from services.billing_service import SubscriptionPlan from services.retention.workflow_run import clear_free_plan_expired_workflow_run_logs as cleanup_module from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup -class FakeRun: - def __init__( - self, - run_id: str, - tenant_id: str, - created_at: datetime.datetime, - app_id: str = "app-1", - workflow_id: str = "wf-1", - triggered_from: str = "workflow-run", - ) -> None: - self.id = run_id - self.tenant_id = tenant_id - self.app_id = app_id - self.workflow_id = workflow_id - self.triggered_from = triggered_from - self.created_at = created_at +def make_ref(run_id: str, tenant_id: str, created_at: datetime.datetime) -> WorkflowRunCleanupRef: + return WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at) class FakeRepo: def __init__( self, - batches: list[list[FakeRun]], + batches: list[list[WorkflowRunCleanupRef]], delete_result: dict[str, int] | None = None, count_result: dict[str, int] | None = None, ) -> None: self.batches = batches - self.call_idx = 0 + self.candidate_call_idx = 0 + self.last_candidate_batch: list[WorkflowRunCleanupRef] = [] + self.cleanup_ref_calls: list[dict[str, object]] = [] self.deleted: list[list[str]] = [] self.counted: list[list[str]] = [] self.delete_result = delete_result or { @@ -56,7 +45,7 @@ class FakeRepo: "pause_reasons": 0, } - def get_runs_batch_by_time_range( + def get_cleanup_refs_batch_by_time_range( self, start_from: datetime.datetime | None, end_before: datetime.datetime, @@ -65,27 +54,50 @@ class FakeRepo: run_types=None, tenant_ids=None, workflow_ids=None, - ) -> list[FakeRun]: - if self.call_idx >= len(self.batches): + upper_bound: tuple[datetime.datetime, str] | None = None, + ) -> list[WorkflowRunCleanupRef]: + self.cleanup_ref_calls.append( + { + "start_from": start_from, + "end_before": end_before, + "last_seen": last_seen, + "batch_size": batch_size, + "run_types": run_types, + "tenant_ids": tenant_ids, + "workflow_ids": workflow_ids, + "upper_bound": upper_bound, + } + ) + if tenant_ids is not None or upper_bound is not None: + refs = self.last_candidate_batch + if tenant_ids is not None: + tenant_id_set = set(tenant_ids) + refs = [ref for ref in refs if ref.tenant_id in tenant_id_set] + if upper_bound is not None: + refs = [ref for ref in refs if (ref.created_at, ref.id) <= upper_bound] + return refs[:batch_size] + + if self.candidate_call_idx >= len(self.batches): return [] - batch = self.batches[self.call_idx] - self.call_idx += 1 + batch = self.batches[self.candidate_call_idx] + self.candidate_call_idx += 1 + self.last_candidate_batch = batch return batch - def delete_runs_with_related( - self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None + def delete_runs_with_related_by_ids( + self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None ) -> dict[str, int]: - self.deleted.append([run.id for run in runs]) + self.deleted.append(list(run_ids)) result = self.delete_result.copy() - result["runs"] = len(runs) + result["runs"] = len(run_ids) return result - def count_runs_with_related( - self, runs: list[FakeRun], count_node_executions=None, count_trigger_logs=None + def count_runs_with_related_by_ids( + self, run_ids: list[str], count_node_executions=None, count_trigger_logs=None ) -> dict[str, int]: - self.counted.append([run.id for run in runs]) + self.counted.append(list(run_ids)) result = self.count_result.copy() - result["runs"] = len(runs) + result["runs"] = len(run_ids) return result @@ -218,8 +230,8 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: repo = FakeRepo( batches=[ [ - FakeRun("run-free", "t_free", cutoff), - FakeRun("run-paid", "t_paid", cutoff), + make_ref("run-free", "t_free", cutoff), + make_ref("run-paid", "t_paid", cutoff), ] ] ) @@ -240,11 +252,43 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: cleanup.run() assert repo.deleted == [["run-free"]] + assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"] + + +def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.MonkeyPatch) -> None: + cutoff = datetime.datetime.now() + repo = FakeRepo( + batches=[ + [ + make_ref("run-free", "t_free", cutoff), + make_ref("run-paid", "t_paid", cutoff), + ] + ] + ) + cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) + + monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + billing_calls: list[list[str]] = [] + + def fake_bulk(tenant_ids: list[str]) -> dict[str, SubscriptionPlan]: + billing_calls.append(tenant_ids) + return { + "t_free": plan_info("sandbox", -1), + "t_paid": plan_info("team", -1), + } + + monkeypatch.setattr(cleanup_module.BillingService, "get_plan_bulk_with_cache", staticmethod(fake_bulk)) + + cleanup.run() + + assert billing_calls == [["t_free", "t_paid"]] + assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"] + assert repo.deleted == [["run-free"]] def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: cutoff = datetime.datetime.now() - repo = FakeRepo(batches=[[FakeRun("run-paid", "t_paid", cutoff)]]) + repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) @@ -257,6 +301,53 @@ def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None cleanup.run() assert repo.deleted == [] + assert len(repo.cleanup_ref_calls) == 2 + + +def test_run_paid_only_records_skipped_metrics(monkeypatch: pytest.MonkeyPatch) -> None: + cutoff = datetime.datetime.now() + repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) + cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) + + monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr( + cleanup_module.BillingService, + "get_plan_bulk_with_cache", + staticmethod(lambda tenant_ids: {tenant_id: plan_info("team", 1893456000) for tenant_id in tenant_ids}), + ) + batch_calls: list[dict[str, object]] = [] + monkeypatch.setattr(cleanup._metrics, "record_batch", lambda **kwargs: batch_calls.append(kwargs)) + + cleanup.run() + + assert repo.deleted == [] + assert repo.counted == [] + assert batch_calls[0]["batch_rows"] == 1 + assert batch_calls[0]["targeted_runs"] == 0 + assert batch_calls[0]["skipped_runs"] == 1 + assert batch_calls[0]["deleted_runs"] == 0 + + +def test_run_target_query_is_bounded_by_candidate_high_water(monkeypatch: pytest.MonkeyPatch) -> None: + first_created_at = datetime.datetime(2024, 1, 1, 0, 0, 0) + second_created_at = datetime.datetime(2024, 1, 1, 0, 1, 0) + repo = FakeRepo( + batches=[ + [ + make_ref("run-free-1", "t_free", first_created_at), + make_ref("run-free-2", "t_free", second_created_at), + ] + ] + ) + cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=2) + + monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) + + cleanup.run() + + assert repo.cleanup_ref_calls[1]["last_seen"] is None + assert repo.cleanup_ref_calls[1]["upper_bound"] == (second_created_at, "run-free-2") + assert repo.cleanup_ref_calls[2]["last_seen"] == (second_created_at, "run-free-2") def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None: @@ -268,7 +359,7 @@ def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None: def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None: cutoff = datetime.datetime.now() repo = FakeRepo( - batches=[[FakeRun("run-free", "t_free", cutoff)]], + batches=[[make_ref("run-free", "t_free", cutoff)]], delete_result={ "runs": 0, "node_executions": 2, @@ -300,13 +391,13 @@ def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None: class FailingRepo(FakeRepo): - def delete_runs_with_related( - self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None + def delete_runs_with_related_by_ids( + self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None ) -> dict[str, int]: raise RuntimeError("delete failed") cutoff = datetime.datetime.now() - repo = FailingRepo(batches=[[FakeRun("run-free", "t_free", cutoff)]]) + repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) @@ -323,7 +414,7 @@ def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None: def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: cutoff = datetime.datetime.now() repo = FakeRepo( - batches=[[FakeRun("run-free", "t_free", cutoff)]], + batches=[[make_ref("run-free", "t_free", cutoff)]], count_result={ "runs": 0, "node_executions": 2, From f992ede8364f50aa2c20706fefc36728b1953df2 Mon Sep 17 00:00:00 2001 From: Prajeeth Channa Date: Tue, 16 Jun 2026 23:37:09 -0400 Subject: [PATCH 04/62] test: replace logger patch with caplog in remaining test files (#37562) --- .../services/test_end_user_service.py | 20 ++++----- .../test_remove_app_and_related_data_task.py | 27 ++++++------ .../service_api/app/test_file_preview.py | 22 ++++++---- .../core/mcp/session/test_base_session.py | 15 ++++--- .../enterprise/telemetry/test_exporter.py | 42 +++++++++---------- .../test_remove_app_and_related_data_task.py | 8 ++-- 6 files changed, 66 insertions(+), 68 deletions(-) diff --git a/api/tests/test_containers_integration_tests/services/test_end_user_service.py b/api/tests/test_containers_integration_tests/services/test_end_user_service.py index 998b3378e2c..af6fb879acb 100644 --- a/api/tests/test_containers_integration_tests/services/test_end_user_service.py +++ b/api/tests/test_containers_integration_tests/services/test_end_user_service.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -from unittest.mock import patch from uuid import uuid4 import pytest @@ -259,9 +258,8 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert len(matching_logs) == 1 - @patch("services.end_user_service.logger") def test_get_existing_end_user_matching_type( - self, mock_logger, db_session_with_containers: Session, factory: TestEndUserServiceFactory + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, caplog ): """Test retrieving existing end user with matching type.""" # Arrange @@ -279,17 +277,19 @@ class TestEndUserServiceGetOrCreateEndUserByType: ) # Act - Request with same type - result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, - tenant_id=tenant_id, - app_id=app_id, - user_id=user_id, - ) + with caplog.at_level(logging.INFO, logger="services.end_user_service"): + result = EndUserService.get_or_create_end_user_by_type( + type=InvokeFrom.SERVICE_API, + tenant_id=tenant_id, + app_id=app_id, + user_id=user_id, + ) # Assert assert result.id == existing_user.id assert result.type == InvokeFrom.SERVICE_API - mock_logger.info.assert_not_called() + # No legacy-upgrade log should be emitted when the existing user's type already matches. + assert [record for record in caplog.records if record.levelno == logging.INFO] == [] def test_create_anonymous_user_with_default_session( self, db_session_with_containers: Session, factory: TestEndUserServiceFactory diff --git a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py index 204f5339785..0ec1b87f59c 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py @@ -1,5 +1,6 @@ +import logging import uuid -from unittest.mock import ANY, call, patch +from unittest.mock import call, patch import pytest from sqlalchemy import delete, func, select @@ -146,10 +147,7 @@ class TestDeleteDraftVariablesBatch: assert db_session_with_containers.scalar(select(func.count()).select_from(WorkflowDraftVariable)) == 0 @patch("tasks.remove_app_and_related_data_task._delete_draft_variable_offload_data") - @patch("tasks.remove_app_and_related_data_task.logger") - def test_delete_draft_variables_batch_logs_progress( - self, mock_logger, mock_offload_cleanup, db_session_with_containers - ): + def test_delete_draft_variables_batch_logs_progress(self, mock_offload_cleanup, db_session_with_containers, caplog): """Test that batch deletion logs progress correctly.""" tenant, app = _create_tenant_and_app(db_session_with_containers) offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=10) @@ -163,14 +161,15 @@ class TestDeleteDraftVariablesBatch: mock_offload_cleanup.return_value = len(file_id_by_index) - result = delete_draft_variables_batch(app.id, 50) + with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"): + result = delete_draft_variables_batch(app.id, 50) assert result == 30 mock_offload_cleanup.assert_called_once() _, called_file_ids = mock_offload_cleanup.call_args.args assert {str(file_id) for file_id in called_file_ids} == {str(file_id) for file_id in file_id_by_index.values()} - assert mock_logger.info.call_count == 2 - mock_logger.info.assert_any_call(ANY) + info_records = [record for record in caplog.records if record.levelno == logging.INFO] + assert len(info_records) == 2 class TestDeleteDraftVariableOffloadData: @@ -204,10 +203,7 @@ class TestDeleteDraftVariableOffloadData: assert remaining_upload_files_count == 0 @patch("extensions.ext_storage.storage") - @patch("tasks.remove_app_and_related_data_task.logging") - def test_delete_draft_variable_offload_data_storage_failure( - self, mock_logging, mock_storage, db_session_with_containers - ): + def test_delete_draft_variable_offload_data_storage_failure(self, mock_storage, db_session_with_containers, caplog): """Test handling of storage deletion failures.""" tenant, app = _create_tenant_and_app(db_session_with_containers) offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=2) @@ -217,11 +213,12 @@ class TestDeleteDraftVariableOffloadData: mock_storage.delete.side_effect = [Exception("Storage error"), None] - with session_factory.create_session() as session, session.begin(): - result = _delete_draft_variable_offload_data(session, file_ids) + with caplog.at_level(logging.ERROR): + with session_factory.create_session() as session, session.begin(): + result = _delete_draft_variable_offload_data(session, file_ids) assert result == 1 - mock_logging.exception.assert_called_once_with("Failed to delete storage object %s", storage_keys[0]) + assert f"Failed to delete storage object {storage_keys[0]}" in caplog.text remaining_var_files_count = db_session_with_containers.scalar( select(func.count()) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py b/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py index f5e8453c5cd..6fe1cbb71d2 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py @@ -2,6 +2,7 @@ Unit tests for Service API File Preview endpoint """ +import logging import uuid from unittest.mock import Mock, patch @@ -348,8 +349,7 @@ class TestFilePreviewApi: assert "Storage error" in str(exc_info.value) - @patch("controllers.service_api.app.file_preview.logger") - def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api: FilePreviewApi): + def test_validate_file_ownership_unexpected_error_logging(self, file_preview_api: FilePreviewApi, caplog): """Test that unexpected errors are logged properly""" file_id = str(uuid.uuid4()) app_id = str(uuid.uuid4()) @@ -359,14 +359,18 @@ class TestFilePreviewApi: mock_db.session.scalar.side_effect = Exception("Unexpected database error") # Execute and assert exception - with pytest.raises(FileAccessDeniedError) as exc_info: - file_preview_api._validate_file_ownership(file_id, app_id) + with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"): + with pytest.raises(FileAccessDeniedError) as exc_info: + file_preview_api._validate_file_ownership(file_id, app_id) # Verify error message assert "File access validation failed" in str(exc_info.value) - # Verify logging was called - mock_logger.exception.assert_called_once_with( - "Unexpected error during file ownership validation", - extra={"file_id": file_id, "app_id": app_id, "error": "Unexpected database error"}, - ) + # Verify logging was called with the structured context fields. The ``extra`` keys + # are attached to the LogRecord as attributes, so they are not in ``caplog.text``. + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.getMessage() == "Unexpected error during file ownership validation" + assert record.file_id == file_id + assert record.app_id == app_id + assert record.error == "Unexpected database error" diff --git a/api/tests/unit_tests/core/mcp/session/test_base_session.py b/api/tests/unit_tests/core/mcp/session/test_base_session.py index 1dd916bcf12..72155515513 100644 --- a/api/tests/unit_tests/core/mcp/session/test_base_session.py +++ b/api/tests/unit_tests/core/mcp/session/test_base_session.py @@ -1,3 +1,4 @@ +import logging import queue import time from concurrent.futures import Future, ThreadPoolExecutor @@ -511,10 +512,8 @@ def test_receive_loop_http_error_unknown_id(streams): @pytest.mark.timeout(10) -def test_receive_loop_validation_error_notification(streams): - from core.mcp.session.base_session import logger - - with patch.object(logger, "warning") as mock_warning: +def test_receive_loop_validation_error_notification(streams, caplog): + with caplog.at_level(logging.WARNING, logger="core.mcp.session.base_session"): read_stream, write_stream = streams session = MockSession(read_stream, write_stream, ReceiveRequest, RootModel[MockNotification]) @@ -523,7 +522,7 @@ def test_receive_loop_validation_error_notification(streams): read_stream.put(SessionMessage(message=JSONRPCMessage.model_validate(notif_payload))) time.sleep(1.0) - assert mock_warning.called + assert "Failed to validate notification" in caplog.text @pytest.mark.timeout(5) @@ -571,16 +570,16 @@ def test_session_exit_timeout(streams): @pytest.mark.timeout(10) -def test_receive_loop_fatal_exception(streams): +def test_receive_loop_fatal_exception(streams, caplog): read_stream, write_stream = streams session = MockSession(read_stream, write_stream, ReceiveRequest, ReceiveNotification) with patch.object(read_stream, "get", side_effect=RuntimeError("Fatal loop error")): - with patch("core.mcp.session.base_session.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="core.mcp.session.base_session"): with pytest.raises(RuntimeError, match="Fatal loop error"): with session: pass - mock_logger.exception.assert_called_with("Error in message processing loop") + assert "Error in message processing loop" in caplog.text @pytest.mark.timeout(5) diff --git a/api/tests/unit_tests/enterprise/telemetry/test_exporter.py b/api/tests/unit_tests/enterprise/telemetry/test_exporter.py index 6bdae139237..674a2026131 100644 --- a/api/tests/unit_tests/enterprise/telemetry/test_exporter.py +++ b/api/tests/unit_tests/enterprise/telemetry/test_exporter.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -88,11 +89,10 @@ def test_api_key_and_custom_headers_merge(mock_metric_exporter: MagicMock, mock_ assert ("x-custom", "foo") in headers -@patch("enterprise.telemetry.exporter.logger") @patch("enterprise.telemetry.exporter.GRPCSpanExporter") @patch("enterprise.telemetry.exporter.GRPCMetricExporter") def test_api_key_overrides_conflicting_header( - mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, mock_logger: MagicMock + mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, caplog ) -> None: """Test that API key overrides conflicting authorization header and logs warning.""" mock_config = SimpleNamespace( @@ -105,7 +105,8 @@ def test_api_key_overrides_conflicting_header( ENTERPRISE_OTLP_API_KEY="test-key", ) - EnterpriseExporter(mock_config) + with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"): + EnterpriseExporter(mock_config) # Verify Bearer header takes precedence assert mock_span_exporter.call_args is not None @@ -116,11 +117,8 @@ def test_api_key_overrides_conflicting_header( assert ("authorization", "Basic old") not in headers # Verify warning was logged - mock_logger.warning.assert_called_once() - assert mock_logger.warning.call_args is not None - warning_message = mock_logger.warning.call_args[0][0] - assert "ENTERPRISE_OTLP_API_KEY is set" in warning_message - assert "authorization" in warning_message + assert "ENTERPRISE_OTLP_API_KEY is set" in caplog.text + assert "authorization" in caplog.text @patch("enterprise.telemetry.exporter.GRPCSpanExporter") @@ -535,33 +533,33 @@ def test_export_span_cross_workflow_parent_context() -> None: assert kwargs["context"] is not None -@patch("enterprise.telemetry.exporter.logger") -def test_export_span_logs_exception_on_error(mock_logger: MagicMock) -> None: +def test_export_span_logs_exception_on_error(caplog) -> None: """If the span block raises, the exception is logged and context is still cleared.""" exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer() mock_tracer.start_as_current_span.side_effect = RuntimeError("boom") - exporter.export_span(name="bad.span", attributes={}) # must not raise + with caplog.at_level(logging.ERROR, logger="enterprise.telemetry.exporter"): + exporter.export_span(name="bad.span", attributes={}) # must not raise - mock_logger.exception.assert_called_once() - assert "bad.span" in mock_logger.exception.call_args[0][1] + assert "Failed to export span" in caplog.text + assert "bad.span" in caplog.text -@patch("enterprise.telemetry.exporter.logger") -def test_export_span_invalid_trace_correlation_logs_warning(mock_logger: MagicMock) -> None: +def test_export_span_invalid_trace_correlation_logs_warning(caplog) -> None: """Invalid UUID for trace_correlation_override triggers a warning log.""" exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer() parent_uid = "987fbc97-4bed-5078-9f07-9141ba07c9f3" - exporter.export_span( - name="link.span", - attributes={}, - correlation_id="not-a-valid-uuid", - parent_span_id_source=parent_uid, - ) + with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"): + exporter.export_span( + name="link.span", + attributes={}, + correlation_id="not-a-valid-uuid", + parent_span_id_source=parent_uid, + ) - mock_logger.warning.assert_called() + assert "Invalid trace correlation UUID for cross-workflow link" in caplog.text # --------------------------------------------------------------------------- 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 3fb673198b3..a91b61111ca 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 @@ -50,8 +50,7 @@ class TestDeleteDraftVariableOffloadData: assert result == 0 mock_conn.execute.assert_not_called() - @patch("tasks.remove_app_and_related_data_task.logging") - def test_delete_draft_variable_offload_data_database_failure(self, mock_logging): + def test_delete_draft_variable_offload_data_database_failure(self, caplog): """Test handling of database operation failures.""" mock_conn = MagicMock() file_ids = ["file-1"] @@ -60,13 +59,14 @@ class TestDeleteDraftVariableOffloadData: mock_conn.execute.side_effect = Exception("Database error") # Execute function - should not raise, but log error - result = _delete_draft_variable_offload_data(mock_conn, file_ids) + with caplog.at_level(logging.ERROR): + result = _delete_draft_variable_offload_data(mock_conn, file_ids) # Should return 0 when error occurs assert result == 0 # Verify error was logged - mock_logging.exception.assert_called_once_with("Error deleting draft variable offload data:") + assert "Error deleting draft variable offload data:" in caplog.text class TestDeleteWorkflowArchiveLogs: From e970cbde0f6048e2e5ab4021b250aa6d9b934653 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 17 Jun 2026 13:22:52 +0800 Subject: [PATCH 05/62] feat: add agent roster observability APIs (#37566) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/agent/roster.py | 126 +++++++- api/fields/agent_fields.py | 112 ++++++- api/openapi/markdown/console-openapi.md | 185 +++++++++++ api/services/agent/observability_service.py | 300 ++++++++++++++++++ ...alchemy_workflow_run_cleanup_repository.py | 2 +- .../console/agent/test_agent_controllers.py | 106 +++++++ .../agent/test_agent_observability_service.py | 123 +++++++ .../generated/api/console/agent/orpc.gen.ts | 81 ++++- .../generated/api/console/agent/types.gen.ts | 147 +++++++++ .../generated/api/console/agent/zod.gen.ts | 178 +++++++++++ 10 files changed, 1339 insertions(+), 21 deletions(-) create mode 100644 api/services/agent/observability_service.py create mode 100644 api/tests/unit_tests/services/agent/test_agent_observability_service.py diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index faa97ada0db..70aa7cc4c7b 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -1,11 +1,12 @@ from uuid import UUID -from flask import request +from flask import abort, request from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.app import ( AppDetailWithSite, AppListQuery, @@ -27,14 +28,22 @@ from fields.agent_fields import ( AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, + AgentLogListResponse, AgentPublishedReferenceResponse, AgentRosterListResponse, + AgentStatisticSummaryEnvelopeResponse, ) +from libs.datetime_utils import parse_time_range from libs.helper import dump_response from libs.login import login_required from models import Account from models.model import IconType from services.agent.errors import AgentNotFoundError +from services.agent.observability_service import ( + AgentLogQueryParams, + AgentObservabilityService, + AgentStatisticsQueryParams, +) from services.agent.roster_service import AgentRosterService from services.app_service import AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService @@ -63,11 +72,49 @@ class AgentAppUpdatePayload(UpdateAppPayload): role: str | None = Field(default=None, description="Agent role", max_length=255) +class AgentLogsQuery(BaseModel): + page: int = Field(default=1, ge=1, description="Page number") + limit: int = Field(default=20, ge=1, le=100, description="Page size") + keyword: str | None = Field(default=None, description="Search query, answer, or conversation name") + status: str | None = Field(default=None, description="Filter by success, failed, or paused") + source: str | None = Field( + default=None, + description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger", + ) + start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") + end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)") + + @field_validator("keyword", "status", "source", "start", "end", mode="before") + @classmethod + def empty_string_to_none(cls, value: str | None) -> str | None: + if value == "": + return None + return value + + +class AgentStatisticsQuery(BaseModel): + source: str | None = Field( + default=None, + description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger", + ) + start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") + end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)") + + @field_validator("source", "start", "end", mode="before") + @classmethod + def empty_string_to_none(cls, value: str | None) -> str | None: + if value == "": + return None + return value + + register_schema_models( console_ns, AgentAppCreatePayload, AgentAppUpdatePayload, AgentInviteOptionsQuery, + AgentLogsQuery, + AgentStatisticsQuery, AgentIdPath, AppListQuery, UpdateAppPayload, @@ -80,8 +127,10 @@ register_response_schema_models( AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, + AgentLogListResponse, AgentPublishedReferenceResponse, AgentRosterListResponse, + AgentStatisticSummaryEnvelopeResponse, ) @@ -136,7 +185,19 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID): - return _agent_roster_service().get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) + return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + + +def _agent_observability_service() -> AgentObservabilityService: + return AgentObservabilityService(db.session) + + +def _parse_observability_time_range(start: str | None, end: str | None, account: Account): + timezone = account.timezone or "UTC" + try: + return parse_time_range(start, end, timezone) + except ValueError as exc: + abort(400, description=str(exc)) @console_ns.route("/agent") @@ -267,6 +328,65 @@ class AgentInviteOptionsApi(Resource): ) +@console_ns.route("/agent//logs") +class AgentLogsApi(Resource): + @console_ns.doc(params=query_params_from_model(AgentLogsQuery)) + @console_ns.response(200, "Agent logs", console_ns.models[AgentLogListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True)) + start, end = _parse_observability_time_range(query.start, query.end, current_user) + try: + payload = _agent_observability_service().list_logs( + app=app_model, + params=AgentLogQueryParams( + page=query.page, + limit=query.limit, + keyword=query.keyword, + status=query.status, + source=query.source, + start=start, + end=end, + ), + ) + except ValueError as exc: + abort(400, description=str(exc)) + return dump_response(AgentLogListResponse, payload) + + +@console_ns.route("/agent//statistics/summary") +class AgentStatisticsSummaryApi(Resource): + @console_ns.doc(params=query_params_from_model(AgentStatisticsQuery)) + @console_ns.response( + 200, + "Agent monitoring summary and chart data", + console_ns.models[AgentStatisticSummaryEnvelopeResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True)) + timezone = current_user.timezone or "UTC" + start, end = _parse_observability_time_range(query.start, query.end, current_user) + try: + payload = _agent_observability_service().get_statistics_summary( + app=app_model, + params=AgentStatisticsQueryParams(source=query.source, start=start, end=end, timezone=timezone), + ) + except ValueError as exc: + abort(400, description=str(exc)) + return dump_response(AgentStatisticSummaryEnvelopeResponse, payload) + + @console_ns.route("/agent//versions") class AgentRosterVersionsApi(Resource): @console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__]) diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index 36d96231987..724e5ecf7db 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -1,8 +1,10 @@ +from datetime import datetime from typing import Annotated, Literal -from pydantic import Field +from pydantic import Field, field_validator from fields.base import ResponseModel +from libs.helper import to_timestamp from models.agent import ( AgentConfigRevisionOperation, AgentIconType, @@ -105,6 +107,114 @@ class AgentInviteOptionsResponse(ResponseModel): has_more: bool +class AgentLogItemResponse(ResponseModel): + id: str + message_id: str + conversation_id: str + conversation_name: str | None = None + query: str + answer: str + status: str + error: str | None = None + source: str | None = None + from_source: str | None = None + from_end_user_id: str | None = None + from_account_id: str | None = None + message_tokens: int + answer_tokens: int + total_tokens: int + total_price: str + currency: str + latency: float + created_at: int | None = None + updated_at: int | None = None + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class AgentLogListResponse(ResponseModel): + data: list[AgentLogItemResponse] + page: int + limit: int + total: int + has_more: bool + + +class AgentStatisticSummaryResponse(ResponseModel): + total_messages: int + total_conversations: int + total_end_users: int + total_tokens: int + total_price: str + currency: str + average_session_interactions: float + average_response_time: float + tokens_per_second: float + user_satisfaction_rate: float + + +class AgentDailyMessageStatisticResponse(ResponseModel): + date: str + message_count: int + + +class AgentDailyConversationStatisticResponse(ResponseModel): + date: str + conversation_count: int + + +class AgentDailyEndUserStatisticResponse(ResponseModel): + date: str + terminal_count: int + + +class AgentTokenUsageStatisticResponse(ResponseModel): + date: str + token_count: int + total_price: str + currency: str + + +class AgentAverageSessionInteractionStatisticResponse(ResponseModel): + date: str + interactions: float + + +class AgentAverageResponseTimeStatisticResponse(ResponseModel): + date: str + latency: float + + +class AgentTokensPerSecondStatisticResponse(ResponseModel): + date: str + tps: float + + +class AgentUserSatisfactionRateStatisticResponse(ResponseModel): + date: str + rate: float + + +class AgentStatisticChartsResponse(ResponseModel): + daily_messages: list[AgentDailyMessageStatisticResponse] = Field(default_factory=list) + daily_conversations: list[AgentDailyConversationStatisticResponse] = Field(default_factory=list) + daily_end_users: list[AgentDailyEndUserStatisticResponse] = Field(default_factory=list) + token_usage: list[AgentTokenUsageStatisticResponse] = Field(default_factory=list) + average_session_interactions: list[AgentAverageSessionInteractionStatisticResponse] = Field(default_factory=list) + average_response_time: list[AgentAverageResponseTimeStatisticResponse] = Field(default_factory=list) + tokens_per_second: list[AgentTokensPerSecondStatisticResponse] = Field(default_factory=list) + user_satisfaction_rate: list[AgentUserSatisfactionRateStatisticResponse] = Field(default_factory=list) + + +class AgentStatisticSummaryEnvelopeResponse(ResponseModel): + source: str + summary: AgentStatisticSummaryResponse + charts: AgentStatisticChartsResponse + + class AgentConfigRevisionResponse(ResponseModel): id: str previous_snapshot_id: str | None = None diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 2f2e8ae15bd..925d4bd84af 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -638,6 +638,26 @@ Commit an uploaded file into the Agent App drive under files/ | ---- | ----------- | ------ | | 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
| +### [GET] /agent/{agent_id}/logs +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| keyword | query | Search query, answer, or conversation name | No | string | +| limit | query | Page size | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | +| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | +| status | query | Filter by success, failed, or paused | No | string | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent logs | **application/json**: [AgentLogListResponse](#agentloglistresponse)
| + ### [GET] /agent/{agent_id}/messages/{message_id} Get Agent App message details by ID @@ -790,6 +810,22 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill | ---- | ----------- | ------ | | 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
| +### [GET] /agent/{agent_id}/statistics/summary +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent monitoring summary and chart data | **application/json**: [AgentStatisticSummaryEnvelopeResponse](#agentstatisticsummaryenveloperesponse)
| + ### [GET] /agent/{agent_id}/versions #### Parameters @@ -11318,6 +11354,20 @@ default (the config form sends the full desired feature state on save). | role | string | Agent role | No | | use_icon_as_answer_icon | boolean | Use icon as answer icon | No | +#### AgentAverageResponseTimeStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| latency | number | | Yes | + +#### AgentAverageSessionInteractionStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| interactions | number | | Yes | + #### AgentCliToolAuthorizationStatus Authorization state for Agent-scoped CLI tools. @@ -11558,6 +11608,27 @@ Audit operation recorded for Agent Soul version/revision changes. | version | integer | | Yes | | version_note | string | | No | +#### AgentDailyConversationStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_count | integer | | Yes | +| date | string | | Yes | + +#### AgentDailyEndUserStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| terminal_count | integer | | Yes | + +#### AgentDailyMessageStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| message_count | integer | | Yes | + #### AgentDriveDeleteFileByAgentQuery | Name | Type | Description | Required | @@ -11797,6 +11868,41 @@ the current roster/workflow APIs scoped to Dify Agent. | ---- | ---- | ----------- | -------- | | AgentKnowledgeQueryMode | string | | | +#### AgentLogItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | Yes | +| answer_tokens | integer | | Yes | +| conversation_id | string | | Yes | +| conversation_name | string | | No | +| created_at | integer | | No | +| currency | string | | Yes | +| error | string | | No | +| from_account_id | string | | No | +| from_end_user_id | string | | No | +| from_source | string | | No | +| id | string | | Yes | +| latency | number | | Yes | +| message_id | string | | Yes | +| message_tokens | integer | | Yes | +| query | string | | Yes | +| source | string | | No | +| status | string | | Yes | +| total_price | string | | Yes | +| total_tokens | integer | | Yes | +| updated_at | integer | | No | + +#### AgentLogListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AgentLogItemResponse](#agentlogitemresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + #### AgentLogMetaResponse | Name | Type | Description | Required | @@ -11824,6 +11930,18 @@ the current roster/workflow APIs scoped to Dify Agent. | iterations | [ [AgentIterationLogResponse](#agentiterationlogresponse) ] | | Yes | | meta | [AgentLogMetaResponse](#agentlogmetaresponse) | | Yes | +#### AgentLogsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| keyword | string | Search query, answer, or conversation name | No | +| limit | integer,
**Default:** 20 | Page size | No | +| page | integer,
**Default:** 1 | Page number | No | +| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | +| status | string | Filter by success, failed, or paused | No | + #### AgentMemoryArtifactConfig | Name | Type | Description | Required | @@ -12197,6 +12315,50 @@ Origin that created or imported the Agent. | ---- | ---- | ----------- | -------- | | AgentSource | string | Origin that created or imported the Agent. | | +#### AgentStatisticChartsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| average_response_time | [ [AgentAverageResponseTimeStatisticResponse](#agentaverageresponsetimestatisticresponse) ] | | No | +| average_session_interactions | [ [AgentAverageSessionInteractionStatisticResponse](#agentaveragesessioninteractionstatisticresponse) ] | | No | +| daily_conversations | [ [AgentDailyConversationStatisticResponse](#agentdailyconversationstatisticresponse) ] | | No | +| daily_end_users | [ [AgentDailyEndUserStatisticResponse](#agentdailyenduserstatisticresponse) ] | | No | +| daily_messages | [ [AgentDailyMessageStatisticResponse](#agentdailymessagestatisticresponse) ] | | No | +| token_usage | [ [AgentTokenUsageStatisticResponse](#agenttokenusagestatisticresponse) ] | | No | +| tokens_per_second | [ [AgentTokensPerSecondStatisticResponse](#agenttokenspersecondstatisticresponse) ] | | No | +| user_satisfaction_rate | [ [AgentUserSatisfactionRateStatisticResponse](#agentusersatisfactionratestatisticresponse) ] | | No | + +#### AgentStatisticSummaryEnvelopeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| charts | [AgentStatisticChartsResponse](#agentstatisticchartsresponse) | | Yes | +| source | string | | Yes | +| summary | [AgentStatisticSummaryResponse](#agentstatisticsummaryresponse) | | Yes | + +#### AgentStatisticSummaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| average_response_time | number | | Yes | +| average_session_interactions | number | | Yes | +| currency | string | | Yes | +| tokens_per_second | number | | Yes | +| total_conversations | integer | | Yes | +| total_end_users | integer | | Yes | +| total_messages | integer | | Yes | +| total_price | string | | Yes | +| total_tokens | integer | | Yes | +| user_satisfaction_rate | number | | Yes | + +#### AgentStatisticsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | + #### AgentStatus Soft lifecycle state for Agent records. @@ -12239,6 +12401,22 @@ Soft lifecycle state for Agent records. | tool_input | string | | No | | tool_labels | [JSONValue](#jsonvalue) | | Yes | +#### AgentTokenUsageStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| currency | string | | Yes | +| date | string | | Yes | +| token_count | integer | | Yes | +| total_price | string | | Yes | + +#### AgentTokensPerSecondStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| tps | number | | Yes | + #### AgentToolCallResponse | Name | Type | Description | Required | @@ -12253,6 +12431,13 @@ Soft lifecycle state for Agent records. | tool_output | object | | Yes | | tool_parameters | object | | Yes | +#### AgentUserSatisfactionRateStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| rate | number | | Yes | + #### AllowedExtensionsResponse | Name | Type | Description | Required | diff --git a/api/services/agent/observability_service.py b/api/services/agent/observability_service.py new file mode 100644 index 00000000000..a150f70d7f4 --- /dev/null +++ b/api/services/agent/observability_service.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Any + +import sqlalchemy as sa +from sqlalchemy import func, or_, select + +from core.app.entities.app_invoke_entities import InvokeFrom +from libs.helper import convert_datetime_to_date, escape_like_pattern, to_timestamp +from models.enums import MessageStatus +from models.model import App, Conversation, Message + + +@dataclass(frozen=True) +class AgentLogQueryParams: + page: int = 1 + limit: int = 20 + keyword: str | None = None + status: str | None = None + source: str | None = None + start: datetime | None = None + end: datetime | None = None + + +@dataclass(frozen=True) +class AgentStatisticsQueryParams: + source: str | None = None + start: datetime | None = None + end: datetime | None = None + timezone: str = "UTC" + + +class AgentObservabilityService: + _SOURCE_ALIASES: dict[str, InvokeFrom] = { + "api": InvokeFrom.SERVICE_API, + "service-api": InvokeFrom.SERVICE_API, + "service_api": InvokeFrom.SERVICE_API, + "console": InvokeFrom.EXPLORE, + "explore": InvokeFrom.EXPLORE, + "explore-app": InvokeFrom.EXPLORE, + "explore_app": InvokeFrom.EXPLORE, + "web": InvokeFrom.WEB_APP, + "web-app": InvokeFrom.WEB_APP, + "web_app": InvokeFrom.WEB_APP, + "debugger": InvokeFrom.DEBUGGER, + "dev": InvokeFrom.DEBUGGER, + "openapi": InvokeFrom.OPENAPI, + "trigger": InvokeFrom.TRIGGER, + } + + def __init__(self, session: Any): + self._session = session + + @classmethod + def resolve_source(cls, source: str | None) -> InvokeFrom | None: + if not source or source == "all": + return None + normalized = source.strip().lower() + if not normalized or normalized == "all": + return None + try: + return cls._SOURCE_ALIASES[normalized] + except KeyError as exc: + raise ValueError(f"Unsupported source: {source}") from exc + + @staticmethod + def _message_status(message: Message) -> str: + if message.error or message.status == MessageStatus.ERROR: + return "failed" + if message.status == MessageStatus.PAUSED: + return "paused" + return "success" + + @staticmethod + def _total_tokens(message: Message) -> int: + return int(message.message_tokens or 0) + int(message.answer_tokens or 0) + + @classmethod + def serialize_log_message(cls, message: Message, conversation: Conversation | None = None) -> dict[str, Any]: + invoke_from = message.invoke_from.value if message.invoke_from else None + return { + "id": message.id, + "message_id": message.id, + "conversation_id": message.conversation_id, + "conversation_name": conversation.name if conversation else None, + "query": message.query, + "answer": message.answer, + "status": cls._message_status(message), + "error": message.error, + "source": invoke_from, + "from_source": message.from_source.value if message.from_source else None, + "from_end_user_id": message.from_end_user_id, + "from_account_id": message.from_account_id, + "message_tokens": int(message.message_tokens or 0), + "answer_tokens": int(message.answer_tokens or 0), + "total_tokens": cls._total_tokens(message), + "total_price": str(message.total_price or Decimal(0)), + "currency": message.currency, + "latency": float(message.provider_response_latency or 0), + "created_at": to_timestamp(message.created_at), + "updated_at": to_timestamp(message.updated_at), + } + + def list_logs(self, *, app: App, params: AgentLogQueryParams) -> dict[str, Any]: + source = self.resolve_source(params.source) + stmt = ( + select(Message, Conversation) + .join(Conversation, Conversation.id == Message.conversation_id) + .where(Message.app_id == app.id, Conversation.app_id == app.id) + ) + stmt = self._apply_source_filter(stmt, source) + + if params.start: + stmt = stmt.where(Message.created_at >= params.start) + if params.end: + stmt = stmt.where(Message.created_at < params.end) + if params.keyword: + escaped_keyword = escape_like_pattern(params.keyword) + pattern = f"%{escaped_keyword}%" + stmt = stmt.where( + or_( + Message.query.ilike(pattern, escape="\\"), + Message.answer.ilike(pattern, escape="\\"), + Conversation.name.ilike(pattern, escape="\\"), + ) + ) + if params.status: + stmt = self._apply_status_filter(stmt, params.status) + + total = self._session.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + rows = list( + self._session.execute( + stmt.order_by(Message.created_at.desc(), Message.id.desc()) + .offset((params.page - 1) * params.limit) + .limit(params.limit) + ).all() + ) + data = [] + for message, conversation in rows: + data.append(self.serialize_log_message(message, conversation)) + return { + "data": data, + "page": params.page, + "limit": params.limit, + "total": total, + "has_more": params.page * params.limit < total, + } + + @classmethod + def _apply_source_filter(cls, stmt, source: InvokeFrom | None): + if source is None: + return stmt.where(Message.invoke_from != InvokeFrom.DEBUGGER) + return stmt.where(Message.invoke_from == source) + + @staticmethod + def _apply_status_filter(stmt, status: str): + normalized = status.strip().lower() + if normalized in {"success", "normal"}: + return stmt.where(Message.error.is_(None), Message.status == MessageStatus.NORMAL) + if normalized in {"failed", "error"}: + return stmt.where(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR)) + if normalized == "paused": + return stmt.where(Message.status == MessageStatus.PAUSED) + raise ValueError(f"Unsupported status: {status}") + + def get_statistics_summary(self, *, app: App, params: AgentStatisticsQueryParams) -> dict[str, Any]: + source = self.resolve_source(params.source) + rows = self._load_daily_statistics(app=app, params=params, source=source) + charts = self._build_charts(rows) + summary = self._build_summary(rows) + return { + "source": source.value if source else "all", + "summary": summary, + "charts": charts, + } + + def _load_daily_statistics( + self, *, app: App, params: AgentStatisticsQueryParams, source: InvokeFrom | None + ) -> list[dict[str, Any]]: + converted_created_at = convert_datetime_to_date("m.created_at") + source_condition = "AND m.invoke_from != :debugger" if source is None else "AND m.invoke_from = :source" + sql_query = f"""SELECT + {converted_created_at} AS date, + COUNT(m.id) AS message_count, + COUNT(DISTINCT m.conversation_id) AS conversation_count, + COUNT(DISTINCT m.from_end_user_id) AS end_user_count, + COALESCE(SUM(COALESCE(m.message_tokens, 0) + COALESCE(m.answer_tokens, 0)), 0) AS token_count, + COALESCE(SUM(COALESCE(m.total_price, 0)), 0) AS total_price, + COALESCE(AVG(m.provider_response_latency), 0) AS avg_latency, + COALESCE(SUM(m.provider_response_latency), 0) AS latency_sum, + COALESCE(SUM(m.answer_tokens), 0) AS answer_tokens, + COUNT(mf.id) AS like_count +FROM messages m +LEFT JOIN message_feedbacks mf + ON mf.message_id = m.id AND mf.rating = 'like' +WHERE + m.app_id = :app_id + {source_condition}""" + args: dict[str, Any] = { + "tz": params.timezone, + "app_id": app.id, + "debugger": InvokeFrom.DEBUGGER, + } + if source is not None: + args["source"] = source + if params.start: + sql_query += " AND m.created_at >= :start" + args["start"] = params.start + if params.end: + sql_query += " AND m.created_at < :end" + args["end"] = params.end + sql_query += " GROUP BY date ORDER BY date" + + return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()] + + @staticmethod + def _build_charts(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + messages = [] + conversations = [] + end_users = [] + token_usage = [] + average_session_interactions = [] + average_response_time = [] + tokens_per_second = [] + user_satisfaction_rate = [] + + for row in rows: + date = str(row["date"]) + message_count = int(row["message_count"] or 0) + conversation_count = int(row["conversation_count"] or 0) + token_count = int(row["token_count"] or 0) + total_price = row["total_price"] or Decimal(0) + avg_latency = float(row["avg_latency"] or 0) + latency_sum = float(row["latency_sum"] or 0) + answer_tokens = int(row["answer_tokens"] or 0) + like_count = int(row["like_count"] or 0) + + messages.append({"date": date, "message_count": message_count}) + conversations.append({"date": date, "conversation_count": conversation_count}) + end_users.append({"date": date, "terminal_count": int(row["end_user_count"] or 0)}) + token_usage.append( + { + "date": date, + "token_count": token_count, + "total_price": str(total_price), + "currency": "USD", + } + ) + average_session_interactions.append( + { + "date": date, + "interactions": round(message_count / conversation_count, 2) if conversation_count else 0, + } + ) + average_response_time.append({"date": date, "latency": round(avg_latency * 1000, 4)}) + tokens_per_second.append({"date": date, "tps": round(answer_tokens / latency_sum, 4) if latency_sum else 0}) + user_satisfaction_rate.append( + {"date": date, "rate": round(like_count * 100 / message_count, 2) if message_count else 0} + ) + + return { + "daily_messages": messages, + "daily_conversations": conversations, + "daily_end_users": end_users, + "token_usage": token_usage, + "average_session_interactions": average_session_interactions, + "average_response_time": average_response_time, + "tokens_per_second": tokens_per_second, + "user_satisfaction_rate": user_satisfaction_rate, + } + + @staticmethod + def _build_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: + total_messages = sum(int(row["message_count"] or 0) for row in rows) + total_conversations = sum(int(row["conversation_count"] or 0) for row in rows) + total_end_users = sum(int(row["end_user_count"] or 0) for row in rows) + total_tokens = sum(int(row["token_count"] or 0) for row in rows) + total_price = sum(Decimal(str(row["total_price"] or 0)) for row in rows) + total_answer_tokens = sum(int(row["answer_tokens"] or 0) for row in rows) + total_latency = sum(float(row["latency_sum"] or 0) for row in rows) + weighted_latency = sum(float(row["avg_latency"] or 0) * int(row["message_count"] or 0) for row in rows) + total_likes = sum(int(row["like_count"] or 0) for row in rows) + + return { + "total_messages": total_messages, + "total_conversations": total_conversations, + "total_end_users": total_end_users, + "total_tokens": total_tokens, + "total_price": str(total_price), + "currency": "USD", + "average_session_interactions": round(total_messages / total_conversations, 2) + if total_conversations + else 0, + "average_response_time": round((weighted_latency / total_messages) * 1000, 4) if total_messages else 0, + "tokens_per_second": round(total_answer_tokens / total_latency, 4) if total_latency else 0, + "user_satisfaction_rate": round(total_likes * 100 / total_messages, 2) if total_messages else 0, + } diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py index a2834dc80aa..12268851713 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py @@ -87,7 +87,7 @@ def _add_app_log(session: Session, scope: _TestScope, workflow_run: WorkflowRun) session.commit() -def _add_pause_with_reason(session: Session, scope: _TestScope, workflow_run: WorkflowRun) -> WorkflowPause: +def _add_pause_with_reason(session: Session, _scope: _TestScope, workflow_run: WorkflowRun) -> WorkflowPause: pause = WorkflowPause( workflow_id=workflow_run.workflow_id, workflow_run_id=workflow_run.id, diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 91b644b1c75..429c3d0e2ea 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -23,8 +23,10 @@ from controllers.console.agent.roster import ( AgentAppApi, AgentAppListApi, AgentInviteOptionsApi, + AgentLogsApi, AgentRosterVersionDetailApi, AgentRosterVersionsApi, + AgentStatisticsSummaryApi, ) from controllers.console.app import completion as completion_controller from controllers.console.app import message as message_controller @@ -148,6 +150,8 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//feedbacks", "/agent//chat-messages//suggested-questions", "/agent//messages/", + "/agent//logs", + "/agent//statistics/summary", "/agent/invite-options", ): assert route in paths @@ -371,6 +375,108 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc assert version_detail["agent_id"] == agent_id +def test_agent_observability_routes_resolve_app_from_agent_id( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = SimpleNamespace(id="app-1") + captured: dict[str, object] = {} + + class FakeObservabilityService: + def list_logs(self, *, app, params): + captured["logs"] = {"app": app, "params": params} + return { + "data": [ + { + "id": "message-1", + "message_id": "message-1", + "conversation_id": "conversation-1", + "conversation_name": "Debug", + "query": "hello", + "answer": "hi", + "status": "success", + "error": None, + "source": "explore", + "from_source": "console", + "from_end_user_id": None, + "from_account_id": account_id, + "message_tokens": 1, + "answer_tokens": 2, + "total_tokens": 3, + "total_price": "0", + "currency": "USD", + "latency": 1.2, + "created_at": 1, + "updated_at": 2, + } + ], + "page": 2, + "limit": 5, + "total": 6, + "has_more": False, + } + + def get_statistics_summary(self, *, app, params): + captured["statistics"] = {"app": app, "params": params} + return { + "source": "all", + "summary": { + "total_messages": 1, + "total_conversations": 1, + "total_end_users": 1, + "total_tokens": 3, + "total_price": "0", + "currency": "USD", + "average_session_interactions": 1, + "average_response_time": 1200, + "tokens_per_second": 2, + "user_satisfaction_rate": 100, + }, + "charts": { + "daily_messages": [{"date": "2026-06-17", "message_count": 1}], + "daily_conversations": [{"date": "2026-06-17", "conversation_count": 1}], + "daily_end_users": [{"date": "2026-06-17", "terminal_count": 1}], + "token_usage": [{"date": "2026-06-17", "token_count": 3, "total_price": "0", "currency": "USD"}], + "average_session_interactions": [{"date": "2026-06-17", "interactions": 1}], + "average_response_time": [{"date": "2026-06-17", "latency": 1200}], + "tokens_per_second": [{"date": "2026-06-17", "tps": 2}], + "user_satisfaction_rate": [{"date": "2026-06-17", "rate": 100}], + }, + } + + monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model) + monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda: FakeObservabilityService()) + + account = SimpleNamespace(id=account_id, timezone="UTC") + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/logs" + "?page=2&limit=5&keyword=hello&status=success&source=console" + ): + logs = unwrap(AgentLogsApi.get)(AgentLogsApi(), "tenant-1", account, agent_id) + + assert logs["data"][0]["id"] == "message-1" + logs_call = cast(dict[str, object], captured["logs"]) + assert logs_call["app"] is app_model + logs_params = cast(Any, logs_call["params"]) + assert logs_params.page == 2 + assert logs_params.limit == 5 + assert logs_params.keyword == "hello" + assert logs_params.status == "success" + assert logs_params.source == "console" + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/statistics/summary?source=api" + ): + statistics = unwrap(AgentStatisticsSummaryApi.get)(AgentStatisticsSummaryApi(), "tenant-1", account, agent_id) + + assert statistics["summary"]["total_messages"] == 1 + stats_call = cast(dict[str, object], captured["statistics"]) + assert stats_call["app"] is app_model + stats_params = cast(Any, stats_call["params"]) + assert stats_params.source == "api" + assert stats_params.timezone == "UTC" + + def test_workflow_composer_get_put_validate_candidates_impact_and_save( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: diff --git a/api/tests/unit_tests/services/agent/test_agent_observability_service.py b/api/tests/unit_tests/services/agent/test_agent_observability_service.py new file mode 100644 index 00000000000..1ce8edad788 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_agent_observability_service.py @@ -0,0 +1,123 @@ +from datetime import UTC, datetime +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from core.app.entities.app_invoke_entities import InvokeFrom +from models.enums import ConversationFromSource, MessageStatus +from services.agent.observability_service import AgentObservabilityService + + +def test_resolve_source_accepts_frontend_aliases() -> None: + assert AgentObservabilityService.resolve_source(None) is None + assert AgentObservabilityService.resolve_source("all") is None + assert AgentObservabilityService.resolve_source("console") == InvokeFrom.EXPLORE + assert AgentObservabilityService.resolve_source("api") == InvokeFrom.SERVICE_API + assert AgentObservabilityService.resolve_source("web_app") == InvokeFrom.WEB_APP + + with pytest.raises(ValueError, match="Unsupported source"): + AgentObservabilityService.resolve_source("unknown") + + +def test_serialize_log_message_returns_frontend_log_shape() -> None: + created_at = datetime(2026, 6, 17, 1, 2, 3, tzinfo=UTC) + updated_at = datetime(2026, 6, 17, 1, 3, 3, tzinfo=UTC) + message = SimpleNamespace( + id="message-1", + conversation_id="conversation-1", + query="hello", + answer="hi", + error=None, + status=MessageStatus.NORMAL, + invoke_from=InvokeFrom.EXPLORE, + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id="account-1", + message_tokens=3, + answer_tokens=4, + total_price=Decimal("0.0001"), + currency="USD", + provider_response_latency=1.25, + created_at=created_at, + updated_at=updated_at, + ) + conversation = SimpleNamespace(name="Debug conversation") + + payload = AgentObservabilityService.serialize_log_message(message, conversation) # type: ignore[arg-type] + + assert payload == { + "id": "message-1", + "message_id": "message-1", + "conversation_id": "conversation-1", + "conversation_name": "Debug conversation", + "query": "hello", + "answer": "hi", + "status": "success", + "error": None, + "source": "explore", + "from_source": "console", + "from_end_user_id": None, + "from_account_id": "account-1", + "message_tokens": 3, + "answer_tokens": 4, + "total_tokens": 7, + "total_price": "0.0001", + "currency": "USD", + "latency": 1.25, + "created_at": int(created_at.timestamp()), + "updated_at": int(updated_at.timestamp()), + } + + +def test_build_charts_and_summary_match_monitoring_metrics() -> None: + rows = [ + { + "date": "2026-06-16", + "message_count": 2, + "conversation_count": 1, + "end_user_count": 1, + "token_count": 30, + "total_price": Decimal("0.003"), + "avg_latency": 1.5, + "latency_sum": 3, + "answer_tokens": 12, + "like_count": 1, + }, + { + "date": "2026-06-17", + "message_count": 1, + "conversation_count": 1, + "end_user_count": 1, + "token_count": 20, + "total_price": Decimal("0.002"), + "avg_latency": 2, + "latency_sum": 2, + "answer_tokens": 8, + "like_count": 1, + }, + ] + + charts = AgentObservabilityService._build_charts(rows) + summary = AgentObservabilityService._build_summary(rows) + + assert charts["token_usage"] == [ + {"date": "2026-06-16", "token_count": 30, "total_price": "0.003", "currency": "USD"}, + {"date": "2026-06-17", "token_count": 20, "total_price": "0.002", "currency": "USD"}, + ] + assert charts["average_response_time"] == [ + {"date": "2026-06-16", "latency": 1500.0}, + {"date": "2026-06-17", "latency": 2000.0}, + ] + assert summary == { + "total_messages": 3, + "total_conversations": 2, + "total_end_users": 2, + "total_tokens": 50, + "total_price": "0.005", + "currency": "USD", + "average_session_interactions": 1.5, + "average_response_time": 1666.6667, + "tokens_per_second": 4.0, + "user_satisfaction_rate": 66.67, + } diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index b749f644532..ba01699e2bd 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -29,6 +29,9 @@ import { zGetAgentByAgentIdDriveFilesPreviewResponse, zGetAgentByAgentIdDriveFilesQuery, zGetAgentByAgentIdDriveFilesResponse, + zGetAgentByAgentIdLogsPath, + zGetAgentByAgentIdLogsQuery, + zGetAgentByAgentIdLogsResponse, zGetAgentByAgentIdMessagesByMessageIdPath, zGetAgentByAgentIdMessagesByMessageIdResponse, zGetAgentByAgentIdPath, @@ -41,6 +44,9 @@ import { zGetAgentByAgentIdSandboxFilesReadQuery, zGetAgentByAgentIdSandboxFilesReadResponse, zGetAgentByAgentIdSandboxFilesResponse, + zGetAgentByAgentIdStatisticsSummaryPath, + zGetAgentByAgentIdStatisticsSummaryQuery, + zGetAgentByAgentIdStatisticsSummaryResponse, zGetAgentByAgentIdVersionsByVersionIdPath, zGetAgentByAgentIdVersionsByVersionIdResponse, zGetAgentByAgentIdVersionsPath, @@ -391,10 +397,27 @@ export const files2 = { post: post5, } +export const get9 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdLogs', + path: '/agent/{agent_id}/logs', + tags: ['console'], + }) + .input( + z.object({ params: zGetAgentByAgentIdLogsPath, query: zGetAgentByAgentIdLogsQuery.optional() }), + ) + .output(zGetAgentByAgentIdLogsResponse) + +export const logs = { + get: get9, +} + /** * Get Agent App message details by ID */ -export const get9 = oc +export const get10 = oc .route({ description: 'Get Agent App message details by ID', inputStructure: 'detailed', @@ -407,7 +430,7 @@ export const get9 = oc .output(zGetAgentByAgentIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get9, + get: get10, } export const messages = { @@ -417,7 +440,7 @@ export const messages = { /** * List workflow apps that reference this Agent App's bound Agent (read-only) */ -export const get10 = oc +export const get11 = oc .route({ description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', inputStructure: 'detailed', @@ -430,13 +453,13 @@ export const get10 = oc .output(zGetAgentByAgentIdReferencingWorkflowsResponse) export const referencingWorkflows = { - get: get10, + get: get11, } /** * Read a text/binary preview file in an Agent App conversation sandbox */ -export const get11 = oc +export const get12 = oc .route({ description: 'Read a text/binary preview file in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -454,7 +477,7 @@ export const get11 = oc .output(zGetAgentByAgentIdSandboxFilesReadResponse) export const read = { - get: get11, + get: get12, } /** @@ -484,7 +507,7 @@ export const upload = { /** * List a directory in an Agent App conversation sandbox */ -export const get12 = oc +export const get13 = oc .route({ description: 'List a directory in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -502,7 +525,7 @@ export const get12 = oc .output(zGetAgentByAgentIdSandboxFilesResponse) export const files3 = { - get: get12, + get: get13, read, upload, } @@ -596,7 +619,31 @@ export const skills = { bySlug, } -export const get13 = oc +export const get14 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdStatisticsSummary', + path: '/agent/{agent_id}/statistics/summary', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdStatisticsSummaryPath, + query: zGetAgentByAgentIdStatisticsSummaryQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdStatisticsSummaryResponse) + +export const summary = { + get: get14, +} + +export const statistics = { + summary, +} + +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -608,10 +655,10 @@ export const get13 = oc .output(zGetAgentByAgentIdVersionsByVersionIdResponse) export const byVersionId = { - get: get13, + get: get15, } -export const get14 = oc +export const get16 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -623,7 +670,7 @@ export const get14 = oc .output(zGetAgentByAgentIdVersionsResponse) export const versions = { - get: get14, + get: get16, byVersionId, } @@ -639,7 +686,7 @@ export const delete3 = oc .input(z.object({ params: zDeleteAgentByAgentIdPath })) .output(zDeleteAgentByAgentIdResponse) -export const get15 = oc +export const get17 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -663,7 +710,7 @@ export const put2 = oc export const byAgentId = { delete: delete3, - get: get15, + get: get17, put: put2, chatMessages, composer, @@ -671,14 +718,16 @@ export const byAgentId = { features, feedbacks, files: files2, + logs, messages, referencingWorkflows, sandbox, skills, + statistics, versions, } -export const get16 = oc +export const get18 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -702,7 +751,7 @@ export const post10 = oc .output(zPostAgentResponse) export const agent = { - get: get16, + get: get18, post: post10, inviteOptions, byAgentId, diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 2f7d2ee0af8..5d123216d7c 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -169,6 +169,14 @@ export type AgentDriveFileCommitResponse = { file: AgentDriveFileResponse } +export type AgentLogListResponse = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + export type MessageDetailResponse = { agent_thoughts?: Array annotation?: ConversationAnnotation | null @@ -242,6 +250,12 @@ export type SkillToolInferenceResult = { reason?: string | null } +export type AgentStatisticSummaryEnvelopeResponse = { + charts: AgentStatisticChartsResponse + source: string + summary: AgentStatisticSummaryResponse +} + export type AgentConfigSnapshotListResponse = { data: Array } @@ -530,6 +544,29 @@ export type AgentDriveFileResponse = { size?: number | null } +export type AgentLogItemResponse = { + answer: string + answer_tokens: number + conversation_id: string + conversation_name?: string | null + created_at?: number | null + currency: string + error?: string | null + from_account_id?: string | null + from_end_user_id?: string | null + from_source?: string | null + id: string + latency: number + message_id: string + message_tokens: number + query: string + source?: string | null + status: string + total_price: string + total_tokens: number + updated_at?: number | null +} + export type AgentThought = { chain_id?: string | null created_at?: number | null @@ -644,6 +681,30 @@ export type CliToolSuggestion = { name: string } +export type AgentStatisticChartsResponse = { + average_response_time?: Array + average_session_interactions?: Array + daily_conversations?: Array + daily_end_users?: Array + daily_messages?: Array + token_usage?: Array + tokens_per_second?: Array + user_satisfaction_rate?: Array +} + +export type AgentStatisticSummaryResponse = { + average_response_time: number + average_session_interactions: number + currency: string + tokens_per_second: number + total_conversations: number + total_end_users: number + total_messages: number + total_price: string + total_tokens: number + user_satisfaction_rate: number +} + export type AgentConfigRevisionResponse = { created_at?: number | null created_by?: string | null @@ -953,6 +1014,48 @@ export type EnvSuggestion = { secret_likely?: boolean } +export type AgentAverageResponseTimeStatisticResponse = { + date: string + latency: number +} + +export type AgentAverageSessionInteractionStatisticResponse = { + date: string + interactions: number +} + +export type AgentDailyConversationStatisticResponse = { + conversation_count: number + date: string +} + +export type AgentDailyEndUserStatisticResponse = { + date: string + terminal_count: number +} + +export type AgentDailyMessageStatisticResponse = { + date: string + message_count: number +} + +export type AgentTokenUsageStatisticResponse = { + currency: string + date: string + token_count: number + total_price: string +} + +export type AgentTokensPerSecondStatisticResponse = { + date: string + tps: number +} + +export type AgentUserSatisfactionRateStatisticResponse = { + date: string + rate: number +} + export type AgentConfigRevisionOperation = | 'create_version' | 'save_current_version' @@ -1717,6 +1820,30 @@ export type PostAgentByAgentIdFilesResponses = { export type PostAgentByAgentIdFilesResponse = PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses] +export type GetAgentByAgentIdLogsData = { + body?: never + path: { + agent_id: string + } + query?: { + end?: string + keyword?: string + limit?: number + page?: number + source?: string + start?: string + status?: string + } + url: '/agent/{agent_id}/logs' +} + +export type GetAgentByAgentIdLogsResponses = { + 200: AgentLogListResponse +} + +export type GetAgentByAgentIdLogsResponse + = GetAgentByAgentIdLogsResponses[keyof GetAgentByAgentIdLogsResponses] + export type GetAgentByAgentIdMessagesByMessageIdData = { body?: never path: { @@ -1886,6 +2013,26 @@ export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = { export type PostAgentByAgentIdSkillsBySlugInferToolsResponse = PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses] +export type GetAgentByAgentIdStatisticsSummaryData = { + body?: never + path: { + agent_id: string + } + query?: { + end?: string + source?: string + start?: string + } + url: '/agent/{agent_id}/statistics/summary' +} + +export type GetAgentByAgentIdStatisticsSummaryResponses = { + 200: AgentStatisticSummaryEnvelopeResponse +} + +export type GetAgentByAgentIdStatisticsSummaryResponse + = GetAgentByAgentIdStatisticsSummaryResponses[keyof GetAgentByAgentIdStatisticsSummaryResponses] + export type GetAgentByAgentIdVersionsData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 2e1ffadc4b5..5232696ab58 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -321,6 +321,43 @@ export const zAgentDriveFileCommitResponse = z.object({ file: zAgentDriveFileResponse, }) +/** + * AgentLogItemResponse + */ +export const zAgentLogItemResponse = z.object({ + answer: z.string(), + answer_tokens: z.int(), + conversation_id: z.string(), + conversation_name: z.string().nullish(), + created_at: z.int().nullish(), + currency: z.string(), + error: z.string().nullish(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string().nullish(), + id: z.string(), + latency: z.number(), + message_id: z.string(), + message_tokens: z.int(), + query: z.string(), + source: z.string().nullish(), + status: z.string(), + total_price: z.string(), + total_tokens: z.int(), + updated_at: z.int().nullish(), +}) + +/** + * AgentLogListResponse + */ +export const zAgentLogListResponse = z.object({ + data: z.array(zAgentLogItemResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * AgentThought */ @@ -458,6 +495,22 @@ export const zAgentSkillUploadResponse = z.object({ skill: zAgentSkillRefConfig, }) +/** + * AgentStatisticSummaryResponse + */ +export const zAgentStatisticSummaryResponse = z.object({ + average_response_time: z.number(), + average_session_interactions: z.number(), + currency: z.string(), + tokens_per_second: z.number(), + total_conversations: z.int(), + total_end_users: z.int(), + total_messages: z.int(), + total_price: z.string(), + total_tokens: z.int(), + user_satisfaction_rate: z.number(), +}) + /** * ModelConfigPartial */ @@ -885,6 +938,97 @@ export const zSkillToolInferenceResult = z.object({ reason: z.string().nullish(), }) +/** + * AgentAverageResponseTimeStatisticResponse + */ +export const zAgentAverageResponseTimeStatisticResponse = z.object({ + date: z.string(), + latency: z.number(), +}) + +/** + * AgentAverageSessionInteractionStatisticResponse + */ +export const zAgentAverageSessionInteractionStatisticResponse = z.object({ + date: z.string(), + interactions: z.number(), +}) + +/** + * AgentDailyConversationStatisticResponse + */ +export const zAgentDailyConversationStatisticResponse = z.object({ + conversation_count: z.int(), + date: z.string(), +}) + +/** + * AgentDailyEndUserStatisticResponse + */ +export const zAgentDailyEndUserStatisticResponse = z.object({ + date: z.string(), + terminal_count: z.int(), +}) + +/** + * AgentDailyMessageStatisticResponse + */ +export const zAgentDailyMessageStatisticResponse = z.object({ + date: z.string(), + message_count: z.int(), +}) + +/** + * AgentTokenUsageStatisticResponse + */ +export const zAgentTokenUsageStatisticResponse = z.object({ + currency: z.string(), + date: z.string(), + token_count: z.int(), + total_price: z.string(), +}) + +/** + * AgentTokensPerSecondStatisticResponse + */ +export const zAgentTokensPerSecondStatisticResponse = z.object({ + date: z.string(), + tps: z.number(), +}) + +/** + * AgentUserSatisfactionRateStatisticResponse + */ +export const zAgentUserSatisfactionRateStatisticResponse = z.object({ + date: z.string(), + rate: z.number(), +}) + +/** + * AgentStatisticChartsResponse + */ +export const zAgentStatisticChartsResponse = z.object({ + average_response_time: z.array(zAgentAverageResponseTimeStatisticResponse).optional(), + average_session_interactions: z + .array(zAgentAverageSessionInteractionStatisticResponse) + .optional(), + daily_conversations: z.array(zAgentDailyConversationStatisticResponse).optional(), + daily_end_users: z.array(zAgentDailyEndUserStatisticResponse).optional(), + daily_messages: z.array(zAgentDailyMessageStatisticResponse).optional(), + token_usage: z.array(zAgentTokenUsageStatisticResponse).optional(), + tokens_per_second: z.array(zAgentTokensPerSecondStatisticResponse).optional(), + user_satisfaction_rate: z.array(zAgentUserSatisfactionRateStatisticResponse).optional(), +}) + +/** + * AgentStatisticSummaryEnvelopeResponse + */ +export const zAgentStatisticSummaryEnvelopeResponse = z.object({ + charts: zAgentStatisticChartsResponse, + source: z.string(), + summary: zAgentStatisticSummaryResponse, +}) + /** * AgentConfigRevisionOperation * @@ -2106,6 +2250,25 @@ export const zPostAgentByAgentIdFilesPath = z.object({ */ export const zPostAgentByAgentIdFilesResponse = zAgentDriveFileCommitResponse +export const zGetAgentByAgentIdLogsPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdLogsQuery = z.object({ + end: z.string().optional(), + keyword: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + source: z.string().optional(), + start: z.string().optional(), + status: z.string().optional(), +}) + +/** + * Agent logs + */ +export const zGetAgentByAgentIdLogsResponse = zAgentLogListResponse + export const zGetAgentByAgentIdMessagesByMessageIdPath = z.object({ agent_id: z.string(), message_id: z.string(), @@ -2202,6 +2365,21 @@ export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({ */ export const zPostAgentByAgentIdSkillsBySlugInferToolsResponse = zSkillToolInferenceResult +export const zGetAgentByAgentIdStatisticsSummaryPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdStatisticsSummaryQuery = z.object({ + end: z.string().optional(), + source: z.string().optional(), + start: z.string().optional(), +}) + +/** + * Agent monitoring summary and chart data + */ +export const zGetAgentByAgentIdStatisticsSummaryResponse = zAgentStatisticSummaryEnvelopeResponse + export const zGetAgentByAgentIdVersionsPath = z.object({ agent_id: z.string(), }) From f203ab7f1d10da1fbdbe144efc58271c64657476 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:56:53 +0800 Subject: [PATCH 06/62] fix(agent-v2): include workflow references in agent list (#37567) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/agent/roster.py | 50 ++++++++++++-- api/openapi/markdown/console-openapi.md | 68 ++++++++++++++++--- api/services/agent/roster_service.py | 6 ++ ...alchemy_workflow_run_cleanup_repository.py | 6 +- .../console/agent/test_agent_controllers.py | 30 ++++++++ .../generated/api/console/agent/types.gen.ts | 26 +++++-- .../generated/api/console/agent/zod.gen.ts | 37 +++++++--- .../generated/api/console/apps/types.gen.ts | 49 ++----------- .../generated/api/console/apps/zod.gen.ts | 55 ++------------- 9 files changed, 202 insertions(+), 125 deletions(-) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 70aa7cc4c7b..9d64b141d9d 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -11,6 +11,7 @@ from controllers.console.app.app import ( AppDetailWithSite, AppListQuery, AppPagination, + AppPartial, UpdateAppPayload, _normalize_app_list_query_args, ) @@ -72,6 +73,27 @@ class AgentAppUpdatePayload(UpdateAppPayload): role: str | None = Field(default=None, description="Agent role", max_length=255) +class AgentAppPublishedReferenceResponse(BaseModel): + app_id: str + app_name: str + app_icon_type: str | None = None + app_icon: str | None = None + app_icon_background: str | None = None + + +class AgentAppPartial(AppPartial): + published_reference_count: int = 0 + published_references: list[AgentAppPublishedReferenceResponse] = Field(default_factory=list) + + +class AgentAppPagination(BaseModel): + page: int + limit: int + total: int + has_more: bool + data: list[AgentAppPartial] + + class AgentLogsQuery(BaseModel): page: int = Field(default=1, ge=1, description="Page number") limit: int = Field(default=20, ge=1, le=100, description="Page size") @@ -123,7 +145,8 @@ register_schema_models( register_response_schema_models( console_ns, AppDetailWithSite, - AppPagination, + AgentAppPagination, + AgentAppPublishedReferenceResponse, AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, @@ -171,6 +194,10 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: tenant_id=tenant_id, agents=list(agents_by_app_id.values()), ) + published_references_by_agent_id = roster_service.load_published_references_by_agent_id( + tenant_id=tenant_id, + agent_ids=[agent.id for agent in agents_by_app_id.values()], + ) payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json") for item in payload["data"]: app_id = item["id"] @@ -181,7 +208,22 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: item["id"] = agent.id item["role"] = agent.role or "" item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False) - return payload + published_references = published_references_by_agent_id.get(agent.id, []) + item["published_reference_count"] = len(published_references) + item["published_references"] = [ + { + "app_id": reference["app_id"], + "app_name": reference["app_name"], + "app_icon_type": reference["app_icon_type"], + "app_icon": reference["app_icon"], + "app_icon_background": reference["app_icon_background"], + } + for reference in published_references + ] + return AgentAppPagination.model_validate(payload).model_dump( + mode="json", + exclude={"data": {"__all__": {"bound_agent_id"}}}, + ) def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID): @@ -203,7 +245,7 @@ def _parse_observability_time_range(start: str | None, end: str | None, account: @console_ns.route("/agent") class AgentAppListApi(Resource): @console_ns.doc(params=query_params_from_model(AppListQuery)) - @console_ns.response(200, "Agent app list", console_ns.models[AppPagination.__name__]) + @console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__]) @setup_required @login_required @account_initialization_required @@ -224,7 +266,7 @@ class AgentAppListApi(Resource): app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params) if app_pagination is None: - empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) + empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json") return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 925d4bd84af..b413b1c0170 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -311,7 +311,7 @@ Check if activation token is valid | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent app list | **application/json**: [AppPagination](#apppagination)
| +| 200 | Agent app list | **application/json**: [AgentAppPagination](#agentapppagination)
| ### [POST] /agent #### Request Body @@ -11341,6 +11341,59 @@ default (the config form sends the full desired feature state on save). | suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No | | text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No | +#### AgentAppPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AgentAppPartial](#agentapppartial) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### AgentAppPartial + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | string | | No | +| active_config_is_published | boolean | | No | +| app_id | string | | No | +| author_name | string | | No | +| bound_agent_id | string | | No | +| create_user_name | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| description | string | | No | +| has_draft_trigger | boolean | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| icon_url | string | | Yes | +| id | string | | Yes | +| is_starred | boolean | | No | +| max_active_requests | integer | | No | +| mode | string | | Yes | +| model_config | [ModelConfigPartial](#modelconfigpartial) | | No | +| name | string | | Yes | +| published_reference_count | integer | | No | +| published_references | [ [AgentAppPublishedReferenceResponse](#agentapppublishedreferenceresponse) ] | | No | +| role | string | | No | +| tags | [ [Tag](#tag) ] | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | +| workflow | [WorkflowPartial](#workflowpartial) | | No | + +#### AgentAppPublishedReferenceResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_icon | string | | No | +| app_icon_background | string | | No | +| app_icon_type | string | | No | +| app_id | string | | Yes | +| app_name | string | | Yes | + #### AgentAppUpdatePayload | Name | Type | Description | Required | @@ -12816,10 +12869,10 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [AppPartial](#apppartial) ] | | Yes | -| has_more | boolean | | Yes | -| limit | integer | | Yes | +| has_next | boolean | | Yes | +| items | [ [AppPartial](#apppartial) ] | | Yes | | page | integer | | Yes | +| per_page | integer | | Yes | | total | integer | | Yes | #### AppPartial @@ -12829,22 +12882,21 @@ AppMCPServer Status Enum | access_mode | string | | No | | active_config_is_published | boolean | | No | | app_id | string | | No | +| app_model_config | [ModelConfigPartial](#modelconfigpartial) | | No | | author_name | string | | No | | bound_agent_id | string | | No | | create_user_name | string | | No | | created_at | integer | | No | | created_by | string | | No | -| description | string | | No | +| desc_or_prompt | string | | No | | has_draft_trigger | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | -| icon_url | string | | Yes | | id | string | | Yes | | is_starred | boolean | | No | | max_active_requests | integer | | No | -| mode | string | | Yes | -| model_config | [ModelConfigPartial](#modelconfigpartial) | | No | +| mode_compatible_with_agent | string | | Yes | | name | string | | Yes | | role | string | | No | | tags | [ [Tag](#tag) ] | | No | diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 69a2306cc8a..85636a9609b 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -432,6 +432,12 @@ class AgentRosterService: return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=[agent.id]).get(agent.id, []) + def load_published_references_by_agent_id( + self, *, tenant_id: str, agent_ids: list[str] + ) -> dict[str, list[AgentReferencingWorkflow]]: + """Return published workflow references grouped by roster Agent id.""" + return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=agent_ids) + def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]: agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) active_version = self._get_version( diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py index 12268851713..be659fac184 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py @@ -87,7 +87,7 @@ def _add_app_log(session: Session, scope: _TestScope, workflow_run: WorkflowRun) session.commit() -def _add_pause_with_reason(session: Session, _scope: _TestScope, workflow_run: WorkflowRun) -> WorkflowPause: +def _add_pause_with_reason(session: Session, workflow_run: WorkflowRun) -> WorkflowPause: pause = WorkflowPause( workflow_id=workflow_run.workflow_id, workflow_run_id=workflow_run.id, @@ -185,7 +185,7 @@ class TestCountRunsWithRelatedByIds: ) missing_run_id = str(uuid4()) _add_app_log(db_session_with_containers, scope, workflow_run) - _add_pause_with_reason(db_session_with_containers, scope, workflow_run) + _add_pause_with_reason(db_session_with_containers, workflow_run) counted_node_run_ids: list[str] = [] counted_trigger_run_ids: list[str] = [] @@ -239,7 +239,7 @@ class TestDeleteRunsWithRelatedByIds: created_at=datetime(2024, 1, 1, 12, 0, 0), ) _add_app_log(db_session_with_containers, scope, workflow_run) - pause = _add_pause_with_reason(db_session_with_containers, scope, workflow_run) + pause = _add_pause_with_reason(db_session_with_containers, workflow_run) pause_id = pause.id deleted_node_run_ids: list[str] = [] deleted_trigger_run_ids: list[str] = [] diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 429c3d0e2ea..a6ae9e6933b 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -214,6 +214,26 @@ def test_agent_app_list_and_create_use_agent_route( id="agent-created", role="Created role", active_config_snapshot_id=None ), ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "load_published_references_by_agent_id", + lambda _self, **kwargs: { + "agent-list": [ + { + "app_id": "workflow-app-id", + "app_name": "RFP Review Flow", + "app_icon_type": "emoji", + "app_icon": "A", + "app_icon_background": "#fff", + "app_mode": "workflow", + "app_updated_at": 1781660000, + "workflow_id": "workflow-1", + "workflow_version": "v1", + "node_ids": ["node-1", "node-2"], + } + ] + }, + ) monkeypatch.setattr( roster_controller.FeatureService, "get_system_features", @@ -230,6 +250,16 @@ def test_agent_app_list_and_create_use_agent_route( assert listed["data"][0]["app_id"] == "app-list" assert listed["data"][0]["role"] == "List role" assert listed["data"][0]["active_config_is_published"] is False + assert listed["data"][0]["published_reference_count"] == 1 + assert listed["data"][0]["published_references"] == [ + { + "app_id": "workflow-app-id", + "app_name": "RFP Review Flow", + "app_icon_type": "emoji", + "app_icon": "A", + "app_icon_background": "#fff", + } + ] assert "bound_agent_id" not in listed["data"][0] list_call = cast(dict[str, object], captured["list"]) list_params = cast(Any, list_call["params"]) diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 5d123216d7c..77b203fb958 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -4,8 +4,8 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } -export type AppPagination = { - data: Array +export type AgentAppPagination = { + data: Array has_more: boolean limit: number page: number @@ -272,7 +272,7 @@ export type AgentConfigSnapshotDetailResponse = { version_note?: string | null } -export type AppPartial = { +export type AgentAppPartial = { access_mode?: string | null active_config_is_published?: boolean app_id?: string | null @@ -293,6 +293,8 @@ export type AppPartial = { mode: string model_config?: ModelConfigPartial | null name: string + published_reference_count?: number + published_references?: Array role?: string | null tags?: Array updated_at?: number | null @@ -726,6 +728,14 @@ export type ModelConfigPartial = { updated_by?: string | null } +export type AgentAppPublishedReferenceResponse = { + app_icon?: string | null + app_icon_background?: string | null + app_icon_type?: string | null + app_id: string + app_name: string +} + export type LlmMode = 'chat' | 'completion' export type AgentKind = 'dify_agent' @@ -1361,8 +1371,8 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' export type ValueSourceType = 'constant' | 'variable' -export type AppPaginationWritable = { - data: Array +export type AgentAppPaginationWritable = { + data: Array has_more: boolean limit: number page: number @@ -1399,7 +1409,7 @@ export type AppDetailWithSiteWritable = { workflow?: WorkflowPartial | null } -export type AppPartialWritable = { +export type AgentAppPartialWritable = { access_mode?: string | null active_config_is_published?: boolean app_id?: string | null @@ -1419,6 +1429,8 @@ export type AppPartialWritable = { mode: string model_config?: ModelConfigPartial | null name: string + published_reference_count?: number + published_references?: Array role?: string | null tags?: Array updated_at?: number | null @@ -1468,7 +1480,7 @@ export type GetAgentData = { } export type GetAgentResponses = { - 200: AppPagination + 200: AgentAppPagination } export type GetAgentResponse = GetAgentResponses[keyof GetAgentResponses] diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 5232696ab58..7ea55838189 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -524,9 +524,20 @@ export const zModelConfigPartial = z.object({ }) /** - * AppPartial + * AgentAppPublishedReferenceResponse */ -export const zAppPartial = z.object({ +export const zAgentAppPublishedReferenceResponse = z.object({ + app_icon: z.string().nullish(), + app_icon_background: z.string().nullish(), + app_icon_type: z.string().nullish(), + app_id: z.string(), + app_name: z.string(), +}) + +/** + * AgentAppPartial + */ +export const zAgentAppPartial = z.object({ access_mode: z.string().nullish(), active_config_is_published: z.boolean().optional().default(false), app_id: z.string().nullish(), @@ -547,6 +558,8 @@ export const zAppPartial = z.object({ mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), + published_reference_count: z.int().optional().default(0), + published_references: z.array(zAgentAppPublishedReferenceResponse).optional(), role: z.string().nullish(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), @@ -556,10 +569,10 @@ export const zAppPartial = z.object({ }) /** - * AppPagination + * AgentAppPagination */ -export const zAppPagination = z.object({ - data: z.array(zAppPartial), +export const zAgentAppPagination = z.object({ + data: z.array(zAgentAppPartial), has_more: z.boolean(), limit: z.int(), page: z.int(), @@ -1917,9 +1930,9 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({ }) /** - * AppPartial + * AgentAppPartial */ -export const zAppPartialWritable = z.object({ +export const zAgentAppPartialWritable = z.object({ access_mode: z.string().nullish(), active_config_is_published: z.boolean().optional().default(false), app_id: z.string().nullish(), @@ -1939,6 +1952,8 @@ export const zAppPartialWritable = z.object({ mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), + published_reference_count: z.int().optional().default(0), + published_references: z.array(zAgentAppPublishedReferenceResponse).optional(), role: z.string().nullish(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), @@ -1948,10 +1963,10 @@ export const zAppPartialWritable = z.object({ }) /** - * AppPagination + * AgentAppPagination */ -export const zAppPaginationWritable = z.object({ - data: z.array(zAppPartialWritable), +export const zAgentAppPaginationWritable = z.object({ + data: z.array(zAgentAppPartialWritable), has_more: z.boolean(), limit: z.int(), page: z.int(), @@ -2039,7 +2054,7 @@ export const zGetAgentQuery = z.object({ /** * Agent app list */ -export const zGetAgentResponse = zAppPagination +export const zGetAgentResponse = zAgentAppPagination export const zPostAgentBody = zAgentAppCreatePayload diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 0c7633ba7ec..8ccf899b451 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -5,10 +5,10 @@ export type ClientOptions = { } export type AppPagination = { - data: Array - has_more: boolean - limit: number + has_next: boolean + items: Array page: number + per_page: number total: number } @@ -1158,22 +1158,21 @@ export type AppPartial = { access_mode?: string | null active_config_is_published?: boolean app_id?: string | null + app_model_config?: ModelConfigPartial | null author_name?: string | null bound_agent_id?: string | null create_user_name?: string | null created_at?: number | null created_by?: string | null - description?: string | null + desc_or_prompt?: string | null has_draft_trigger?: boolean | null icon?: string | null icon_background?: string | null icon_type?: string | null - readonly icon_url: string | null id: string is_starred?: boolean max_active_requests?: number | null - mode: string - model_config?: ModelConfigPartial | null + mode_compatible_with_agent: string name: string role?: string | null tags?: Array @@ -2576,14 +2575,6 @@ export type AgentModerationIoConfig = { export type ValueSourceType = 'constant' | 'variable' -export type AppPaginationWritable = { - data: Array - has_more: boolean - limit: number - page: number - total: number -} - export type AppDetailWithSiteWritable = { access_mode?: string | null active_config_is_published?: boolean @@ -2637,34 +2628,6 @@ export type WorkflowCommentDetailWritable = { updated_at?: number | null } -export type AppPartialWritable = { - access_mode?: string | null - active_config_is_published?: boolean - app_id?: string | null - author_name?: string | null - bound_agent_id?: string | null - create_user_name?: string | null - created_at?: number | null - created_by?: string | null - description?: string | null - has_draft_trigger?: boolean | null - icon?: string | null - icon_background?: string | null - icon_type?: string | null - id: string - is_starred?: boolean - max_active_requests?: number | null - mode: string - model_config?: ModelConfigPartial | null - name: string - role?: string | null - tags?: Array - updated_at?: number | null - updated_by?: string | null - use_icon_as_answer_icon?: boolean | null - workflow?: WorkflowPartial | null -} - export type SiteWritable = { chat_color_theme?: string | null chat_color_theme_inverted: boolean diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 475823246b7..556f11f5521 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -1948,22 +1948,21 @@ export const zAppPartial = z.object({ access_mode: z.string().nullish(), active_config_is_published: z.boolean().optional().default(false), app_id: z.string().nullish(), + app_model_config: zModelConfigPartial.nullish(), author_name: z.string().nullish(), bound_agent_id: z.string().nullish(), create_user_name: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), - description: z.string().nullish(), + desc_or_prompt: z.string().nullish(), has_draft_trigger: z.boolean().nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), - icon_url: z.string().nullable(), id: z.string(), is_starred: z.boolean().optional().default(false), max_active_requests: z.int().nullish(), - mode: z.string(), - model_config: zModelConfigPartial.nullish(), + mode_compatible_with_agent: z.string(), name: z.string(), role: z.string().nullish(), tags: z.array(zTag).optional(), @@ -1977,10 +1976,10 @@ export const zAppPartial = z.object({ * AppPagination */ export const zAppPagination = z.object({ - data: z.array(zAppPartial), - has_more: z.boolean(), - limit: z.int(), + has_next: z.boolean(), + items: z.array(zAppPartial), page: z.int(), + per_page: z.int(), total: z.int(), }) @@ -3473,48 +3472,6 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({ */ export const zGeneratedAppResponseWritable = zJsonValue -/** - * AppPartial - */ -export const zAppPartialWritable = z.object({ - access_mode: z.string().nullish(), - active_config_is_published: z.boolean().optional().default(false), - app_id: z.string().nullish(), - author_name: z.string().nullish(), - bound_agent_id: z.string().nullish(), - create_user_name: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - description: z.string().nullish(), - has_draft_trigger: z.boolean().nullish(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: z.string().nullish(), - id: z.string(), - is_starred: z.boolean().optional().default(false), - max_active_requests: z.int().nullish(), - mode: z.string(), - model_config: zModelConfigPartial.nullish(), - name: z.string(), - role: z.string().nullish(), - tags: z.array(zTag).optional(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.nullish(), -}) - -/** - * AppPagination - */ -export const zAppPaginationWritable = z.object({ - data: z.array(zAppPartialWritable), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - /** * Site */ From 3b0f6aef8ec28116d3c786f37f5305f7c26e6316 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 17 Jun 2026 14:02:03 +0800 Subject: [PATCH 07/62] fix(api): allow inline workflow agent soul saves (#37563) --- api/services/agent/composer_service.py | 30 ++- api/services/agent/composer_validator.py | 8 +- .../agent/test_agent_composer_entities.py | 13 + .../services/agent/test_agent_services.py | 230 ++++++++++++++++++ 4 files changed, 279 insertions(+), 2 deletions(-) diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 3f544e9438b..16ab3627929 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -115,7 +115,15 @@ class AgentComposerService: and binding is not None and binding.agent_id and payload.save_strategy - in (ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, ComposerSaveStrategy.SAVE_AS_NEW_VERSION) + in ( + ComposerSaveStrategy.NODE_JOB_ONLY, + ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + ) + and ( + payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY + or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT + ) ): cls._require_drive_refs_resolved( tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul @@ -823,6 +831,26 @@ class AgentComposerService: node_job = payload.node_job or WorkflowNodeJobConfig() if binding: binding.node_job_config = node_job + if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT: + current_snapshot = cls._require_version( + tenant_id=tenant_id, + agent_id=binding.agent_id, + version_id=binding.current_snapshot_id, + ) + version = cls._update_current_version( + current_snapshot=current_snapshot, + account_id=account_id, + agent_soul=payload.agent_soul, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + version_note=payload.version_note, + ) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) + if agent.scope != AgentScope.WORKFLOW_ONLY: + raise ValueError("Inline workflow agent binding must point to a workflow-only agent") + agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) + agent.updated_by = account_id + binding.current_snapshot_id = version.id binding.updated_by = account_id return binding diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index 47c255aae2d..8554b5c1ab7 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -18,6 +18,7 @@ from services.agent.prompt_mentions import ( from services.entities.agent_entities import ( AgentSoulConfig, ComposerSavePayload, + ComposerSaveStrategy, ComposerVariant, WorkflowNodeJobConfig, ) @@ -50,7 +51,12 @@ _DANGEROUS_ACK_KEYS = ( class ComposerConfigValidator: @classmethod def validate_save_payload(cls, payload: ComposerSavePayload) -> None: - if payload.variant == ComposerVariant.WORKFLOW and payload.soul_lock.locked and payload.agent_soul is not None: + if ( + payload.variant == ComposerVariant.WORKFLOW + and payload.soul_lock.locked + and payload.agent_soul is not None + and payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY + ): raise AgentSoulLockedError() if payload.agent_soul is not None: diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index dbdf37a9053..089a5c74f3a 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -51,6 +51,19 @@ def test_locked_workflow_soul_rejects_soul_changes(): ComposerConfigValidator.validate_save_payload(payload) +def test_locked_workflow_node_job_only_allows_inline_soul_payload(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY, + "soul_lock": {"locked": True}, + "agent_soul": {"prompt": {"system_prompt": "changed"}}, + } + ) + + ComposerConfigValidator.validate_save_payload(payload) + + def test_agent_app_soul_allows_app_features_and_variables(): payload = ComposerSavePayload.model_validate( { diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 5d6ba1d0c99..fc85719883e 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -459,6 +459,125 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch): assert new_version_binding.current_snapshot_id == "new-version-1" +def test_node_job_only_updates_inline_agent_soul(monkeypatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + inline_agent = SimpleNamespace( + id="inline-agent-1", + scope=AgentScope.WORKFLOW_ONLY, + active_config_snapshot_id="inline-version-1", + active_config_has_model=False, + updated_by=None, + ) + current_snapshot = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ) + next_snapshot = AgentConfigSnapshot( + id="inline-version-2", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=2, + ) + + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) + monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: inline_agent) + + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="inline-agent-1", + current_snapshot_id="inline-version-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "agent_soul": { + "model": { + "plugin_id": "langgenius/openai/openai", + "model_provider": "openai", + "model": "gpt-4o", + }, + "prompt": {"system_prompt": "new"}, + }, + "node_job": {"workflow_prompt": "use prior output"}, + } + ) + + updated_binding = AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + assert updated_binding.current_snapshot_id == "inline-version-2" + assert updated_binding.node_job_config_dict["workflow_prompt"] == "use prior output" + assert updated_binding.updated_by == "account-1" + assert inline_agent.active_config_snapshot_id == "inline-version-2" + assert inline_agent.active_config_has_model is True + assert inline_agent.updated_by == "account-1" + + +def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + current_snapshot = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ) + next_snapshot = AgentConfigSnapshot(id="inline-version-2", tenant_id="tenant-1", agent_id="agent-1", version=2) + roster_agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER) + + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) + monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="inline-version-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "agent_soul": {"prompt": {"system_prompt": "new"}}, + } + ) + + with pytest.raises(ValueError, match="workflow-only agent"): + AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + def test_composer_create_agents_syncs_active_config_has_model(monkeypatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) @@ -2175,6 +2294,117 @@ def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies( assert guarded["agent_id"] == "agent-1" +def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monkeypatch): + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "node_job_only", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + binding = WorkflowAgentNodeBinding( + tenant_id="t-1", + app_id="app-1", + workflow_id="wf-1", + workflow_version="draft", + node_id="n-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) + ) + monkeypatch.setattr( + AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []}) + ) + guarded: dict[str, str] = {} + + def fake_guard(cls, *, tenant_id, agent_id, agent_soul): + guarded["tenant_id"] = tenant_id + guarded["agent_id"] = agent_id + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard)) + + result = AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + + assert result == {"state": "ok", "validation": {"warnings": []}} + assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"} + + +def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkeypatch): + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "node_job_only", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + binding = WorkflowAgentNodeBinding( + tenant_id="t-1", + app_id="app-1", + workflow_id="wf-1", + workflow_version="draft", + node_id="n-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) + ) + monkeypatch.setattr( + AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []}) + ) + + def fail_guard(cls, *, tenant_id, agent_id, agent_soul): + raise AssertionError("roster node-job-only saves must not validate agent drive refs") + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fail_guard)) + + result = AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + + assert result == {"state": "ok", "validation": {"warnings": []}} + + def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch): soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}} _, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) From 758bea1a91b9fe1c893d408222ef625626328e68 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 17 Jun 2026 14:47:39 +0800 Subject: [PATCH 08/62] fix(api): hide agent apps from installed apps (#37570) --- api/controllers/console/explore/installed_app.py | 9 ++++++--- .../controllers/console/explore/test_installed_app.py | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/controllers/console/explore/installed_app.py b/api/controllers/console/explore/installed_app.py index 14837897502..c1fa1378ffa 100644 --- a/api/controllers/console/explore/installed_app.py +++ b/api/controllers/console/explore/installed_app.py @@ -73,9 +73,12 @@ def _published_app_filter(): has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id)) has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id)) - return or_( - and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow), - and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config), + return and_( + App.mode != AppMode.AGENT, + or_( + and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow), + and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config), + ), ) diff --git a/api/tests/unit_tests/controllers/console/explore/test_installed_app.py b/api/tests/unit_tests/controllers/console/explore/test_installed_app.py index be6275b5cb8..be85082ecd0 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_installed_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_installed_app.py @@ -64,6 +64,7 @@ class TestInstalledAppsListApi: assert "app_model_configs" in compiled_filter assert "workflow_id" in compiled_filter assert "app_model_config_id" in compiled_filter + assert "apps.mode != 'agent'" in compiled_filter def test_get_installed_apps( self, app: Flask, current_user: MagicMock, tenant_id: str, installed_app: MagicMock From 872b5a081f0d3ac608ee167553abdd7c7e5cdf0b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:56:40 +0000 Subject: [PATCH 09/62] chore(i18n): sync translations with en-US (#37557) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/nl-NL/app-log.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/i18n/nl-NL/app-log.json b/web/i18n/nl-NL/app-log.json index c14b8dad84f..0f30728e2ed 100644 --- a/web/i18n/nl-NL/app-log.json +++ b/web/i18n/nl-NL/app-log.json @@ -42,7 +42,7 @@ "filter.period.today": "Today", "filter.period.yearToDate": "Year to date", "filter.sortBy": "Sort by:", - "monitoring.description": "Monitoring records the running status of the application, including performance, user activity, and costs.", + "monitoring.description": "Monitoring registreert de actieve status van de applicatie, inclusief prestaties, gebruikersactiviteit en kosten.", "promptLog": "Prompt Log", "runDetail.fileListDetail": "Detail", "runDetail.fileListLabel": "File Details", From 912c0fa8d1cd25a19aa1cf7cd0fcdb9a2da75f41 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 17 Jun 2026 16:09:47 +0800 Subject: [PATCH 10/62] fix(agent): add agent app duplicate endpoint (#37571) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/agent/roster.py | 30 ++ api/openapi/markdown/console-openapi.md | 21 ++ api/services/agent/roster_service.py | 163 ++++++++++- .../console/agent/test_agent_controllers.py | 48 ++++ .../services/agent/test_agent_services.py | 262 ++++++++++++++++++ .../generated/api/console/agent/orpc.gen.ts | 52 ++-- .../generated/api/console/agent/types.gen.ts | 29 ++ .../generated/api/console/agent/zod.gen.ts | 22 ++ 8 files changed, 610 insertions(+), 17 deletions(-) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 9d64b141d9d..d7935552ac0 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -12,6 +12,7 @@ from controllers.console.app.app import ( AppListQuery, AppPagination, AppPartial, + CopyAppPayload, UpdateAppPayload, _normalize_app_list_query_args, ) @@ -134,6 +135,7 @@ register_schema_models( console_ns, AgentAppCreatePayload, AgentAppUpdatePayload, + CopyAppPayload, AgentInviteOptionsQuery, AgentLogsQuery, AgentStatisticsQuery, @@ -348,6 +350,34 @@ class AgentAppApi(Resource): return "", 204 +@console_ns.route("/agent//copy") +class AgentAppCopyApi(Resource): + @console_ns.expect(console_ns.models[CopyAppPayload.__name__]) + @console_ns.response(201, "Agent app copied successfully", console_ns.models[AppDetailWithSite.__name__]) + @console_ns.response(403, "Insufficient permissions") + @console_ns.response(400, "Invalid request parameters") + @setup_required + @login_required + @account_initialization_required + @cloud_edition_billing_resource_check("apps") + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + args = CopyAppPayload.model_validate(console_ns.payload or {}) + copied_app = _agent_roster_service().duplicate_agent_app( + tenant_id=tenant_id, + agent_id=str(agent_id), + account=current_user, + name=args.name, + description=args.description, + icon_type=args.icon_type, + icon=args.icon, + icon_background=args.icon_background, + ) + return _serialize_agent_app_detail(copied_app), 201 + + @console_ns.route("/agent/invite-options") class AgentInviteOptionsApi(Resource): @console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery)) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index b413b1c0170..3e21ed4fe06 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -508,6 +508,27 @@ Stop a running Agent App chat message generation | ---- | ----------- | ------ | | 200 | Agent app composer validation result | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)
| +### [POST] /agent/{agent_id}/copy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CopyAppPayload](#copyapppayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Agent app copied successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| +| 400 | Invalid request parameters | | +| 403 | Insufficient permissions | | + ### [GET] /agent/{agent_id}/drive/files List agent drive entries for an Agent App diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 85636a9609b..8e68174b35c 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -19,7 +19,7 @@ from models.agent import ( ) from models.agent_config_entities import AgentSoulConfig from models.enums import AppStatus -from models.model import App, AppMode +from models.model import App, AppMode, IconType from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator @@ -29,7 +29,10 @@ from services.agent.errors import ( AgentNotFoundError, AgentVersionNotFoundError, ) +from services.app_service import AppService, CreateAppParams +from services.enterprise.enterprise_service import EnterpriseService from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload +from services.feature_service import FeatureService class AgentReferencingWorkflow(TypedDict): @@ -48,6 +51,28 @@ class AgentReferencingWorkflow(TypedDict): class AgentRosterService: + _APP_MODEL_CONFIG_COPY_FIELDS = ( + "opening_statement", + "suggested_questions", + "suggested_questions_after_answer", + "speech_to_text", + "text_to_speech", + "more_like_this", + "model", + "user_input_form", + "dataset_query_variable", + "pre_prompt", + "agent_mode", + "sensitive_word_avoidance", + "retriever_resource", + "prompt_type", + "chat_prompt_config", + "completion_prompt_config", + "dataset_configs", + "external_data_tools", + "file_upload", + ) + def __init__(self, session: Any): self._session = session @@ -418,6 +443,142 @@ class AgentRosterService: raise AgentNotFoundError() return app + def duplicate_agent_app( + self, + *, + tenant_id: str, + agent_id: str, + account: Any, + name: str | None = None, + description: str | None = None, + icon_type: Any = None, + icon: str | None = None, + icon_background: str | None = None, + ) -> App: + source_app = self.get_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + source_agent = self.get_app_backing_agent(tenant_id=tenant_id, app_id=source_app.id) + if source_agent is None: + raise AgentNotFoundError() + + copied_name = name or self._next_duplicate_agent_name(tenant_id=tenant_id, base_name=source_app.name) + copied_description = description if description is not None else source_app.description + copied_icon_type = icon_type if icon_type is not None else source_app.icon_type + copied_icon = icon if icon is not None else source_app.icon + copied_icon_background = icon_background if icon_background is not None else source_app.icon_background + + target_app = AppService().create_app( + tenant_id, + CreateAppParams( + name=copied_name, + description=copied_description, + mode="agent", + agent_role=source_agent.role or "", + icon_type=self._normalize_app_icon_type(copied_icon_type), + icon=copied_icon, + icon_background=copied_icon_background, + api_rph=source_app.api_rph or 0, + api_rpm=source_app.api_rpm or 0, + max_active_requests=source_app.max_active_requests, + ), + account, + ) + + target_app.enable_site = source_app.enable_site + target_app.enable_api = source_app.enable_api + target_app.use_icon_as_answer_icon = source_app.use_icon_as_answer_icon + target_app.tracing = source_app.tracing + + self._copy_app_model_config(source_app=source_app, target_app=target_app, account_id=account.id) + self._copy_agent_active_snapshot( + tenant_id=tenant_id, + source_agent=source_agent, + target_app_id=target_app.id, + account_id=account.id, + ) + self._session.commit() + + if FeatureService.get_system_features().webapp_auth.enabled: + try: + original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(source_app.id) + access_mode = original_settings.access_mode + except Exception: + access_mode = "public" + EnterpriseService.WebAppAuth.update_app_access_mode(target_app.id, access_mode) + + return target_app + + @staticmethod + def _normalize_app_icon_type(icon_type: IconType | str | None) -> str | None: + if icon_type is None: + return None + if isinstance(icon_type, IconType): + return icon_type.value + return icon_type + + def _copy_app_model_config(self, *, source_app: App, target_app: App, account_id: str) -> None: + source_config = source_app.app_model_config + target_config = target_app.app_model_config + if source_config is None or target_config is None: + return + + for field_name in self._APP_MODEL_CONFIG_COPY_FIELDS: + setattr(target_config, field_name, getattr(source_config, field_name)) + target_config.updated_by = account_id + + def _copy_agent_active_snapshot( + self, + *, + tenant_id: str, + source_agent: Agent, + target_app_id: str, + account_id: str, + ) -> None: + target_agent = self.get_app_backing_agent(tenant_id=tenant_id, app_id=target_app_id) + if target_agent is None: + raise AgentNotFoundError() + + source_version = self._get_version( + tenant_id=tenant_id, + agent_id=source_agent.id, + version_id=source_agent.active_config_snapshot_id, + ) + target_version = self._get_version( + tenant_id=tenant_id, + agent_id=target_agent.id, + version_id=target_agent.active_config_snapshot_id, + ) + + target_version.config_snapshot = AgentSoulConfig.model_validate(source_version.config_snapshot_dict) + target_version.summary = source_version.summary + target_version.version_note = source_version.version_note + target_version.created_by = account_id + target_agent.active_config_has_model = agent_soul_has_model(target_version.config_snapshot) + target_agent.updated_by = account_id + + def _next_duplicate_agent_name(self, *, tenant_id: str, base_name: str) -> str: + suffix = " copy" + max_base_len = 255 - len(suffix) + first_candidate = f"{base_name[:max_base_len]}{suffix}" + candidates = [first_candidate] + for index in range(2, 100): + numbered_suffix = f" copy {index}" + candidates.append(f"{base_name[: 255 - len(numbered_suffix)]}{numbered_suffix}") + + existing_names = set( + self._session.scalars( + select(Agent.name).where( + Agent.tenant_id == tenant_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + Agent.name.in_(candidates), + ) + ).all() + ) + for candidate in candidates: + if candidate not in existing_names: + return candidate + return f"{base_name[:245]} copy {int(naive_utc_now().timestamp())}" + def list_workflows_referencing_app_agent(self, *, tenant_id: str, app_id: str) -> list[AgentReferencingWorkflow]: """List the workflow apps that reference this Agent App's bound Agent. diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index a6ae9e6933b..1679cc7d6a7 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -21,6 +21,7 @@ from controllers.console.agent.composer import ( ) from controllers.console.agent.roster import ( AgentAppApi, + AgentAppCopyApi, AgentAppListApi, AgentInviteOptionsApi, AgentLogsApi, @@ -140,6 +141,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//composer/validate", "/agent//composer/candidates", "/agent//features", + "/agent//copy", "/agent//referencing-workflows", "/agent//drive/files", "/agent//sandbox/files", @@ -347,6 +349,52 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert captured["delete"] is app_model +def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + current_user = SimpleNamespace(id=account_id) + copied_app = _app_detail_obj(id="copied-app", bound_agent_id="copied-agent") + captured: dict[str, object] = {} + + class FakeRosterService: + def duplicate_agent_app(self, **kwargs: object) -> object: + captured.update(kwargs) + return copied_app + + monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda: FakeRosterService()) + monkeypatch.setattr( + roster_controller, + "_serialize_agent_app_detail", + lambda app_model: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name}, + ) + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/copy", + json={ + "name": "Iris copy", + "description": "Copied", + "icon_type": "emoji", + "icon": "sparkles", + "icon_background": "#fff", + }, + ): + copied, status = unwrap(AgentAppCopyApi.post)(AgentAppCopyApi(), "tenant-1", current_user, agent_id) + + assert status == 201 + assert copied == {"id": "copied-agent", "app_id": "copied-app", "name": "Iris"} + assert captured == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "account": current_user, + "name": "Iris copy", + "description": "Copied", + "icon_type": "emoji", + "icon": "sparkles", + "icon_background": "#fff", + } + + def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index fc85719883e..fb0a316648a 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -23,6 +23,7 @@ from models.agent_config_entities import ( DeclaredOutputType, WorkflowNodeJobConfig, ) +from models.model import IconType from models.workflow import Workflow from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model @@ -1309,6 +1310,267 @@ class TestAgentAppBackingAgent: with pytest.raises(roster_service.AgentNotFoundError): service.get_agent_app_model(tenant_id="tenant-1", agent_id="agent-x") + def test_duplicate_agent_app_copies_app_config_and_active_soul(self, monkeypatch): + source_config = SimpleNamespace( + opening_statement="hello", + suggested_questions='["q1"]', + suggested_questions_after_answer='{"enabled": true}', + speech_to_text='{"enabled": false}', + text_to_speech='{"enabled": false}', + more_like_this='{"enabled": false}', + model=None, + user_input_form=None, + dataset_query_variable=None, + pre_prompt=None, + agent_mode=None, + sensitive_word_avoidance=None, + retriever_resource='{"enabled": true}', + prompt_type="simple", + chat_prompt_config=None, + completion_prompt_config=None, + dataset_configs=None, + external_data_tools=None, + file_upload='{"image": {"enabled": true}}', + ) + target_config = SimpleNamespace(**dict.fromkeys(AgentRosterService._APP_MODEL_CONFIG_COPY_FIELDS)) + source_app = SimpleNamespace( + id="source-app", + tenant_id="tenant-1", + name="Iris", + description="source desc", + icon_type="emoji", + icon="robot", + icon_background="#fff", + api_rph=1, + api_rpm=2, + max_active_requests=3, + enable_site=False, + enable_api=True, + use_icon_as_answer_icon=True, + tracing="{}", + app_model_config=source_config, + ) + target_app = SimpleNamespace( + id="target-app", + app_model_config=target_config, + enable_site=True, + enable_api=True, + use_icon_as_answer_icon=False, + tracing=None, + ) + source_agent = Agent( + id="source-agent", + tenant_id="tenant-1", + name="Iris", + description="source desc", + role="Analyst", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id="source-app", + active_config_snapshot_id="source-version", + active_config_has_model=True, + ) + target_agent = Agent( + id="target-agent", + tenant_id="tenant-1", + name="Iris copy", + description="source desc", + role="Analyst", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id="target-app", + active_config_snapshot_id="target-version", + ) + source_version = AgentConfigSnapshot( + id="source-version", + tenant_id="tenant-1", + agent_id="source-agent", + version=1, + config_snapshot=_agent_soul_with_model(), + summary="configured", + version_note="v1", + created_by="account-1", + ) + target_version = AgentConfigSnapshot( + id="target-version", + tenant_id="tenant-1", + agent_id="target-agent", + version=1, + config_snapshot=AgentSoulConfig(), + created_by="account-1", + ) + session = FakeSession( + scalar=[source_agent, source_app, source_agent, target_agent, source_version, target_version], + scalars=[[]], + ) + captured: dict[str, object] = {} + + class FakeAppService: + def create_app(self, tenant_id: str, params, account: object) -> object: + captured["tenant_id"] = tenant_id + captured["params"] = params + captured["account"] = account + return target_app + + monkeypatch.setattr(roster_service, "AppService", FakeAppService) + monkeypatch.setattr( + roster_service.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + account = SimpleNamespace(id="account-1") + duplicated = AgentRosterService(session).duplicate_agent_app( + tenant_id="tenant-1", + agent_id="source-agent", + account=account, + ) + + assert duplicated is target_app + params = captured["params"] + assert params.name == "Iris copy" + assert params.mode == "agent" + assert params.agent_role == "Analyst" + assert target_app.enable_site is False + assert target_app.enable_api is True + assert target_app.use_icon_as_answer_icon is True + assert target_app.tracing == "{}" + assert target_config.opening_statement == "hello" + assert target_config.file_upload == '{"image": {"enabled": true}}' + assert target_config.updated_by == "account-1" + assert target_version.config_snapshot.model.model == "gpt-4o" + assert target_version.summary == "configured" + assert target_version.version_note == "v1" + assert target_agent.active_config_has_model is True + assert target_agent.updated_by == "account-1" + assert session.commits == 1 + + def test_duplicate_agent_app_inherits_webapp_access_mode(self, monkeypatch): + source_app = SimpleNamespace( + id="source-app", + tenant_id="tenant-1", + name="Iris", + description="source desc", + icon_type=None, + icon="robot", + icon_background="#fff", + api_rph=1, + api_rpm=2, + max_active_requests=3, + enable_site=True, + enable_api=True, + use_icon_as_answer_icon=False, + tracing=None, + ) + source_agent = SimpleNamespace(id="source-agent", role="Analyst") + target_app = SimpleNamespace(id="target-app") + session = FakeSession() + service = AgentRosterService(session) + monkeypatch.setattr(service, "get_agent_app_model", lambda **_: source_app) + monkeypatch.setattr(service, "get_app_backing_agent", lambda **_: source_agent) + monkeypatch.setattr(service, "_copy_app_model_config", lambda **_: None) + monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None) + monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy") + + class FakeAppService: + def create_app(self, tenant_id: str, params, account: object) -> object: + return target_app + + access_mode_updates = [] + + class FakeWebAppAuth: + @classmethod + def get_app_access_mode_by_id(cls, app_id: str) -> object: + return SimpleNamespace(access_mode="private") + + @classmethod + def update_app_access_mode(cls, app_id: str, access_mode: str) -> None: + access_mode_updates.append((app_id, access_mode)) + + monkeypatch.setattr(roster_service, "AppService", FakeAppService) + monkeypatch.setattr( + roster_service.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)), + ) + monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth) + + duplicated = service.duplicate_agent_app( + tenant_id="tenant-1", + agent_id="source-agent", + account=SimpleNamespace(id="account-1"), + ) + + assert duplicated is target_app + assert access_mode_updates == [("target-app", "private")] + + def test_duplicate_agent_app_falls_back_to_public_access_mode(self, monkeypatch): + source_app = SimpleNamespace( + id="source-app", + tenant_id="tenant-1", + name="Iris", + description="source desc", + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#fff", + api_rph=1, + api_rpm=2, + max_active_requests=3, + enable_site=True, + enable_api=True, + use_icon_as_answer_icon=False, + tracing=None, + ) + source_agent = SimpleNamespace(id="source-agent", role="Analyst") + target_app = SimpleNamespace(id="target-app") + session = FakeSession() + service = AgentRosterService(session) + monkeypatch.setattr(service, "get_agent_app_model", lambda **_: source_app) + monkeypatch.setattr(service, "get_app_backing_agent", lambda **_: source_agent) + monkeypatch.setattr(service, "_copy_app_model_config", lambda **_: None) + monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None) + monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy") + + class FakeAppService: + def create_app(self, tenant_id: str, params, account: object) -> object: + return target_app + + access_mode_updates = [] + + class FakeWebAppAuth: + @classmethod + def get_app_access_mode_by_id(cls, app_id: str) -> object: + raise ValueError("not found") + + @classmethod + def update_app_access_mode(cls, app_id: str, access_mode: str) -> None: + access_mode_updates.append((app_id, access_mode)) + + monkeypatch.setattr(roster_service, "AppService", FakeAppService) + monkeypatch.setattr( + roster_service.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)), + ) + monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth) + + service.duplicate_agent_app( + tenant_id="tenant-1", + agent_id="source-agent", + account=SimpleNamespace(id="account-1"), + ) + + assert access_mode_updates == [("target-app", "public")] + + def test_normalize_app_icon_type(self): + assert AgentRosterService._normalize_app_icon_type(None) is None + assert AgentRosterService._normalize_app_icon_type(IconType.EMOJI) == "emoji" + assert AgentRosterService._normalize_app_icon_type("image") == "image" + class TestListWorkflowsReferencingAppAgent: def test_groups_bindings_by_workflow_app_and_sorts_by_name(self): diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index ba01699e2bd..13522677abe 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -61,6 +61,9 @@ import { zPostAgentByAgentIdComposerValidateBody, zPostAgentByAgentIdComposerValidatePath, zPostAgentByAgentIdComposerValidateResponse, + zPostAgentByAgentIdCopyBody, + zPostAgentByAgentIdCopyPath, + zPostAgentByAgentIdCopyResponse, zPostAgentByAgentIdFeaturesBody, zPostAgentByAgentIdFeaturesPath, zPostAgentByAgentIdFeaturesResponse, @@ -239,6 +242,22 @@ export const composer = { validate, } +export const post3 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdCopy', + path: '/agent/{agent_id}/copy', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ body: zPostAgentByAgentIdCopyBody, params: zPostAgentByAgentIdCopyPath })) + .output(zPostAgentByAgentIdCopyResponse) + +export const copy = { + post: post3, +} + /** * Time-limited external signed URL for one Agent App drive value */ @@ -320,7 +339,7 @@ export const drive = { /** * Update an Agent App's presentation features (opener, follow-up, citations, ...) */ -export const post3 = oc +export const post4 = oc .route({ description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', inputStructure: 'detailed', @@ -335,13 +354,13 @@ export const post3 = oc .output(zPostAgentByAgentIdFeaturesResponse) export const features = { - post: post3, + post: post4, } /** * Create or update Agent App message feedback */ -export const post4 = oc +export const post5 = oc .route({ description: 'Create or update Agent App message feedback', inputStructure: 'detailed', @@ -356,7 +375,7 @@ export const post4 = oc .output(zPostAgentByAgentIdFeedbacksResponse) export const feedbacks = { - post: post4, + post: post5, } /** @@ -379,7 +398,7 @@ export const delete_ = oc /** * Commit an uploaded file into the Agent App drive under files/ */ -export const post5 = oc +export const post6 = oc .route({ description: 'Commit an uploaded file into the Agent App drive under files/', inputStructure: 'detailed', @@ -394,7 +413,7 @@ export const post5 = oc export const files2 = { delete: delete_, - post: post5, + post: post6, } export const get9 = oc @@ -483,7 +502,7 @@ export const read = { /** * Upload one Agent App sandbox file as a Dify ToolFile mapping */ -export const post6 = oc +export const post7 = oc .route({ description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -501,7 +520,7 @@ export const post6 = oc .output(zPostAgentByAgentIdSandboxFilesUploadResponse) export const upload = { - post: post6, + post: post7, } /** @@ -537,7 +556,7 @@ export const sandbox = { /** * Validate + standardize a Skill into an Agent App drive */ -export const post7 = oc +export const post8 = oc .route({ description: 'Validate + standardize a Skill into an Agent App drive', inputStructure: 'detailed', @@ -551,13 +570,13 @@ export const post7 = oc .output(zPostAgentByAgentIdSkillsStandardizeResponse) export const standardize = { - post: post7, + post: post8, } /** * Upload + validate a Skill package for an Agent App */ -export const post8 = oc +export const post9 = oc .route({ description: 'Upload + validate a Skill package for an Agent App', inputStructure: 'detailed', @@ -571,13 +590,13 @@ export const post8 = oc .output(zPostAgentByAgentIdSkillsUploadResponse) export const upload2 = { - post: post8, + post: post9, } /** * Infer CLI tool + ENV suggestions from a standardized Agent App skill */ -export const post9 = oc +export const post10 = oc .route({ description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill', inputStructure: 'detailed', @@ -590,7 +609,7 @@ export const post9 = oc .output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse) export const inferTools = { - post: post9, + post: post10, } /** @@ -714,6 +733,7 @@ export const byAgentId = { put: put2, chatMessages, composer, + copy, drive, features, feedbacks, @@ -738,7 +758,7 @@ export const get18 = oc .input(z.object({ query: zGetAgentQuery.optional() })) .output(zGetAgentResponse) -export const post10 = oc +export const post11 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -752,7 +772,7 @@ export const post10 = oc export const agent = { get: get18, - post: post10, + post: post11, inviteOptions, byAgentId, } diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 77b203fb958..2373233d063 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -122,6 +122,14 @@ export type AgentComposerValidateResponse = { warnings?: Array } +export type CopyAppPayload = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: IconType | null + name?: string | null +} + export type AgentDriveListResponse = { items?: Array } @@ -1703,6 +1711,27 @@ export type PostAgentByAgentIdComposerValidateResponses = { export type PostAgentByAgentIdComposerValidateResponse = PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses] +export type PostAgentByAgentIdCopyData = { + body: CopyAppPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/copy' +} + +export type PostAgentByAgentIdCopyErrors = { + 400: unknown + 403: unknown +} + +export type PostAgentByAgentIdCopyResponses = { + 201: AppDetailWithSite +} + +export type PostAgentByAgentIdCopyResponse + = PostAgentByAgentIdCopyResponses[keyof PostAgentByAgentIdCopyResponses] + export type GetAgentByAgentIdDriveFilesData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 7ea55838189..dec6a250798 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -109,6 +109,17 @@ export const zAgentAppUpdatePayload = z.object({ use_icon_as_answer_icon: z.boolean().nullish(), }) +/** + * CopyAppPayload + */ +export const zCopyAppPayload = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zIconType.nullish(), + name: z.string().nullish(), +}) + /** * DeletedTool */ @@ -2180,6 +2191,17 @@ export const zPostAgentByAgentIdComposerValidatePath = z.object({ */ export const zPostAgentByAgentIdComposerValidateResponse = zAgentComposerValidateResponse +export const zPostAgentByAgentIdCopyBody = zCopyAppPayload + +export const zPostAgentByAgentIdCopyPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app copied successfully + */ +export const zPostAgentByAgentIdCopyResponse = zAppDetailWithSite + export const zGetAgentByAgentIdDriveFilesPath = z.object({ agent_id: z.string(), }) From e6a91bfcde8549d99f43c834ff8c333be2225b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 17 Jun 2026 16:13:26 +0800 Subject: [PATCH 11/62] chore: workflow restore sandbox upgrade (#37568) --- .../__tests__/header-in-restoring.spec.tsx | 36 +++++++- .../workflow/header/header-in-restoring.tsx | 22 ++++- .../__tests__/index.spec.tsx | 89 +++++++++++++++++-- .../action-menu/__tests__/index.spec.tsx | 48 ++++++++++ .../action-menu/action-menu-item.tsx | 23 ++++- .../action-menu/index.tsx | 2 +- .../action-menu/use-action-menu.ts | 8 +- .../panel/version-history-panel/index.tsx | 24 ++++- web/i18n/ar-TN/billing.json | 2 + web/i18n/de-DE/billing.json | 2 + web/i18n/en-US/billing.json | 2 + web/i18n/es-ES/billing.json | 2 + web/i18n/fa-IR/billing.json | 2 + web/i18n/fr-FR/billing.json | 2 + web/i18n/hi-IN/billing.json | 2 + web/i18n/id-ID/billing.json | 2 + web/i18n/it-IT/billing.json | 2 + web/i18n/ja-JP/billing.json | 2 + web/i18n/ko-KR/billing.json | 2 + web/i18n/nl-NL/billing.json | 2 + web/i18n/pl-PL/billing.json | 2 + web/i18n/pt-BR/billing.json | 2 + web/i18n/ro-RO/billing.json | 2 + web/i18n/ru-RU/billing.json | 2 + web/i18n/sl-SI/billing.json | 2 + web/i18n/th-TH/billing.json | 2 + web/i18n/tr-TR/billing.json | 2 + web/i18n/uk-UA/billing.json | 2 + web/i18n/vi-VN/billing.json | 2 + web/i18n/zh-Hans/billing.json | 2 + web/i18n/zh-Hant/billing.json | 2 + 31 files changed, 284 insertions(+), 14 deletions(-) diff --git a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx index 87e1db69bf0..ebf8afa6d8d 100644 --- a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx @@ -1,5 +1,6 @@ import type { VersionHistory } from '@/types/workflow' -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' +import { Plan } from '@/app/components/billing/type' import { FlowType } from '@/types/common' import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' import { WorkflowVersion } from '../../types' @@ -10,6 +11,15 @@ const mockInvalidAllLastRun = vi.fn() const mockResetWorkflowVersionHistory = vi.fn() const mockHandleLoadBackupDraft = vi.fn() const mockHandleRefreshWorkflowDraft = vi.fn() +let mockPlanType = Plan.professional +let mockEnableBilling = true + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { type: mockPlanType }, + enableBilling: mockEnableBilling, + }), +})) vi.mock('@/hooks/use-theme', () => ({ default: () => ({ @@ -75,6 +85,8 @@ const createVersion = (overrides: Partial = {}): VersionHistory describe('HeaderInRestoring', () => { beforeEach(() => { vi.clearAllMocks() + mockPlanType = Plan.professional + mockEnableBilling = true }) it('should disable restore when the flow id is not ready yet', () => { @@ -125,4 +137,26 @@ describe('HeaderInRestoring', () => { expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled() }) + + it('should show plan upgrade modal instead of restoring when sandbox users click restore', () => { + mockPlanType = Plan.sandbox + renderWorkflowComponent(, { + initialStoreState: { + currentVersion: createVersion(), + }, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + + expect(screen.getByText('billing.upgrade.workflowRestore.title')).toBeInTheDocument() + expect(mockRestoreWorkflow).not.toHaveBeenCalled() + expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index bb3753ccfb4..b098bed6018 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -4,9 +4,13 @@ import { toast } from '@langgenius/dify-ui/toast' import { RiHistoryLine } from '@remixicon/react' import { useCallback, + useState, } from 'react' import { useTranslation } from 'react-i18next' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' +import { Plan } from '@/app/components/billing/type' import { useSelector as useAppContextSelector } from '@/context/app-context' +import { useProviderContext } from '@/context/provider-context' import useTheme from '@/hooks/use-theme' import { useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow } from '@/service/use-workflow' import { FlowType } from '@/types/common' @@ -32,6 +36,8 @@ const HeaderInRestoring = ({ }: HeaderInRestoringProps) => { const { t } = useTranslation() const { theme } = useTheme() + const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false) + const { plan, enableBilling } = useProviderContext() const workflowStore = useWorkflowStore() const userProfile = useAppContextSelector(s => s.userProfile) const configsMap = useHooksStore(s => s.configsMap) @@ -49,6 +55,7 @@ const HeaderInRestoring = ({ const { mutateAsync: restoreWorkflow } = useRestoreWorkflow() const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft + const canUseWorkflowVersionAction = !enableBilling || plan.type !== Plan.sandbox const canEmitCollaborationEvents = configsMap?.flowType === FlowType.appFlow const handleCancelRestore = useCallback(() => { @@ -116,6 +123,11 @@ const HeaderInRestoring = ({ if (!canRestore || !currentVersion) return + if (!canUseWorkflowVersionAction) { + setIsRestorePlanUpgradeModalOpen(true) + return + } + setShowWorkflowVersionHistoryPanel(false) await emitRestoreIntent() @@ -138,7 +150,7 @@ const HeaderInRestoring = ({ resetWorkflowVersionHistory() onRestoreSettled?.() } - }, [canRestore, currentVersion, setShowWorkflowVersionHistoryPanel, emitRestoreIntent, restoreWorkflow, restoreVersionUrl, workflowStore, handleRefreshWorkflowDraft, t, deleteAllInspectVars, invalidAllLastRun, emitRestoreComplete, emitWorkflowUpdate, resetWorkflowVersionHistory, onRestoreSettled]) + }, [canRestore, currentVersion, canUseWorkflowVersionAction, setShowWorkflowVersionHistoryPanel, emitRestoreIntent, restoreWorkflow, restoreVersionUrl, workflowStore, handleRefreshWorkflowDraft, t, deleteAllInspectVars, invalidAllLastRun, emitRestoreComplete, emitWorkflowUpdate, resetWorkflowVersionHistory, onRestoreSettled]) return ( <> @@ -170,6 +182,14 @@ const HeaderInRestoring = ({ + {isRestorePlanUpgradeModalOpen && ( + setIsRestorePlanUpgradeModalOpen(false)} + title={t('upgrade.workflowRestore.title', { ns: 'billing' })!} + description={t('upgrade.workflowRestore.description', { ns: 'billing' })!} + /> + )} ) } diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx index 3c9f7dba0ea..3bd17d4947f 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx @@ -1,16 +1,23 @@ import type { Shape } from '../../../store' import type { VersionHistory } from '@/types/workflow' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' +import { Plan } from '@/app/components/billing/type' import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types' const mockHandleRestoreFromPublishedWorkflow = vi.fn() const mockHandleLoadBackupDraft = vi.fn() const mockHandleRefreshWorkflowDraft = vi.fn() +const mockHandleExportDSL = vi.fn() const mockRestoreWorkflow = vi.fn() const mockSetCurrentVersion = vi.fn() const mockSetShowWorkflowVersionHistoryPanel = vi.fn() const mockWorkflowStoreSetState = vi.fn() +const mockEmitRestoreIntent = vi.fn() +const mockEmitRestoreComplete = vi.fn() +const mockEmitWorkflowUpdate = vi.fn() +let mockPlanType = Plan.professional +let mockEnableBilling = true const createVersionHistory = (overrides: Partial = {}): VersionHistory => ({ id: 'version-id', @@ -56,6 +63,13 @@ vi.mock('@/context/app-context', () => ({ useSelector: () => ({ id: 'test-user-id' }), })) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { type: mockPlanType }, + enableBilling: mockEnableBilling, + }), +})) + vi.mock('@/service/use-workflow', () => ({ useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }), useInvalidAllLastRun: () => vi.fn(), @@ -88,7 +102,7 @@ vi.mock('@/service/use-workflow', () => ({ })) vi.mock('../../../hooks', () => ({ - useDSL: () => ({ handleExportDSL: vi.fn() }), + useDSL: () => ({ handleExportDSL: mockHandleExportDSL }), useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }), useWorkflowRun: () => ({ handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow, @@ -103,6 +117,14 @@ vi.mock('../../../hooks-store', () => ({ }), })) +vi.mock('../../../collaboration/core/collaboration-manager', () => ({ + collaborationManager: { + emitRestoreIntent: mockEmitRestoreIntent, + emitRestoreComplete: mockEmitRestoreComplete, + emitWorkflowUpdate: mockEmitWorkflowUpdate, + }, +})) + vi.mock('../../../store', () => ({ useStore: (selector: (state: MockVersionStoreState) => T) => { const state: MockVersionStoreState = { @@ -149,19 +171,27 @@ vi.mock('../version-history-item', () => ({ default: (props: MockVersionHistoryItemProps) => { const MockVersionHistoryItem = () => { const { item, onClick, handleClickActionMenuItem } = props + const didSelectDraftRef = useRef(false) useEffect(() => { - if (item.version === WorkflowVersion.Draft) + if (item.version === WorkflowVersion.Draft && !didSelectDraftRef.current) { + didSelectDraftRef.current = true onClick(item) + } }, [item, onClick]) return (
{item.version !== WorkflowVersion.Draft && ( - + <> + + + )}
) @@ -174,7 +204,10 @@ vi.mock('../version-history-item', () => ({ describe('VersionHistoryPanel', () => { beforeEach(() => { vi.clearAllMocks() + mockRestoreWorkflow.mockResolvedValue(undefined) mockCurrentVersion = null + mockPlanType = Plan.professional + mockEnableBilling = true }) describe('Version Click Behavior', () => { @@ -221,6 +254,9 @@ describe('VersionHistoryPanel', () => { />, ) + await waitFor(() => { + expect(mockHandleLoadBackupDraft).toHaveBeenCalled() + }) vi.clearAllMocks() fireEvent.click(screen.getByText('restore-published-version-id')) @@ -237,9 +273,47 @@ describe('VersionHistoryPanel', () => { }) }) + it('should show plan upgrade modal instead of restore confirmation for sandbox users', async () => { + const { VersionHistoryPanel } = await import('../index') + mockPlanType = Plan.sandbox + + render( + `/apps/app-1/workflows/${versionId}/restore`} + />, + ) + + vi.clearAllMocks() + + fireEvent.click(screen.getByText('restore-published-version-id')) + + expect(screen.getByText('billing.upgrade.workflowRestore.title')).toBeInTheDocument() + expect(screen.queryByText('confirm restore')).not.toBeInTheDocument() + expect(mockRestoreWorkflow).not.toHaveBeenCalled() + }) + + it('should show plan upgrade modal instead of exporting DSL for sandbox users', async () => { + const { VersionHistoryPanel } = await import('../index') + mockPlanType = Plan.sandbox + + render( + `/apps/app-1/workflows/${versionId}/restore`} + />, + ) + + vi.clearAllMocks() + + fireEvent.click(screen.getByText('export-published-version-id')) + + expect(screen.getByText('billing.upgrade.workflowRestore.title')).toBeInTheDocument() + expect(mockHandleExportDSL).not.toHaveBeenCalled() + }) + it('should keep restore mode backup state when restore request fails', async () => { const { VersionHistoryPanel } = await import('../index') - mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed')) mockCurrentVersion = createVersionHistory({ id: 'draft-version-id', version: WorkflowVersion.Draft, @@ -253,6 +327,7 @@ describe('VersionHistoryPanel', () => { ) vi.clearAllMocks() + mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed')) fireEvent.click(screen.getByText('restore-published-version-id')) fireEvent.click(screen.getByText('confirm restore')) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx index 42952f373b8..5a633f36bcc 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx @@ -1,10 +1,35 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { Plan } from '@/app/components/billing/type' import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env' import { VersionHistoryContextMenuOptions } from '../../../../types' import ActionMenu from '../index' +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_CLOUD_EDITION: true, + } +}) + +let mockPlanType = Plan.professional +let mockEnableBilling = true + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { type: mockPlanType }, + enableBilling: mockEnableBilling, + }), +})) + describe('ActionMenu', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPlanType = Plan.professional + mockEnableBilling = true + }) + it('toggles the trigger and forwards menu clicks', async () => { const user = userEvent.setup() const setOpen = vi.fn() @@ -34,4 +59,27 @@ describe('ActionMenu', () => { VersionHistoryContextMenuOptions.delete, ) }) + + it('shows upgrade buttons beside restore and export for sandbox users', async () => { + const user = userEvent.setup() + const handleClickActionMenuItem = vi.fn() + mockPlanType = Plan.sandbox + + renderWorkflowComponent( + , + ) + + const upgradeButtons = screen.getAllByRole('button', { name: 'billing.upgradeBtn.encourageShort' }) + expect(upgradeButtons).toHaveLength(2) + + await user.click(upgradeButtons[0]!) + + expect(handleClickActionMenuItem).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx index 7d24e812177..a1fc8673eff 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx @@ -3,11 +3,13 @@ import type { VersionHistoryContextMenuOptions } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' type ActionMenuItemProps = { item: { key: VersionHistoryContextMenuOptions name: string + showUpgrade?: boolean } onClick: (operation: VersionHistoryContextMenuOptions) => void isDestructive?: boolean @@ -22,21 +24,38 @@ const ActionMenuItem: FC = ({ { event.stopPropagation() + const target = event.target + if (target instanceof Element && target.closest('[data-upgrade-action]')) + return + onClick(item.key) }} >
{item.name}
+ {item.showUpgrade && ( +
+ +
+ )}
) } diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx index 4af7f9bc52b..81f04f31ea8 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx @@ -41,7 +41,7 @@ const ActionMenu: FC = (props: ActionMenuProps) => { { options.map(option => ( diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts index 4a81809aeb2..765816427a4 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts @@ -1,7 +1,9 @@ import type { ActionMenuProps } from './index' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { Plan } from '@/app/components/billing/type' import { useStore } from '@/app/components/workflow/store' +import { useProviderContext } from '@/context/provider-context' import { VersionHistoryContextMenuOptions } from '../../../types' const useActionMenu = (props: ActionMenuProps) => { @@ -10,6 +12,8 @@ const useActionMenu = (props: ActionMenuProps) => { } = props const { t } = useTranslation() const pipelineId = useStore(s => s.pipelineId) + const { plan, enableBilling } = useProviderContext() + const shouldShowUpgrade = enableBilling && plan.type === Plan.sandbox const deleteOperation = { key: VersionHistoryContextMenuOptions.delete, @@ -21,6 +25,7 @@ const useActionMenu = (props: ActionMenuProps) => { { key: VersionHistoryContextMenuOptions.restore, name: t('common.restore', { ns: 'workflow' }), + ...(shouldShowUpgrade ? { showUpgrade: true } : {}), }, isNamedVersion ? { @@ -36,6 +41,7 @@ const useActionMenu = (props: ActionMenuProps) => { ? [{ key: VersionHistoryContextMenuOptions.exportDSL, name: t('export', { ns: 'app' }), + ...(shouldShowUpgrade ? { showUpgrade: true } : {}), }] : []), { @@ -43,7 +49,7 @@ const useActionMenu = (props: ActionMenuProps) => { name: t('versionHistory.copyId', { ns: 'workflow' }), }, ] - }, [isNamedVersion, pipelineId, t]) + }, [isNamedVersion, pipelineId, shouldShowUpgrade, t]) return { deleteOperation, diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index 568d12fd959..05c7dce7290 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -8,7 +8,10 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal' import Divider from '@/app/components/base/divider' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' +import { Plan } from '@/app/components/billing/type' import { useSelector as useAppContextSelector } from '@/context/app-context' +import { useProviderContext } from '@/context/provider-context' import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks' import { useHooksStore } from '../../hooks-store' @@ -43,8 +46,11 @@ export const VersionHistoryPanel = ({ const [isOnlyShowNamedVersions, setIsOnlyShowNamedVersions] = useState(false) const [operatedItem, setOperatedItem] = useState() const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) + const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [editModalOpen, setEditModalOpen] = useState(false) + const { plan, enableBilling } = useProviderContext() + const canUseWorkflowVersionAction = !enableBilling || plan.type !== Plan.sandbox const workflowStore = useWorkflowStore() const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun() const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() @@ -111,6 +117,10 @@ export const VersionHistoryPanel = ({ setOperatedItem(item) switch (operation) { case VersionHistoryContextMenuOptions.restore: + if (!canUseWorkflowVersionAction) { + setIsRestorePlanUpgradeModalOpen(true) + break + } setRestoreConfirmOpen(true) break case VersionHistoryContextMenuOptions.edit: @@ -124,10 +134,14 @@ export const VersionHistoryPanel = ({ toast.success(t('versionHistory.action.copyIdSuccess', { ns: 'workflow' })) break case VersionHistoryContextMenuOptions.exportDSL: + if (!canUseWorkflowVersionAction) { + setIsRestorePlanUpgradeModalOpen(true) + break + } handleExportDSL?.(false, item.id) break } - }, [t, handleExportDSL]) + }, [canUseWorkflowVersionAction, t, handleExportDSL]) const handleCancel = useCallback((operation: VersionHistoryContextMenuOptions) => { switch (operation) { @@ -330,6 +344,14 @@ export const VersionHistoryPanel = ({ onRestore={handleRestore} /> )} + {isRestorePlanUpgradeModalOpen && ( + setIsRestorePlanUpgradeModalOpen(false)} + title={t('upgrade.workflowRestore.title', { ns: 'billing' })!} + description={t('upgrade.workflowRestore.description', { ns: 'billing' })!} + /> + )} {deleteConfirmOpen && ( Date: Wed, 17 Jun 2026 16:33:51 +0800 Subject: [PATCH 12/62] fix(web): prevent workspace trigger focus ring clipping (#37576) --- web/app/components/main-nav/components/workspace-card.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/app/components/main-nav/components/workspace-card.tsx b/web/app/components/main-nav/components/workspace-card.tsx index 09d521bd448..7bd78fb5a78 100644 --- a/web/app/components/main-nav/components/workspace-card.tsx +++ b/web/app/components/main-nav/components/workspace-card.tsx @@ -118,6 +118,7 @@ function WorkspaceCardTrigger({ title={name} className={cn( 'flex w-full items-center gap-1.5 py-1.5 pr-3 pl-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden focus-visible:ring-inset', + showCloudBilling ? 'rounded-t-xl' : 'rounded-xl', open && 'bg-linear-to-b from-background-section-burn to-background-section', )} > From 0ea0647dd0e04b2a3a9e92e5f23dd0898ad6a487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?= Date: Wed, 17 Jun 2026 18:27:38 +0900 Subject: [PATCH 13/62] feat(agent): wire knowledge base retrieval into runtime (#37577) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/clients/agent_backend/__init__.py | 2 + api/clients/agent_backend/request_builder.py | 35 +- api/controllers/inner_api/__init__.py | 4 +- .../inner_api/knowledge/__init__.py | 1 + .../inner_api/knowledge/retrieval.py | 110 +++++ api/controllers/inner_api/wraps.py | 32 +- .../apps/agent_app/runtime_request_builder.py | 9 +- api/core/rag/retrieval/dataset_retrieval.py | 2 +- .../agent_v2/runtime_feature_manifest.py | 16 +- .../nodes/agent_v2/runtime_request_builder.py | 54 ++- .../entities/knowledge_retrieval_inner.py | 210 +++++++++ api/services/errors/knowledge_retrieval.py | 49 ++ api/services/external_knowledge_service.py | 54 ++- .../knowledge_retrieval_inner_service.py | 145 ++++++ .../agent_backend/test_request_builder.py | 40 ++ .../controllers/inner_api/test_auth_wraps.py | 52 +++ .../inner_api/test_knowledge_retrieval.py | 233 ++++++++++ .../agent_app/test_runtime_request_builder.py | 34 ++ ...test_dataset_retrieval_attachment_entry.py | 36 ++ .../agent_v2/test_runtime_request_builder.py | 146 ++++++ .../services/test_external_dataset_service.py | 83 +++- .../test_knowledge_retrieval_inner_service.py | 218 +++++++++ .../layers/execution_context/__init__.py | 3 +- .../layers/execution_context/configs.py | 10 +- .../layers/execution_context/layer.py | 5 +- .../dify_agent/layers/knowledge/__init__.py | 27 ++ .../src/dify_agent/layers/knowledge/client.py | 214 +++++++++ .../dify_agent/layers/knowledge/configs.py | 200 +++++++++ .../src/dify_agent/layers/knowledge/layer.py | 285 ++++++++++++ .../dify_agent/runtime/compositor_factory.py | 34 +- .../src/dify_agent/runtime/run_scheduler.py | 4 + dify-agent/src/dify_agent/runtime/runner.py | 23 +- dify-agent/src/dify_agent/server/app.py | 58 ++- dify-agent/src/dify_agent/server/settings.py | 38 +- .../dify_agent/layers/knowledge/__init__.py | 0 .../layers/knowledge/test_client.py | 248 +++++++++++ .../layers/knowledge/test_configs.py | 65 +++ .../dify_agent/layers/knowledge/test_layer.py | 417 ++++++++++++++++++ .../dify_agent/runtime/test_run_scheduler.py | 20 +- .../local/dify_agent/runtime/test_runner.py | 138 ++++++ .../tests/local/dify_agent/server/test_app.py | 126 +++++- .../local/dify_agent/server/test_settings.py | 9 +- .../dify_agent/test_import_boundaries.py | 4 + 43 files changed, 3360 insertions(+), 133 deletions(-) create mode 100644 api/controllers/inner_api/knowledge/__init__.py create mode 100644 api/controllers/inner_api/knowledge/retrieval.py create mode 100644 api/services/entities/knowledge_retrieval_inner.py create mode 100644 api/services/errors/knowledge_retrieval.py create mode 100644 api/services/knowledge_retrieval_inner_service.py create mode 100644 api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py create mode 100644 api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_attachment_entry.py create mode 100644 api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py create mode 100644 dify-agent/src/dify_agent/layers/knowledge/__init__.py create mode 100644 dify-agent/src/dify_agent/layers/knowledge/client.py create mode 100644 dify-agent/src/dify_agent/layers/knowledge/configs.py create mode 100644 dify-agent/src/dify_agent/layers/knowledge/layer.py create mode 100644 dify-agent/tests/local/dify_agent/layers/knowledge/__init__.py create mode 100644 dify-agent/tests/local/dify_agent/layers/knowledge/test_client.py create mode 100644 dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py create mode 100644 dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py index 238c48a9de3..b9032c521eb 100644 --- a/api/clients/agent_backend/__init__.py +++ b/api/clients/agent_backend/__init__.py @@ -33,6 +33,7 @@ from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAge from clients.agent_backend.request_builder import ( AGENT_SOUL_PROMPT_LAYER_ID, DIFY_EXECUTION_CONTEXT_LAYER_ID, + DIFY_KNOWLEDGE_BASE_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID, WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, WORKFLOW_USER_PROMPT_LAYER_ID, @@ -47,6 +48,7 @@ from clients.agent_backend.request_builder import ( __all__ = [ "AGENT_SOUL_PROMPT_LAYER_ID", "DIFY_EXECUTION_CONTEXT_LAYER_ID", + "DIFY_KNOWLEDGE_BASE_LAYER_ID", "DIFY_PLUGIN_TOOLS_LAYER_ID", "WORKFLOW_NODE_JOB_PROMPT_LAYER_ID", "WORKFLOW_USER_PROMPT_LAYER_ID", diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 55944929ddc..c245a09e970 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -32,6 +32,7 @@ from dify_agent.layers.execution_context import ( DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig, ) +from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.protocol import ( @@ -55,6 +56,7 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt" DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" DIFY_DRIVE_LAYER_ID = "drive" DIFY_PLUGIN_TOOLS_LAYER_ID = "tools" +DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge" DIFY_ASK_HUMAN_LAYER_ID = "ask_human" DIFY_SHELL_LAYER_ID = "shell" @@ -139,6 +141,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + knowledge: DifyKnowledgeBaseLayerConfig | None = None # Drive Skills & Files declaration (dify.drive) — an index the agent pulls # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. drive_config: DifyDriveLayerConfig | None = None @@ -185,6 +188,7 @@ class AgentBackendAgentAppRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + knowledge: DifyKnowledgeBaseLayerConfig | None = None # Drive Skills & Files declaration (dify.drive) — an index the agent pulls # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. drive_config: DifyDriveLayerConfig | None = None @@ -221,7 +225,7 @@ class AgentBackendRunRequestBuilder: Layer graph: optional Agent Soul system prompt → user prompt → execution context → optional history (multi-turn) → LLM → optional - plugin tools → optional structured output. Mirrors the workflow-node + plugin tools / knowledge search → optional structured output. Mirrors the workflow-node layer ordering minus the workflow-job / previous-node prompt. """ layers: list[RunLayerSpec] = [] @@ -300,6 +304,17 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.knowledge is not None and run_input.knowledge.dataset_ids: + layers.append( + RunLayerSpec( + name=DIFY_KNOWLEDGE_BASE_LAYER_ID, + type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + metadata=run_input.metadata, + config=run_input.knowledge, + ) + ) + if run_input.ask_human_config is not None: # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends # the run with a deferred_tool_call; the caller pauses (workflow HITL) and @@ -398,7 +413,12 @@ class AgentBackendRunRequestBuilder: ) def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest: - """Build a workflow Agent Node run request without defining another wire schema.""" + """Build a workflow Agent Node run request without defining another wire schema. + + Layer graph mirrors the workflow surface: prompts → execution context → + optional drive/history → LLM → optional plugin tools / knowledge search + → optional auxiliary layers such as ask_human, shell, and structured output. + """ layers: list[RunLayerSpec] = [] if run_input.agent_soul_prompt: layers.append( @@ -483,6 +503,17 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.knowledge is not None and run_input.knowledge.dataset_ids: + layers.append( + RunLayerSpec( + name=DIFY_KNOWLEDGE_BASE_LAYER_ID, + type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + metadata=run_input.metadata, + config=run_input.knowledge, + ) + ) + if run_input.ask_human_config is not None: # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends # the run with a deferred_tool_call; the caller pauses (workflow HITL) and diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index c782c93ffd4..c0e079eeb2c 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -9,7 +9,7 @@ api = ExternalApi( bp, version="1.0", title="Inner API", - description="Internal APIs for enterprise features, billing, and plugin communication", + description="Internal APIs for enterprise features, billing, knowledge retrieval, and plugin communication", ) # Create namespace @@ -17,6 +17,7 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat from . import mail as _mail from .app import dsl as _app_dsl +from .knowledge import retrieval as _knowledge_retrieval from .plugin import agent_drive as _agent_drive from .plugin import plugin as _plugin from .workspace import workspace as _workspace @@ -26,6 +27,7 @@ api.add_namespace(inner_api_ns) __all__ = [ "_agent_drive", "_app_dsl", + "_knowledge_retrieval", "_mail", "_plugin", "_workspace", diff --git a/api/controllers/inner_api/knowledge/__init__.py b/api/controllers/inner_api/knowledge/__init__.py new file mode 100644 index 00000000000..20c447fa778 --- /dev/null +++ b/api/controllers/inner_api/knowledge/__init__.py @@ -0,0 +1 @@ +"""Inner knowledge retrieval endpoints.""" diff --git a/api/controllers/inner_api/knowledge/retrieval.py b/api/controllers/inner_api/knowledge/retrieval.py new file mode 100644 index 00000000000..ef33fbda518 --- /dev/null +++ b/api/controllers/inner_api/knowledge/retrieval.py @@ -0,0 +1,110 @@ +"""Inner API endpoint for tenant-scoped knowledge retrieval. + +This controller is a thin HTTP wrapper around +``services.knowledge_retrieval_inner_service.InnerKnowledgeRetrievalService``. +It intentionally keeps authorization simple: shared inner API key plus +tenant-scoped app/dataset validation in the service layer. +""" + +from flask_restx import Resource +from pydantic import ValidationError + +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import inner_api_only +from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc +from libs.exception import BaseHTTPException +from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse +from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError, InnerKnowledgeRetrievalServiceError +from services.knowledge_retrieval_inner_service import InnerKnowledgeRetrievalService + + +class InnerKnowledgeRetrievalHttpError(BaseHTTPException): + error_code = "knowledge_retrieve_failed" + description = "Knowledge retrieval failed." + code = 500 + + def __init__( + self, + *, + error_code: str | None = None, + description: str | None = None, + status_code: int | None = None, + ) -> None: + if error_code is not None: + self.error_code = error_code + if description is not None: + self.description = description + if status_code is not None: + self.code = status_code + super().__init__(self.description) + + +register_schema_models(inner_api_ns, InnerKnowledgeRetrieveRequest) +register_response_schema_models(inner_api_ns, InnerKnowledgeRetrieveResponse) + + +@inner_api_ns.route("/knowledge/retrieve") +class InnerKnowledgeRetrieveApi(Resource): + """Retrieve knowledge from one or more datasets within the caller tenant.""" + + @inner_api_only + @inner_api_ns.doc("inner_knowledge_retrieve") + @inner_api_ns.doc(description="Retrieve knowledge for trusted internal callers") + @inner_api_ns.expect(inner_api_ns.models[InnerKnowledgeRetrieveRequest.__name__]) + @inner_api_ns.response( + 200, + "Knowledge retrieved successfully", + inner_api_ns.models[InnerKnowledgeRetrieveResponse.__name__], + ) + @inner_api_ns.doc( + responses={ + 400: "Invalid request body", + 401: "Unauthorized - invalid inner API key", + 403: "Caller tenant does not own the requested resource", + 404: "App or dataset not found", + 422: "Invalid retrieval configuration", + 429: "Knowledge retrieval rate limited", + 502: "External knowledge retrieval failed", + 500: "Unexpected knowledge retrieval failure", + } + ) + def post(self) -> dict[str, object]: + """Validate the payload, run retrieval, and return workflow-style sources.""" + try: + payload = InnerKnowledgeRetrieveRequest.model_validate(inner_api_ns.payload or {}) + except ValidationError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code="invalid_request", + description=str(exc), + status_code=400, + ) from exc + + try: + response = InnerKnowledgeRetrievalService().retrieve(payload) + except InnerKnowledgeRetrievalServiceError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code=exc.error_code, + description=exc.description, + status_code=exc.status_code, + ) from exc + except retrieval_exc.RateLimitExceededError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code="knowledge_rate_limited", + description=str(exc), + status_code=429, + ) from exc + except ExternalKnowledgeRetrievalError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code="external_knowledge_failed", + description=str(exc), + status_code=502, + ) from exc + except ValueError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code="retrieval_config_invalid", + description=str(exc), + status_code=422, + ) from exc + + return response.model_dump(mode="json", by_alias=True) diff --git a/api/controllers/inner_api/wraps.py b/api/controllers/inner_api/wraps.py index 95181b93cfa..999932c98e1 100644 --- a/api/controllers/inner_api/wraps.py +++ b/api/controllers/inner_api/wraps.py @@ -8,39 +8,39 @@ from flask import abort, request from configs import dify_config from core.db.session_factory import session_factory +from libs.exception import BaseHTTPException from models.model import EndUser -def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: +class InnerApiUnauthorizedError(BaseHTTPException): + error_code = "inner_api_unauthorized" + description = "Unauthorized." + code = 401 + + +def inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: + """Restrict access to callers authenticated with the shared inner API key.""" + @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs) -> R: if not dify_config.INNER_API: abort(404) - # get header 'X-Inner-Api-Key' inner_api_key = request.headers.get("X-Inner-Api-Key") if not inner_api_key or inner_api_key != dify_config.INNER_API_KEY: - abort(401) + raise InnerApiUnauthorizedError() return view(*args, **kwargs) return decorated +def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: + return inner_api_only(view) + + def enterprise_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: - @wraps(view) - def decorated(*args: P.args, **kwargs: P.kwargs) -> R: - if not dify_config.INNER_API: - abort(404) - - # get header 'X-Inner-Api-Key' - inner_api_key = request.headers.get("X-Inner-Api-Key") - if not inner_api_key or inner_api_key != dify_config.INNER_API_KEY: - abort(401) - - return view(*args, **kwargs) - - return decorated + return inner_api_only(view) def enterprise_inner_api_user_auth[**P, R](view: Callable[P, R]) -> Callable[P, R]: diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index 71cc0385f97..01206b12db6 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -2,8 +2,10 @@ Mirrors the workflow ``WorkflowAgentRuntimeRequestBuilder`` but for the Agent App surface: the user prompt is the chat message (no workflow-node job / no -previous-node context), and multi-turn continuity flows through the -conversation-keyed ``session_snapshot`` plus the history layer. +previous-node context), multi-turn continuity flows through the +conversation-keyed ``session_snapshot`` plus the history layer, and Agent Soul +knowledge config is mapped into the same fixed ``dify.knowledge_base`` layer +used by workflow runs. """ from __future__ import annotations @@ -36,6 +38,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import ( append_runtime_warnings, build_ask_human_layer_config, build_drive_layer_config, + build_knowledge_layer_config, build_shell_layer_config, ) from models.agent_config_entities import AgentSoulConfig @@ -123,6 +126,7 @@ class AgentAppRuntimeRequestBuilder: if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id) append_runtime_warnings(metadata, drive_warnings) + knowledge_config = build_knowledge_layer_config(agent_soul) request = self._request_builder.build_for_agent_app( AgentBackendAgentAppRunInput( @@ -156,6 +160,7 @@ class AgentAppRuntimeRequestBuilder: or None, user_prompt=context.user_query, tools=tools_layer, + knowledge=knowledge_config, drive_config=drive_config, ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index f4e850d34ed..474c9f90c78 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -123,7 +123,7 @@ class DatasetRetrieval: if not available_datasets_ids: return [] - if not request.query: + if not request.query and not request.attachment_ids: return [] metadata_filter_document_ids, metadata_condition = None, None diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py index 8e0578d1a15..65c5d42e916 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -13,6 +13,7 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( "structured_output", "tools.dify_tools", "tools.cli_tools", + "knowledge", "env", "sandbox", # ENG-623: exposed at runtime as the dify.drive declaration layer @@ -26,7 +27,6 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( RESERVED_AGENT_BACKEND_FEATURES = frozenset( { - "knowledge", "memory", } ) @@ -80,6 +80,9 @@ def build_runtime_feature_manifest( ) reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed") + reserved_status["knowledge"] = ( + "supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured" + ) reserved_status["skills_files"] = ( "supported_by_drive_manifest" if drive_manifest_enabled else "drive_manifest_disabled" ) @@ -97,6 +100,17 @@ def build_runtime_feature_manifest( } +def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]: + """Return the normalized knowledge dataset ids that can produce a runtime layer. + + ``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()`` + must stay aligned: both decide knowledge support from this effective, + non-blank dataset-id set rather than from raw + ``agent_soul.knowledge.datasets`` entries. + """ + return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())] + + def _get_nested(value: dict[str, Any], path: str) -> Any: current: Any = value for part in path.split("."): diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 53c657e8ef9..8aaa4fcc1d3 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -16,6 +16,7 @@ from dify_agent.layers.execution_context import ( DifyExecutionContextLayerConfig, DifyExecutionContextUserFrom, ) +from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig from dify_agent.layers.shell import ( DifyShellCliToolConfig, DifyShellEnvVarConfig, @@ -40,6 +41,7 @@ from graphon.file import FileTransferMethod from graphon.variables.segments import Segment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( + AgentKnowledgeQueryConfig, AgentSoulConfig, DeclaredArrayItem, DeclaredOutputChildConfig, @@ -60,6 +62,7 @@ from services.agent.prompt_mentions import ( from .output_failure_orchestrator import retry_idempotency_key from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError +from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids _DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"}) _DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation") @@ -69,7 +72,6 @@ _DANGEROUS_ACK_KEYS = ( "risk_accepted", "approved", ) -from .runtime_feature_manifest import build_runtime_feature_manifest class WorkflowAgentRuntimeRequestBuildError(ValueError): @@ -183,6 +185,7 @@ class WorkflowAgentRuntimeRequestBuilder: if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id) append_runtime_warnings(metadata, drive_warnings) + knowledge_config = build_knowledge_layer_config(agent_soul) request = self._request_builder.build_for_workflow_node( AgentBackendWorkflowNodeRunInput( @@ -197,10 +200,11 @@ class WorkflowAgentRuntimeRequestBuilder: model_settings=agent_soul.model.model_settings.model_dump(mode="json", exclude_none=True), ), # The execution-context layer is now the only public protocol - # carrier for Dify tenant/user/run identifiers. ``user_id`` must - # be forwarded here because downstream plugin-daemon provider and - # tool clients read it from this layer rather than from any - # parallel top-level request field. + # carrier for Dify tenant/user/run identifiers. ``user_id`` and + # ``user_from`` must be forwarded here because downstream plugin- + # daemon provider/tool clients and knowledge-base layers read + # caller identity from this layer rather than from any parallel + # top-level request field. execution_context=DifyExecutionContextLayerConfig( tenant_id=context.dify_context.tenant_id, user_id=context.dify_context.user_id, @@ -221,6 +225,7 @@ class WorkflowAgentRuntimeRequestBuilder: user_prompt=user_prompt, output=self._build_output_config(node_job.declared_outputs), tools=tools_layer, + knowledge=knowledge_config, drive_config=drive_config, ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, @@ -534,6 +539,45 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi ) +def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None: + """Map Agent Soul knowledge config into the fixed Dify knowledge-base layer. + + Normalization intentionally matches the current dify-agent runtime contract: + + - blank or missing dataset ids are ignored; + - if no valid dataset ids remain, no knowledge layer is injected; + - retrieval mode is always forced to ``multiple`` in this first wiring pass; + - ``top_k`` falls back to a stable runtime default when the soul omits it; + - ``score_threshold`` is only forwarded when the product config explicitly + enables it, otherwise the layer keeps the disabled/default ``0.0`` value; + - metadata filtering stays at the layer DTO default (disabled). + """ + dataset_ids = list_configured_knowledge_dataset_ids(agent_soul) + if not dataset_ids: + return None + + query_config = agent_soul.knowledge.query_config + return DifyKnowledgeBaseLayerConfig( + dataset_ids=dataset_ids, + retrieval=DifyKnowledgeRetrievalConfig( + mode="multiple", + top_k=_knowledge_top_k(query_config), + score_threshold=_knowledge_score_threshold(query_config), + ), + ) + + +def _knowledge_top_k(query_config: AgentKnowledgeQueryConfig) -> int: + top_k = query_config.top_k + return top_k if isinstance(top_k, int) and top_k >= 1 else 4 + + +def _knowledge_score_threshold(query_config: AgentKnowledgeQueryConfig) -> float: + if query_config.score_threshold_enabled and query_config.score_threshold is not None: + return query_config.score_threshold + return 0.0 + + def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None: """Enable the dify.ask_human deferred tool when the soul configures human involvement. diff --git a/api/services/entities/knowledge_retrieval_inner.py b/api/services/entities/knowledge_retrieval_inner.py new file mode 100644 index 00000000000..86276b80177 --- /dev/null +++ b/api/services/entities/knowledge_retrieval_inner.py @@ -0,0 +1,210 @@ +"""DTOs for the inner knowledge retrieval API. + +These models define the stable HTTP contract for trusted internal callers and +the response shape returned by the workflow knowledge retrieval stack. + +Key cross-field invariants live here because callers cannot infer them from +scalar field types alone: ``dataset_ids`` must be non-empty, either ``query`` +or ``attachment_ids`` is required, ``single`` retrieval requires both ``query`` +and ``retrieval.model``, ``automatic`` metadata filtering requires +``model_config``, and ``manual`` metadata filtering requires conditions. The +response reuses workflow ``Source`` items plus serialized ``llm_usage``. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from core.rag.data_post_processor.data_post_processor import WeightsDict +from core.rag.entities.metadata_entities import SupportedComparisonOperator +from core.workflow.nodes.knowledge_retrieval.retrieval import Source +from fields.base import ResponseModel + +type JsonScalar = str | int | float | bool | None +type JsonValue = JsonScalar | list[JsonScalar] | dict[str, JsonScalar] +type MetadataValue = str | list[str] | int | float | None + + +class InnerKnowledgeRetrieveCaller(BaseModel): + """Execution context provided by the trusted internal caller.""" + + model_config = ConfigDict(extra="forbid") + + tenant_id: str = Field(min_length=1) + user_id: str = Field(min_length=1) + app_id: str = Field(min_length=1) + user_from: Literal["account", "end-user"] + invoke_from: str = Field(min_length=1) + + +class InnerKnowledgeRetrieveModelConfig(BaseModel): + """Model configuration used by single-retrieval or metadata filtering.""" + + model_config = ConfigDict(extra="forbid") + + provider: str = Field(min_length=1) + name: str = Field(min_length=1) + mode: str = Field(min_length=1) + completion_params: dict[str, JsonValue] = Field(default_factory=dict) + + +class InnerKnowledgeRetrieveRerankingModelConfig(BaseModel): + """Reranking model configuration for multiple retrieval mode.""" + + model_config = ConfigDict(extra="forbid") + + provider: str = Field(min_length=1) + model: str = Field(min_length=1) + + +class InnerKnowledgeRetrieveRetrievalConfig(BaseModel): + """Retrieval strategy and its mode-specific configuration.""" + + model_config = ConfigDict(extra="forbid") + + mode: Literal["multiple", "single"] + top_k: int | None = Field(default=None, ge=1) + score_threshold: float = 0.0 + reranking_mode: str = "reranking_model" + reranking_enable: bool = True + reranking_model: InnerKnowledgeRetrieveRerankingModelConfig | None = None + weights: WeightsDict | None = None + model: InnerKnowledgeRetrieveModelConfig | None = None + + @model_validator(mode="after") + def validate_mode_specific_fields(self) -> InnerKnowledgeRetrieveRetrievalConfig: + if self.mode == "single" and self.model is None: + raise ValueError("retrieval.model is required for single mode") + if self.mode == "multiple" and self.top_k is None: + raise ValueError("retrieval.top_k is required for multiple mode") + return self + + +class InnerKnowledgeRetrieveMetadataCondition(BaseModel): + """Single metadata filter condition.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1) + comparison_operator: SupportedComparisonOperator + value: MetadataValue = None + + +class InnerKnowledgeRetrieveMetadataConditions(BaseModel): + """Boolean composition for metadata filter conditions.""" + + model_config = ConfigDict(extra="forbid") + + logical_operator: Literal["and", "or"] | None = "and" + conditions: list[InnerKnowledgeRetrieveMetadataCondition] | None = None + + +class InnerKnowledgeRetrieveMetadataFilteringConfig(BaseModel): + """Metadata filtering configuration forwarded to workflow retrieval. + + ``automatic`` mode requires ``model_config`` so downstream metadata model + planning has the necessary LLM settings. ``manual`` mode requires + non-empty conditions because workflow retrieval expects explicit filters + instead of a bare mode switch. + """ + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + mode: Literal["disabled", "automatic", "manual"] = "disabled" + metadata_model_config: InnerKnowledgeRetrieveModelConfig | None = Field(default=None, alias="model_config") + conditions: InnerKnowledgeRetrieveMetadataConditions | None = None + + @model_validator(mode="after") + def validate_mode_specific_fields(self) -> InnerKnowledgeRetrieveMetadataFilteringConfig: + if self.mode == "automatic" and self.metadata_model_config is None: + raise ValueError("metadata_filtering.model_config is required for automatic mode") + if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions): + raise ValueError("metadata_filtering.conditions is required for manual mode") + return self + + +class InnerKnowledgeRetrieveRequest(BaseModel): + """Top-level request payload for the inner knowledge retrieval endpoint. + + Request validation enforces the endpoint's behavioral contract: callers + must provide at least one dataset ID, at least one of ``query`` or + ``attachment_ids``, and a text query for ``single`` retrieval mode. + """ + + model_config = ConfigDict(extra="forbid") + + caller: InnerKnowledgeRetrieveCaller + dataset_ids: list[str] + query: str | None = None + retrieval: InnerKnowledgeRetrieveRetrievalConfig + metadata_filtering: InnerKnowledgeRetrieveMetadataFilteringConfig = Field( + default_factory=InnerKnowledgeRetrieveMetadataFilteringConfig + ) + attachment_ids: list[str] = Field(default_factory=list) + + @field_validator("dataset_ids", "attachment_ids") + @classmethod + def validate_non_empty_items(cls, value: list[str]) -> list[str]: + if any(not item.strip() for item in value): + raise ValueError("list items must not be empty") + return value + + @field_validator("query") + @classmethod + def normalize_query(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + @model_validator(mode="after") + def validate_request(self) -> InnerKnowledgeRetrieveRequest: + if not self.dataset_ids: + raise ValueError("dataset_ids must contain at least one item") + if not self.query and not self.attachment_ids: + raise ValueError("query or attachment_ids is required") + if self.retrieval.mode == "single" and not self.query: + raise ValueError("query is required for single mode") + return self + + +class InnerKnowledgeRetrieveUsage(ResponseModel): + """Serialized LLM usage payload returned by dataset retrieval.""" + + model_config = ConfigDict( + from_attributes=True, + extra="forbid", + populate_by_name=True, + serialize_by_alias=True, + protected_namespaces=(), + ) + + prompt_tokens: int + completion_tokens: int + total_tokens: int + prompt_unit_price: str + completion_unit_price: str + prompt_price_unit: str + completion_price_unit: str + prompt_price: str + completion_price: str + total_price: str + currency: str | None = None + latency: float | int + + +class InnerKnowledgeRetrieveResponse(ResponseModel): + """Workflow-style retrieval results plus accumulated usage.""" + + model_config = ConfigDict( + from_attributes=True, + extra="forbid", + populate_by_name=True, + serialize_by_alias=True, + protected_namespaces=(), + ) + + results: list[Source] + usage: InnerKnowledgeRetrieveUsage diff --git a/api/services/errors/knowledge_retrieval.py b/api/services/errors/knowledge_retrieval.py new file mode 100644 index 00000000000..4e00641e340 --- /dev/null +++ b/api/services/errors/knowledge_retrieval.py @@ -0,0 +1,49 @@ +"""Service errors for the inner knowledge retrieval API.""" + +from services.errors.base import BaseServiceError + + +class InnerKnowledgeRetrievalServiceError(BaseServiceError): + """Base service error with a stable HTTP mapping contract.""" + + error_code = "knowledge_retrieve_failed" + status_code = 500 + default_description = "Knowledge retrieval failed." + + def __init__(self, description: str | None = None): + self.description = description or self.default_description + ValueError.__init__(self, self.description) + + +class InnerKnowledgeRetrieveAppNotFoundError(InnerKnowledgeRetrievalServiceError): + error_code = "app_not_found" + status_code = 404 + default_description = "App not found." + + +class InnerKnowledgeRetrieveAppTenantMismatchError(InnerKnowledgeRetrievalServiceError): + error_code = "app_tenant_mismatch" + status_code = 403 + default_description = "App does not belong to caller tenant." + + +class InnerKnowledgeRetrieveDatasetNotFoundError(InnerKnowledgeRetrievalServiceError): + error_code = "dataset_not_found" + status_code = 404 + default_description = "Dataset not found." + + +class InnerKnowledgeRetrieveDatasetTenantMismatchError(InnerKnowledgeRetrievalServiceError): + error_code = "dataset_tenant_mismatch" + status_code = 403 + default_description = "Dataset does not belong to caller tenant." + + +class ExternalKnowledgeRetrievalError(ValueError): + """Raised when an external dataset retrieval dependency fails. + + This stays a ``ValueError`` subclass for compatibility with existing callers + that already treat external retrieval failures as generic retrieval errors, + while still giving inner API controllers a dedicated error type to map to + ``502 external_knowledge_failed``. + """ diff --git a/api/services/external_knowledge_service.py b/api/services/external_knowledge_service.py index 60b457ecd03..8f89bca8e2b 100644 --- a/api/services/external_knowledge_service.py +++ b/api/services/external_knowledge_service.py @@ -22,6 +22,7 @@ from services.entities.external_knowledge_entities.external_knowledge_entities i ExternalKnowledgeApiSetting, ) from services.errors.dataset import DatasetNameDuplicateError +from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError class ExternalDatasetService: @@ -309,13 +310,22 @@ class ExternalDatasetService: external_retrieval_parameters: dict[str, Any], metadata_condition: MetadataFilteringCondition | None = None, ): + """Fetch retrieval records from an external knowledge provider. + + Success requires a tenant-scoped binding plus API template and a ``200`` + response body shaped like ``{"records": [...]}``. All dependency + failures, non-200 responses, and malformed success payloads must be + normalized to ``ExternalKnowledgeRetrievalError`` so callers—especially + the inner knowledge retrieval API—can consistently expose + ``502 external_knowledge_failed``. + """ external_knowledge_binding = db.session.scalar( select(ExternalKnowledgeBindings) .where(ExternalKnowledgeBindings.dataset_id == dataset_id, ExternalKnowledgeBindings.tenant_id == tenant_id) .limit(1) ) if not external_knowledge_binding: - raise ValueError("external knowledge binding not found") + raise ExternalKnowledgeRetrievalError("external knowledge binding not found") external_knowledge_api = db.session.scalar( select(ExternalKnowledgeApis) @@ -326,7 +336,7 @@ class ExternalDatasetService: .limit(1) ) if external_knowledge_api is None or external_knowledge_api.settings is None: - raise ValueError("external api template not found") + raise ExternalKnowledgeRetrievalError("external api template not found") settings = json.loads(external_knowledge_api.settings) headers = {"Content-Type": "application/json"} @@ -344,16 +354,34 @@ class ExternalDatasetService: "metadata_condition": metadata_condition.model_dump() if metadata_condition else None, } - response = ExternalDatasetService.process_external_api( - ExternalKnowledgeApiSetting( - url=f"{settings.get('endpoint')}/retrieval", - request_method="post", - headers=headers, - params=request_params, - ), - None, - ) + try: + response = ExternalDatasetService.process_external_api( + ExternalKnowledgeApiSetting( + url=f"{settings.get('endpoint')}/retrieval", + request_method="post", + headers=headers, + params=request_params, + ), + None, + ) + except ExternalKnowledgeRetrievalError: + raise + except Exception as exc: + raise ExternalKnowledgeRetrievalError(str(exc)) from exc + if response.status_code == 200: - return cast(list[Any], response.json().get("records", [])) + try: + response_payload = response.json() + except Exception as exc: + raise ExternalKnowledgeRetrievalError("invalid external knowledge response") from exc + + if not isinstance(response_payload, dict): + raise ExternalKnowledgeRetrievalError("invalid external knowledge response") + + records = response_payload.get("records", []) + if not isinstance(records, list): + raise ExternalKnowledgeRetrievalError("invalid external knowledge response") + + return cast(list[Any], records) else: - raise ValueError(response.text) + raise ExternalKnowledgeRetrievalError(response.text) diff --git a/api/services/knowledge_retrieval_inner_service.py b/api/services/knowledge_retrieval_inner_service.py new file mode 100644 index 00000000000..fccc81c4a29 --- /dev/null +++ b/api/services/knowledge_retrieval_inner_service.py @@ -0,0 +1,145 @@ +"""Service wrapper for the inner knowledge retrieval API. + +This service keeps the internal HTTP contract small while reusing the workflow +retrieval stack in ``core.rag.retrieval.dataset_retrieval.DatasetRetrieval``. +The only authorization enforced here is tenant ownership of the caller app and +requested datasets. + +It intentionally does not check ``dataset.enable_api`` or user-level dataset +permissions. After the caller app and requested datasets pass tenant-scoped +prechecks, dataset availability and "no usable document" cases are delegated to +``DatasetRetrieval`` and may legitimately produce an empty result list instead +of a separate validation error. +""" + +from sqlalchemy import select + +from core.rag.entities.metadata_entities import Condition, MetadataFilteringCondition +from core.rag.retrieval.dataset_retrieval import DatasetRetrieval +from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrievalRequest +from extensions.ext_database import db +from graphon.model_runtime.utils.encoders import jsonable_encoder +from graphon.nodes.llm.entities import ModelConfig +from models.dataset import Dataset +from models.model import App +from services.entities.knowledge_retrieval_inner import ( + InnerKnowledgeRetrieveRequest, + InnerKnowledgeRetrieveResponse, + InnerKnowledgeRetrieveUsage, +) +from services.errors.knowledge_retrieval import ( + InnerKnowledgeRetrieveAppNotFoundError, + InnerKnowledgeRetrieveAppTenantMismatchError, + InnerKnowledgeRetrieveDatasetNotFoundError, + InnerKnowledgeRetrieveDatasetTenantMismatchError, +) + + +class InnerKnowledgeRetrievalService: + """Validate inner caller scope and delegate to workflow dataset retrieval.""" + + def retrieve(self, request: InnerKnowledgeRetrieveRequest) -> InnerKnowledgeRetrieveResponse: + """Run tenant-scoped retrieval for a trusted internal caller. + + This method only rejects caller app existence/tenant mismatches and + requested dataset existence/tenant mismatches. It deliberately leaves + ``dataset.enable_api``, user-level dataset permissions, and + availability/no-usable-document handling to ``DatasetRetrieval`` so the + inner API stays aligned with workflow retrieval semantics, including + returning ``[]`` when datasets are present but yield no retrievable + content. + + Raises: + InnerKnowledgeRetrieveAppNotFoundError: The caller app does not exist. + InnerKnowledgeRetrieveAppTenantMismatchError: The caller app is outside the caller tenant. + InnerKnowledgeRetrieveDatasetNotFoundError: At least one requested dataset does not exist. + InnerKnowledgeRetrieveDatasetTenantMismatchError: + At least one requested dataset is outside the caller tenant. + """ + self._validate_caller_app(tenant_id=request.caller.tenant_id, app_id=request.caller.app_id) + self._validate_datasets(tenant_id=request.caller.tenant_id, dataset_ids=request.dataset_ids) + + rag = DatasetRetrieval() + results = rag.knowledge_retrieval(request=self._to_rag_request(request)) + return InnerKnowledgeRetrieveResponse( + results=results, + usage=InnerKnowledgeRetrieveUsage.model_validate(jsonable_encoder(rag.llm_usage)), + ) + + def _validate_caller_app(self, *, tenant_id: str, app_id: str) -> None: + app = db.session.scalar(select(App).where(App.id == app_id).limit(1)) + if app is None: + raise InnerKnowledgeRetrieveAppNotFoundError(f"App '{app_id}' not found") + if app.tenant_id != tenant_id: + raise InnerKnowledgeRetrieveAppTenantMismatchError( + f"App '{app_id}' does not belong to tenant '{tenant_id}'" + ) + + def _validate_datasets(self, *, tenant_id: str, dataset_ids: list[str]) -> None: + datasets = db.session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all() + + found_ids = {dataset.id for dataset in datasets} + missing_ids = sorted(set(dataset_ids) - found_ids) + if missing_ids: + raise InnerKnowledgeRetrieveDatasetNotFoundError(f"Datasets not found: {', '.join(missing_ids)}") + + mismatched_ids = sorted(dataset.id for dataset in datasets if dataset.tenant_id != tenant_id) + if mismatched_ids: + raise InnerKnowledgeRetrieveDatasetTenantMismatchError( + f"Datasets do not belong to tenant '{tenant_id}': {', '.join(mismatched_ids)}" + ) + + def _to_rag_request(self, request: InnerKnowledgeRetrieveRequest) -> KnowledgeRetrievalRequest: + metadata_model_config = request.metadata_filtering.metadata_model_config + metadata_conditions = request.metadata_filtering.conditions + + return KnowledgeRetrievalRequest( + tenant_id=request.caller.tenant_id, + user_id=request.caller.user_id, + app_id=request.caller.app_id, + user_from=request.caller.user_from, + dataset_ids=request.dataset_ids, + query=request.query, + retrieval_mode=request.retrieval.mode, + model_provider=request.retrieval.model.provider if request.retrieval.model else None, + completion_params=request.retrieval.model.completion_params if request.retrieval.model else None, + model_mode=request.retrieval.model.mode if request.retrieval.model else None, + model_name=request.retrieval.model.name if request.retrieval.model else None, + metadata_model_config=ModelConfig.model_validate(metadata_model_config.model_dump(mode="python")) + if metadata_model_config + else None, + metadata_filtering_conditions=( + MetadataFilteringCondition( + logical_operator=metadata_conditions.logical_operator, + conditions=( + [ + Condition( + name=condition.name, + comparison_operator=condition.comparison_operator, + value=condition.value, + ) + for condition in metadata_conditions.conditions + ] + if metadata_conditions.conditions is not None + else None + ), + ) + if metadata_conditions is not None + else None + ), + metadata_filtering_mode=request.metadata_filtering.mode, + top_k=request.retrieval.top_k or 0, + score_threshold=request.retrieval.score_threshold, + reranking_mode=request.retrieval.reranking_mode, + reranking_model=( + { + "reranking_provider_name": request.retrieval.reranking_model.provider, + "reranking_model_name": request.retrieval.reranking_model.model, + } + if request.retrieval.reranking_model is not None + else None + ), + weights=request.retrieval.weights, + reranking_enable=request.retrieval.reranking_enable, + attachment_ids=request.attachment_ids or None, + ) diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index 0fa4d3261bc..c0b308a5cb5 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -15,6 +15,7 @@ from dify_agent.layers.dify_plugin import ( DifyPluginToolsLayerConfig, ) from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig +from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellEnvVarConfig, DifyShellLayerConfig from dify_agent.protocol import ( @@ -28,6 +29,7 @@ from pydantic import ValidationError from clients.agent_backend import ( AGENT_SOUL_PROMPT_LAYER_ID, DIFY_EXECUTION_CONTEXT_LAYER_ID, + DIFY_KNOWLEDGE_BASE_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID, WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, WORKFLOW_USER_PROMPT_LAYER_ID, @@ -155,6 +157,25 @@ def test_request_builder_adds_dify_plugin_tools_layer_when_configured(): assert tools_config.tools[0].tool_name == "current_time" +def test_request_builder_adds_knowledge_layer_when_configured(): + run_input = _run_input() + run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ) + + request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + assert DIFY_KNOWLEDGE_BASE_LAYER_ID in layers + assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID + assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config) + assert knowledge_config.dataset_ids == ["dataset-1"] + + def test_request_builder_can_delete_on_exit_for_cleanup_paths(): run_input = _run_input() run_input.suspend_on_exit = False @@ -329,6 +350,25 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell(): assert shell_config.env[0].name == "APP_ENV" +def test_agent_app_request_builder_adds_knowledge_layer_when_configured(): + run_input = _agent_app_input() + run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1", "dataset-2"], + "retrieval": {"mode": "multiple", "top_k": 2}, + } + ) + + request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + assert DIFY_KNOWLEDGE_BASE_LAYER_ID in layers + assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID + assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config) + assert knowledge_config.dataset_ids == ["dataset-1", "dataset-2"] + + # ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ───── diff --git a/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py b/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py index ffe0c4e6b34..96f1dcaed56 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py @@ -13,6 +13,7 @@ from controllers.inner_api.wraps import ( billing_inner_api_only, enterprise_inner_api_only, enterprise_inner_api_user_auth, + inner_api_only, plugin_inner_api_only, ) from models.model import EndUser @@ -154,6 +155,57 @@ class TestEnterpriseInnerApiOnly: assert exc_info.value.code == 401 +class TestInnerApiOnly: + """Test inner_api_only decorator.""" + + def test_should_allow_when_inner_api_enabled_and_valid_key(self, app: Flask): + @inner_api_only + def protected_view(): + return "success" + + with app.test_request_context(headers={"X-Inner-Api-Key": "valid_key"}): + with patch.object(dify_config, "INNER_API", True): + with patch.object(dify_config, "INNER_API_KEY", "valid_key"): + result = protected_view() + + assert result == "success" + + def test_should_return_404_when_inner_api_disabled(self, app: Flask): + @inner_api_only + def protected_view(): + return "success" + + with app.test_request_context(): + with patch.object(dify_config, "INNER_API", False): + with pytest.raises(HTTPException) as exc_info: + protected_view() + assert exc_info.value.code == 404 + + def test_should_return_401_when_api_key_missing(self, app: Flask): + @inner_api_only + def protected_view(): + return "success" + + with app.test_request_context(headers={}): + with patch.object(dify_config, "INNER_API", True): + with patch.object(dify_config, "INNER_API_KEY", "valid_key"): + with pytest.raises(HTTPException) as exc_info: + protected_view() + assert exc_info.value.code == 401 + + def test_should_return_401_when_api_key_invalid(self, app: Flask): + @inner_api_only + def protected_view(): + return "success" + + with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}): + with patch.object(dify_config, "INNER_API", True): + with patch.object(dify_config, "INNER_API_KEY", "valid_key"): + with pytest.raises(HTTPException) as exc_info: + protected_view() + assert exc_info.value.code == 401 + + class TestEnterpriseInnerApiUserAuth: """Test enterprise_inner_api_user_auth decorator for HMAC-based user authentication""" diff --git a/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py b/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py new file mode 100644 index 00000000000..fa648e0335c --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py @@ -0,0 +1,233 @@ +"""Unit tests for the inner knowledge retrieval controller.""" + +from unittest.mock import patch + +import pytest +from flask import Flask + +from controllers.inner_api import bp as inner_api_bp +from core.workflow.nodes.knowledge_retrieval.exc import RateLimitExceededError +from core.workflow.nodes.knowledge_retrieval.retrieval import Source, SourceMetadata +from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveResponse, InnerKnowledgeRetrieveUsage +from services.errors.knowledge_retrieval import ( + ExternalKnowledgeRetrievalError, + InnerKnowledgeRetrieveAppNotFoundError, + InnerKnowledgeRetrieveDatasetTenantMismatchError, +) + + +@pytest.fixture +def inner_api_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(inner_api_bp) + return app + + +def _headers(api_key: str | None = "inner-key") -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if api_key is not None: + headers["X-Inner-Api-Key"] = api_key + return headers + + +def _payload() -> dict[str, object]: + return { + "caller": { + "tenant_id": "tenant-1", + "user_id": "user-1", + "app_id": "app-1", + "user_from": "account", + "invoke_from": "workflow", + }, + "dataset_ids": ["dataset-1"], + "query": "reset password", + "retrieval": { + "mode": "multiple", + "top_k": 4, + }, + "metadata_filtering": { + "mode": "disabled", + }, + "attachment_ids": [], + } + + +class TestInnerKnowledgeRetrieveApi: + def test_post_returns_401_when_api_key_missing(self, inner_api_app: Flask): + with patch("configs.dify_config.INNER_API", True): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(api_key=None), + ) + + assert response.status_code == 401 + assert response.get_json()["code"] == "inner_api_unauthorized" + + def test_post_returns_401_when_api_key_invalid(self, inner_api_app: Flask): + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(api_key="wrong-key"), + ) + + assert response.status_code == 401 + assert response.get_json()["code"] == "inner_api_unauthorized" + + def test_post_returns_400_for_invalid_body(self, inner_api_app: Flask): + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json={"caller": {"tenant_id": "tenant-1"}}, + headers=_headers(), + ) + + assert response.status_code == 400 + assert response.get_json()["code"] == "invalid_request" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_404_for_service_not_found_error(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = InnerKnowledgeRetrieveAppNotFoundError("app missing") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 404 + assert response.get_json()["code"] == "app_not_found" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_403_for_service_forbidden_error(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = InnerKnowledgeRetrieveDatasetTenantMismatchError("wrong tenant") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 403 + assert response.get_json()["code"] == "dataset_tenant_mismatch" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_422_for_retrieval_config_value_error(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = ValueError("invalid reranking config") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "retrieval_config_invalid" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_429_for_rate_limit_error(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = RateLimitExceededError("knowledge rate limited") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 429 + assert response.get_json()["code"] == "knowledge_rate_limited" + + def test_post_returns_400_for_manual_metadata_without_conditions(self, inner_api_app: Flask): + payload = _payload() + payload["metadata_filtering"] = {"mode": "manual"} + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=payload, + headers=_headers(), + ) + + assert response.status_code == 400 + assert response.get_json()["code"] == "invalid_request" + + def test_post_returns_400_for_automatic_metadata_without_model_config(self, inner_api_app: Flask): + payload = _payload() + payload["metadata_filtering"] = {"mode": "automatic"} + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=payload, + headers=_headers(), + ) + + assert response.status_code == 400 + assert response.get_json()["code"] == "invalid_request" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_502_for_external_knowledge_failure(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = ExternalKnowledgeRetrievalError("upstream failed") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 502 + assert response.get_json()["code"] == "external_knowledge_failed" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_service_response(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.return_value = InnerKnowledgeRetrieveResponse( + results=[ + Source( + metadata=SourceMetadata( + dataset_id="dataset-1", + dataset_name="Docs", + document_id="document-1", + document_name="FAQ.md", + data_source_type="upload_file", + ), + title="FAQ.md", + files=[], + content="Reset your password from settings.", + summary=None, + ) + ], + usage=InnerKnowledgeRetrieveUsage( + prompt_tokens=0, + completion_tokens=0, + total_tokens=0, + prompt_unit_price="0", + completion_unit_price="0", + prompt_price_unit="0.001", + completion_price_unit="0.001", + prompt_price="0", + completion_price="0", + total_price="0", + currency="USD", + latency=0, + ), + ) + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["results"][0]["metadata"]["_source"] == "knowledge" + assert data["results"][0]["title"] == "FAQ.md" + assert data["usage"]["total_tokens"] == 0 diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 83f9b697b75..85a2423f6b1 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -144,6 +144,40 @@ class TestAgentAppRuntimeRequestBuilder: assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]" assert result.metadata["conversation_id"] == "conv-1" + def test_build_maps_agent_soul_knowledge_to_knowledge_layer(self): + soul = AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "langgenius/openai/openai", + "model": "gpt-4o-mini", + }, + "knowledge": { + "datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}], + "query_config": { + "top_k": 3, + "score_threshold": 0.5, + "score_threshold_enabled": False, + }, + }, + } + ) + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(soul)) + + knowledge = next(layer for layer in result.request.composition.layers if layer.name == "knowledge") + assert knowledge.type == "dify.knowledge_base" + assert knowledge.deps == {"execution_context": "execution_context"} + dumped_config = knowledge.config.model_dump(mode="json", by_alias=True) + assert dumped_config["dataset_ids"] == ["dataset-1", "dataset-2"] + assert dumped_config["retrieval"]["mode"] == "multiple" + assert dumped_config["retrieval"]["top_k"] == 3 + assert dumped_config["retrieval"]["score_threshold"] == 0.0 + def test_build_raises_when_model_missing(self): builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_attachment_entry.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_attachment_entry.py new file mode 100644 index 00000000000..adcf5585d39 --- /dev/null +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_attachment_entry.py @@ -0,0 +1,36 @@ +"""Focused tests for attachment-aware dataset retrieval entry behavior.""" + +from unittest.mock import MagicMock, patch + +from core.rag.retrieval.dataset_retrieval import DatasetRetrieval +from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrievalRequest + + +def test_knowledge_retrieval_allows_attachment_only_requests() -> None: + retrieval = DatasetRetrieval() + available_dataset = MagicMock(id="dataset-1") + + request = KnowledgeRetrievalRequest( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + dataset_ids=["dataset-1"], + query=None, + retrieval_mode="multiple", + top_k=4, + score_threshold=0.0, + reranking_mode="reranking_model", + reranking_enable=True, + attachment_ids=["attachment-1"], + ) + + with ( + patch.object(retrieval, "_check_knowledge_rate_limit"), + patch.object(retrieval, "_get_available_datasets", return_value=[available_dataset]), + patch.object(retrieval, "multiple_retrieve", return_value=[]) as mock_multiple, + ): + result = retrieval.knowledge_retrieval(request) + + assert result == [] + mock_multiple.assert_called_once() diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index f402f851b8b..9313aea51e6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -496,6 +496,119 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer(): assert plugin_tools_builder.last_invoke_from == context.dify_context.invoke_from +def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): + context = _context() + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig.model_validate( + { + "prompt": {"system_prompt": "You are careful."}, + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "openai", + "model": "gpt-test", + }, + "knowledge": { + "datasets": [{"id": "dataset-1"}, {"id": " "}, {"id": "dataset-2"}], + "query_config": { + "top_k": 6, + "score_threshold": 0.4, + "score_threshold_enabled": True, + }, + }, + } + ), + ) + context = replace(context, snapshot=snapshot) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} + knowledge_layer = layers["knowledge"] + assert knowledge_layer["type"] == "dify.knowledge_base" + assert knowledge_layer["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + assert knowledge_layer["config"] == { + "dataset_ids": ["dataset-1", "dataset-2"], + "retrieval": { + "mode": "multiple", + "top_k": 6, + "score_threshold": 0.4, + "reranking_mode": "reranking_model", + "reranking_enable": True, + "reranking_model": None, + "weights": None, + "model": None, + }, + "metadata_filtering": {"mode": "disabled", "metadata_model_config": None, "conditions": None}, + "max_result_content_chars": 2000, + "max_observation_chars": 12000, + } + + +def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits_it(): + context = _context() + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig.model_validate( + { + "prompt": {"system_prompt": "You are careful."}, + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "openai", + "model": "gpt-test", + }, + "knowledge": { + "datasets": [{"id": "dataset-1"}], + "query_config": {}, + }, + } + ), + ) + context = replace(context, snapshot=snapshot) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + knowledge_layer = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "knowledge") + assert knowledge_layer["config"]["retrieval"]["top_k"] == 4 + + +def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids(): + context = _context() + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig.model_validate( + { + "prompt": {"system_prompt": "You are careful."}, + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "openai", + "model": "gpt-test", + }, + "knowledge": { + "datasets": [{"id": " "}, {}], + }, + } + ), + ) + context = replace(context, snapshot=snapshot) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + assert all(layer["name"] != "knowledge" for layer in dumped["composition"]["layers"]) + + def test_build_passes_saved_session_snapshot_to_agent_backend_request(): session_snapshot = CompositorSessionSnapshot(layers=[]) context = replace(_context(), session_snapshot=session_snapshot) @@ -868,3 +981,36 @@ def test_feature_manifest_marks_human_supported_when_configured(): assert manifest["reserved_status"]["human"] == "supported_by_ask_human_hitl" # configured human no longer produces a "not executed" warning assert all("human" not in w["section"] for w in manifest["unsupported_runtime_warnings"]) + + +def test_feature_manifest_marks_knowledge_supported_without_warning_when_configured(): + from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest + + soul = AgentSoulConfig.model_validate( + { + "knowledge": { + "datasets": [{"id": "dataset-1", "name": "Product Docs"}], + } + } + ) + + manifest = build_runtime_feature_manifest(soul) + assert "knowledge" in manifest["supported"] + assert "knowledge" not in manifest["reserved"] + assert manifest["reserved_status"]["knowledge"] == "supported_by_knowledge_layer" + assert all("knowledge" not in w["section"] for w in manifest["unsupported_runtime_warnings"]) + + +def test_feature_manifest_treats_blank_knowledge_dataset_ids_as_not_configured(): + from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest + + soul = AgentSoulConfig.model_validate( + { + "knowledge": { + "datasets": [{"id": " "}, {}], + } + } + ) + + manifest = build_runtime_feature_manifest(soul) + assert manifest["reserved_status"]["knowledge"] == "not_configured" diff --git a/api/tests/unit_tests/services/test_external_dataset_service.py b/api/tests/unit_tests/services/test_external_dataset_service.py index fdea0ba8690..143283c0ae9 100644 --- a/api/tests/unit_tests/services/test_external_dataset_service.py +++ b/api/tests/unit_tests/services/test_external_dataset_service.py @@ -21,6 +21,7 @@ from services.entities.external_knowledge_entities.external_knowledge_entities i ExternalKnowledgeApiSetting, ) from services.errors.dataset import DatasetNameDuplicateError +from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError from services.external_knowledge_service import ExternalDatasetService @@ -1558,7 +1559,7 @@ class TestExternalDatasetServiceFetchRetrieval: mock_db.session.scalar.return_value = None # Act & Assert - with pytest.raises(ValueError, match="external knowledge binding not found"): + with pytest.raises(ExternalKnowledgeRetrievalError, match="external knowledge binding not found"): ExternalDatasetService.fetch_external_knowledge_retrieval("tenant-123", "dataset-123", "query", {}) @patch("services.external_knowledge_service.db") @@ -1569,7 +1570,7 @@ class TestExternalDatasetServiceFetchRetrieval: mock_db.session.scalar.side_effect = [binding, None] # Act & Assert - with pytest.raises(ValueError, match="external api template not found"): + with pytest.raises(ExternalKnowledgeRetrievalError, match="external api template not found"): ExternalDatasetService.fetch_external_knowledge_retrieval("tenant-123", "dataset-123", "query", {}) @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") @@ -1643,7 +1644,7 @@ class TestExternalDatasetServiceFetchRetrieval: mock_process.return_value = mock_response # Act & Assert - with pytest.raises(Exception, match="Internal Server Error: Database connection failed"): + with pytest.raises(ExternalKnowledgeRetrievalError, match="Internal Server Error: Database connection failed"): ExternalDatasetService.fetch_external_knowledge_retrieval( "tenant-123", "dataset-123", "query", {"top_k": 5} ) @@ -1684,7 +1685,7 @@ class TestExternalDatasetServiceFetchRetrieval: mock_process.return_value = mock_response # Act & Assert - with pytest.raises(ValueError, match=re.escape(error_message)): + with pytest.raises(ExternalKnowledgeRetrievalError, match=re.escape(error_message)): ExternalDatasetService.fetch_external_knowledge_retrieval(tenant_id, dataset_id, "query", {"top_k": 5}) @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") @@ -1703,7 +1704,79 @@ class TestExternalDatasetServiceFetchRetrieval: mock_process.return_value = mock_response # Act & Assert - with pytest.raises(ValueError): + with pytest.raises(ExternalKnowledgeRetrievalError): + ExternalDatasetService.fetch_external_knowledge_retrieval( + "tenant-123", "dataset-123", "query", {"top_k": 5} + ) + + @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") + @patch("services.external_knowledge_service.db") + def test_fetch_external_knowledge_retrieval_invalid_json_response(self, mock_db, mock_process, factory): + """Test malformed JSON success responses are normalized to external retrieval errors.""" + binding = factory.create_external_knowledge_binding_mock() + api = factory.create_external_knowledge_api_mock() + + mock_db.session.scalar.side_effect = [binding, api] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0) + mock_process.return_value = mock_response + + with pytest.raises(ExternalKnowledgeRetrievalError, match="invalid external knowledge response"): + ExternalDatasetService.fetch_external_knowledge_retrieval( + "tenant-123", "dataset-123", "query", {"top_k": 5} + ) + + @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") + @patch("services.external_knowledge_service.db") + def test_fetch_external_knowledge_retrieval_invalid_success_payload_shape(self, mock_db, mock_process, factory): + """Test malformed success payload shapes are normalized to external retrieval errors.""" + binding = factory.create_external_knowledge_binding_mock() + api = factory.create_external_knowledge_api_mock() + + mock_db.session.scalar.side_effect = [binding, api] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = ["not-a-dict"] + mock_process.return_value = mock_response + + with pytest.raises(ExternalKnowledgeRetrievalError, match="invalid external knowledge response"): + ExternalDatasetService.fetch_external_knowledge_retrieval( + "tenant-123", "dataset-123", "query", {"top_k": 5} + ) + + @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") + @patch("services.external_knowledge_service.db") + def test_fetch_external_knowledge_retrieval_invalid_records_shape(self, mock_db, mock_process, factory): + """Test non-list records payloads are normalized to external retrieval errors.""" + binding = factory.create_external_knowledge_binding_mock() + api = factory.create_external_knowledge_api_mock() + + mock_db.session.scalar.side_effect = [binding, api] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"records": {"unexpected": "shape"}} + mock_process.return_value = mock_response + + with pytest.raises(ExternalKnowledgeRetrievalError, match="invalid external knowledge response"): + ExternalDatasetService.fetch_external_knowledge_retrieval( + "tenant-123", "dataset-123", "query", {"top_k": 5} + ) + + @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") + @patch("services.external_knowledge_service.db") + def test_fetch_external_knowledge_retrieval_wraps_transport_errors(self, mock_db, mock_process, factory): + """Test transport/runtime failures are normalized to external retrieval errors.""" + binding = factory.create_external_knowledge_binding_mock() + api = factory.create_external_knowledge_api_mock() + + mock_db.session.scalar.side_effect = [binding, api] + mock_process.side_effect = RuntimeError("connection reset by peer") + + with pytest.raises(ExternalKnowledgeRetrievalError, match="connection reset by peer"): ExternalDatasetService.fetch_external_knowledge_retrieval( "tenant-123", "dataset-123", "query", {"top_k": 5} ) diff --git a/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py new file mode 100644 index 00000000000..287d787ad70 --- /dev/null +++ b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py @@ -0,0 +1,218 @@ +"""Unit tests for the inner knowledge retrieval service.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from core.workflow.nodes.knowledge_retrieval.retrieval import Source, SourceMetadata +from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest +from services.errors.knowledge_retrieval import ( + InnerKnowledgeRetrieveAppNotFoundError, + InnerKnowledgeRetrieveAppTenantMismatchError, + InnerKnowledgeRetrieveDatasetNotFoundError, + InnerKnowledgeRetrieveDatasetTenantMismatchError, +) +from services.knowledge_retrieval_inner_service import InnerKnowledgeRetrievalService + + +def _build_request(**overrides): + payload = { + "caller": { + "tenant_id": "tenant-1", + "user_id": "user-1", + "app_id": "app-1", + "user_from": "account", + "invoke_from": "workflow", + }, + "dataset_ids": ["dataset-1", "dataset-2"], + "query": "how to reset password", + "retrieval": { + "mode": "multiple", + "top_k": 4, + "score_threshold": 0.25, + "reranking_mode": "reranking_model", + "reranking_enable": True, + "reranking_model": { + "provider": "cohere", + "model": "rerank-english-v3.0", + }, + }, + "metadata_filtering": { + "mode": "manual", + "conditions": { + "logical_operator": "and", + "conditions": [ + { + "name": "category", + "comparison_operator": "contains", + "value": "pricing", + } + ], + }, + }, + "attachment_ids": ["attachment-1"], + } + payload.update(overrides) + return InnerKnowledgeRetrieveRequest.model_validate(payload) + + +def _build_source() -> Source: + return Source( + metadata=SourceMetadata( + dataset_id="dataset-1", + dataset_name="Docs", + document_id="document-1", + document_name="FAQ.md", + data_source_type="upload_file", + ), + title="FAQ.md", + files=[], + content="Reset your password from settings.", + summary=None, + ) + + +class TestInnerKnowledgeRetrievalService: + @patch("services.knowledge_retrieval_inner_service.DatasetRetrieval") + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_maps_multiple_request_and_skips_enable_api_check(self, mock_db, mock_rag_cls): + request = _build_request() + mock_app = MagicMock(id="app-1", tenant_id="tenant-1") + dataset_1 = MagicMock(id="dataset-1", tenant_id="tenant-1", enable_api=False) + dataset_2 = MagicMock(id="dataset-2", tenant_id="tenant-1", enable_api=True) + mock_db.session.scalar.return_value = mock_app + mock_db.session.scalars.return_value.all.return_value = [dataset_1, dataset_2] + + rag = MagicMock() + rag.knowledge_retrieval.return_value = [_build_source()] + rag.llm_usage = { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "prompt_unit_price": "0", + "completion_unit_price": "0", + "prompt_price_unit": "0.001", + "completion_price_unit": "0.001", + "prompt_price": "0", + "completion_price": "0", + "total_price": "0", + "currency": "USD", + "latency": 0, + } + mock_rag_cls.return_value = rag + + response = InnerKnowledgeRetrievalService().retrieve(request) + + rag_request = rag.knowledge_retrieval.call_args.kwargs["request"] + assert rag_request.tenant_id == "tenant-1" + assert rag_request.app_id == "app-1" + assert rag_request.user_id == "user-1" + assert rag_request.dataset_ids == ["dataset-1", "dataset-2"] + assert rag_request.query == "how to reset password" + assert rag_request.retrieval_mode == "multiple" + assert rag_request.top_k == 4 + assert rag_request.score_threshold == 0.25 + assert rag_request.reranking_model == { + "reranking_provider_name": "cohere", + "reranking_model_name": "rerank-english-v3.0", + } + assert rag_request.metadata_filtering_mode == "manual" + assert rag_request.metadata_filtering_conditions is not None + metadata_conditions = rag_request.metadata_filtering_conditions.model_dump(mode="python") + assert metadata_conditions["logical_operator"] == "and" + assert metadata_conditions["conditions"] is not None + assert metadata_conditions["conditions"][0]["name"] == "category" + assert rag_request.attachment_ids == ["attachment-1"] + assert response.results[0].title == "FAQ.md" + assert response.usage.currency == "USD" + + @patch("services.knowledge_retrieval_inner_service.DatasetRetrieval") + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_maps_single_request(self, mock_db, mock_rag_cls): + request = _build_request( + dataset_ids=["dataset-1"], + retrieval={ + "mode": "single", + "model": { + "provider": "openai", + "name": "gpt-4o-mini", + "mode": "chat", + "completion_params": {"temperature": 0}, + }, + }, + metadata_filtering={ + "mode": "automatic", + "model_config": { + "provider": "openai", + "name": "gpt-4o-mini", + "mode": "chat", + "completion_params": {"temperature": 0}, + }, + }, + attachment_ids=[], + ) + mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1") + mock_db.session.scalars.return_value.all.return_value = [MagicMock(id="dataset-1", tenant_id="tenant-1")] + + rag = MagicMock() + rag.knowledge_retrieval.return_value = [] + rag.llm_usage = { + "prompt_tokens": 1, + "completion_tokens": 2, + "total_tokens": 3, + "prompt_unit_price": "0", + "completion_unit_price": "0", + "prompt_price_unit": "0.001", + "completion_price_unit": "0.001", + "prompt_price": "0", + "completion_price": "0", + "total_price": "0", + "currency": "USD", + "latency": 1, + } + mock_rag_cls.return_value = rag + + InnerKnowledgeRetrievalService().retrieve(request) + + rag_request = rag.knowledge_retrieval.call_args.kwargs["request"] + assert rag_request.retrieval_mode == "single" + assert rag_request.model_provider == "openai" + assert rag_request.model_name == "gpt-4o-mini" + assert rag_request.model_mode == "chat" + assert rag_request.completion_params == {"temperature": 0} + assert rag_request.metadata_filtering_mode == "automatic" + assert rag_request.metadata_model_config is not None + assert rag_request.metadata_model_config.provider == "openai" + + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_raises_when_app_missing(self, mock_db): + mock_db.session.scalar.return_value = None + + with pytest.raises(InnerKnowledgeRetrieveAppNotFoundError): + InnerKnowledgeRetrievalService().retrieve(_build_request()) + + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_raises_when_app_belongs_to_other_tenant(self, mock_db): + mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-2") + + with pytest.raises(InnerKnowledgeRetrieveAppTenantMismatchError): + InnerKnowledgeRetrievalService().retrieve(_build_request()) + + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_raises_when_dataset_missing(self, mock_db): + mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1") + mock_db.session.scalars.return_value.all.return_value = [MagicMock(id="dataset-1", tenant_id="tenant-1")] + + with pytest.raises(InnerKnowledgeRetrieveDatasetNotFoundError): + InnerKnowledgeRetrievalService().retrieve(_build_request()) + + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_raises_when_dataset_belongs_to_other_tenant(self, mock_db): + mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1") + mock_db.session.scalars.return_value.all.return_value = [ + MagicMock(id="dataset-1", tenant_id="tenant-1"), + MagicMock(id="dataset-2", tenant_id="tenant-2"), + ] + + with pytest.raises(InnerKnowledgeRetrieveDatasetTenantMismatchError): + InnerKnowledgeRetrievalService().retrieve(_build_request()) diff --git a/dify-agent/src/dify_agent/layers/execution_context/__init__.py b/dify-agent/src/dify_agent/layers/execution_context/__init__.py index f1534bceffd..aaf031501b3 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/__init__.py +++ b/dify-agent/src/dify_agent/layers/execution_context/__init__.py @@ -2,7 +2,8 @@ Implementation layers live in sibling modules and require server-side runtime dependencies. Keep this package root import-safe for client code that only -needs to build run requests. +needs to build run requests. Knowledge layers read ``user_from`` from the same +DTO, but that runtime implementation still lives in sibling modules. """ from dify_agent.layers.execution_context.configs import ( diff --git a/dify-agent/src/dify_agent/layers/execution_context/configs.py b/dify-agent/src/dify_agent/layers/execution_context/configs.py index 2b042add7b5..21abe9b9481 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/configs.py +++ b/dify-agent/src/dify_agent/layers/execution_context/configs.py @@ -4,9 +4,11 @@ This layer carries both Dify product execution context (tenant, user, workflow, invoke source) and Agent backend runtime mode. The product-facing fields are used by trusted server-side boundaries such as the Agent Stub when they need to reconstruct Dify API file-access scope without granting the sandbox any -direct inner-API credentials. Server-only plugin-daemon settings are injected -by the runtime provider factory and therefore do not appear in this public -schema. +direct inner-API credentials. Knowledge-base layers also read ``user_from`` from +this shared config so the inner Dify API can distinguish platform-user and +end-user searches without making that caller identity model-controlled. +Server-only plugin-daemon settings are injected by the runtime provider factory +and therefore do not appear in this public schema. """ from typing import ClassVar, Final, Literal, TypeAlias @@ -42,7 +44,7 @@ class DifyExecutionContextLayerConfig(LayerConfig): tenant_id: str user_id: str | None = None - user_from: DifyExecutionContextUserFrom + user_from: DifyExecutionContextUserFrom | None = None app_id: str | None = None workflow_id: str | None = None workflow_run_id: str | None = None diff --git a/dify-agent/src/dify_agent/layers/execution_context/layer.py b/dify-agent/src/dify_agent/layers/execution_context/layer.py index 06ef41ecf42..55a1920f535 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/layer.py +++ b/dify-agent/src/dify_agent/layers/execution_context/layer.py @@ -1,7 +1,8 @@ """Runtime Dify execution-context layer. The public config carries Dify-owned execution identifiers plus the tenant/user -daemon context needed by plugin-backed business layers. Server-only daemon URL +daemon context needed by plugin-backed business layers and the caller identity +needed by knowledge-base layers. Server-only daemon URL and API key are injected by the provider factory. The layer is intentionally config/settings-only under Agenton's state-only core: it does not open, cache, close, or snapshot HTTP clients, and its lifecycle hooks remain the inherited @@ -29,7 +30,7 @@ from dify_agent.layers.execution_context.configs import ( class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, DifyExecutionContextLayerConfig, EmptyRuntimeState]): """Layer that carries Dify execution context without owning live resources.""" - type_id: ClassVar[str] = DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID + type_id: ClassVar[str | None] = DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID config: DifyExecutionContextLayerConfig daemon_url: str diff --git a/dify-agent/src/dify_agent/layers/knowledge/__init__.py b/dify-agent/src/dify_agent/layers/knowledge/__init__.py new file mode 100644 index 00000000000..569512d8004 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/knowledge/__init__.py @@ -0,0 +1,27 @@ +"""Client-safe exports for Dify knowledge-base layer DTOs and type ids. + +Implementation layers and HTTP clients live in sibling modules so this package +root stays import-safe for callers that only need to construct run requests. +""" + +from dify_agent.layers.knowledge.configs import ( + DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, + DifyKnowledgeBaseLayerConfig, + DifyKnowledgeMetadataCondition, + DifyKnowledgeMetadataConditions, + DifyKnowledgeMetadataFilteringConfig, + DifyKnowledgeModelConfig, + DifyKnowledgeRerankingModelConfig, + DifyKnowledgeRetrievalConfig, +) + +__all__ = [ + "DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID", + "DifyKnowledgeBaseLayerConfig", + "DifyKnowledgeMetadataCondition", + "DifyKnowledgeMetadataConditions", + "DifyKnowledgeMetadataFilteringConfig", + "DifyKnowledgeModelConfig", + "DifyKnowledgeRerankingModelConfig", + "DifyKnowledgeRetrievalConfig", +] diff --git a/dify-agent/src/dify_agent/layers/knowledge/client.py b/dify-agent/src/dify_agent/layers/knowledge/client.py new file mode 100644 index 00000000000..b80e363190a --- /dev/null +++ b/dify-agent/src/dify_agent/layers/knowledge/client.py @@ -0,0 +1,214 @@ +"""Async client for the Dify API inner knowledge retrieval endpoint. + +This wrapper owns only request/response mapping and error normalization for +``POST /inner/api/knowledge/retrieve``. The shared ``httpx.AsyncClient`` is +supplied by the FastAPI lifespan/runtime and must stay open for the caller. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import ClassVar + +import httpx +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError + +from dify_agent.layers.knowledge.configs import ( + DifyKnowledgeMetadataFilteringConfig, + DifyKnowledgeRetrievalConfig, +) + + +class DifyKnowledgeBaseClientError(RuntimeError): + """Raised when the inner knowledge retrieval HTTP boundary fails.""" + + status_code: int | None + error_code: str | None + retryable: bool + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + error_code: str | None = None, + retryable: bool, + ) -> None: + self.status_code = status_code + self.error_code = error_code + self.retryable = retryable + super().__init__(message) + + +class _DifyKnowledgeCaller(BaseModel): + tenant_id: str + user_id: str + app_id: str + user_from: str + invoke_from: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class _DifyKnowledgeRetrieveRequest(BaseModel): + caller: _DifyKnowledgeCaller + dataset_ids: list[str] + query: str + retrieval: dict[str, JsonValue] + metadata_filtering: dict[str, JsonValue] + attachment_ids: list[str] = Field(default_factory=list) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeResultMetadata(BaseModel): + source: str | None = Field(default=None, alias="_source") + dataset_id: str | None = None + dataset_name: str | None = None + document_id: str | None = None + document_name: str | None = None + score: float | int | str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow", populate_by_name=True) + + +class DifyKnowledgeResult(BaseModel): + metadata: DifyKnowledgeResultMetadata + title: str | None = None + files: list[JsonValue] | None = None + content: str | None = None + summary: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeRetrieveResponse(BaseModel): + results: list[DifyKnowledgeResult] + usage: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +@dataclass(slots=True) +class DifyKnowledgeBaseClient: + """Boundary client for the Dify API inner knowledge retrieval endpoint.""" + + base_url: str + api_key: str = field(repr=False) + http_client: httpx.AsyncClient = field(repr=False) + + def __post_init__(self) -> None: + self.base_url = self.base_url.rstrip("/") + + async def retrieve( + self, + *, + tenant_id: str, + user_id: str, + app_id: str, + user_from: str, + invoke_from: str, + dataset_ids: list[str], + query: str, + retrieval: DifyKnowledgeRetrievalConfig, + metadata_filtering: DifyKnowledgeMetadataFilteringConfig, + ) -> DifyKnowledgeRetrieveResponse: + """Call the inner API and return parsed retrieval results. + + Raises: + DifyKnowledgeBaseClientError: For HTTP, transport, or response-shape + failures. Only ``429``, ``502``, and transport/timeout failures + are marked retryable because the model may continue gracefully in + those temporary-unavailable cases. + """ + request_payload = _DifyKnowledgeRetrieveRequest( + caller=_DifyKnowledgeCaller( + tenant_id=tenant_id, + user_id=user_id, + app_id=app_id, + user_from=user_from, + invoke_from=invoke_from, + ), + dataset_ids=dataset_ids, + query=query, + retrieval=retrieval.to_request_payload(), + metadata_filtering=metadata_filtering.to_request_payload(), + ) + + try: + response = await self.http_client.post( + f"{self.base_url}/inner/api/knowledge/retrieve", + headers={ + "X-Inner-Api-Key": self.api_key, + "Content-Type": "application/json", + }, + json=request_payload.model_dump(mode="json", by_alias=True), + ) + except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc: + raise DifyKnowledgeBaseClientError( + f"Knowledge base search is misconfigured: {exc}", + retryable=False, + ) from exc + except httpx.TimeoutException as exc: + raise DifyKnowledgeBaseClientError( + "Knowledge base search timed out.", + retryable=True, + ) from exc + except httpx.RequestError as exc: + raise DifyKnowledgeBaseClientError( + f"Knowledge base search request failed: {exc}", + retryable=True, + ) from exc + + if response.status_code >= 400: + raise _build_http_error(response) + + try: + return DifyKnowledgeRetrieveResponse.model_validate_json(response.text) + except ValidationError as exc: + raise DifyKnowledgeBaseClientError( + "Invalid knowledge retrieval response from Dify API.", + status_code=response.status_code, + error_code="invalid_response", + retryable=False, + ) from exc + + +def _build_http_error(response: httpx.Response) -> DifyKnowledgeBaseClientError: + detail = _decode_error_detail(response) + retryable = response.status_code in {429, 502} + message = detail["message"] or f"HTTP {response.status_code}" + return DifyKnowledgeBaseClientError( + message, + status_code=response.status_code, + error_code=detail["error_code"], + retryable=retryable, + ) + + +def _decode_error_detail(response: httpx.Response) -> dict[str, str | None]: + raw_body = response.text + try: + payload = response.json() + except json.JSONDecodeError: + payload = None + + if isinstance(payload, dict): + error_code = payload.get("code") + message = payload.get("message") + return { + "error_code": error_code if isinstance(error_code, str) else None, + "message": message if isinstance(message, str) and message else raw_body or f"HTTP {response.status_code}", + } + + return {"error_code": None, "message": raw_body or f"HTTP {response.status_code}"} + + +__all__ = [ + "DifyKnowledgeBaseClient", + "DifyKnowledgeBaseClientError", + "DifyKnowledgeResult", + "DifyKnowledgeResultMetadata", + "DifyKnowledgeRetrieveResponse", +] diff --git a/dify-agent/src/dify_agent/layers/knowledge/configs.py b/dify-agent/src/dify_agent/layers/knowledge/configs.py new file mode 100644 index 00000000000..9ada075d1cc --- /dev/null +++ b/dify-agent/src/dify_agent/layers/knowledge/configs.py @@ -0,0 +1,200 @@ +"""Client-safe DTOs for the Dify knowledge-base Agenton layer. + +The public layer config exposes only static retrieval controls: dataset ids, +retrieval strategy, metadata filtering, and observation-size limits. The agent +model itself should only ever see a single ``query`` tool argument; tenant/ +app/user context comes from the execution-context layer and the actual +retrieval is delegated to the Dify API inner endpoint. Tool naming is not +caller-configurable: the runtime always exposes the same stable knowledge-base +search tool. +""" + +from __future__ import annotations + +from typing import ClassVar, Final, Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator + +from agenton.layers import LayerConfig + +type DifyKnowledgeMetadataComparisonOperator = Literal[ + "contains", + "not contains", + "start with", + "end with", + "is", + "is not", + "empty", + "not empty", + "in", + "not in", + "=", + "≠", + ">", + "<", + "≥", + "≤", + "before", + "after", +] + +DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID: Final[str] = "dify.knowledge_base" + + +class DifyKnowledgeModelConfig(BaseModel): + """Static model configuration forwarded to the inner retrieval API.""" + + provider: str + name: str + mode: str + completion_params: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeRerankingModelConfig(BaseModel): + """Reranking model settings for multiple-mode retrieval.""" + + provider: str + model: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeRetrievalConfig(BaseModel): + """Static retrieval controls mirrored into the inner API request.""" + + mode: Literal["multiple", "single"] + top_k: int | None = Field(default=None, ge=1) + score_threshold: float = 0.0 + reranking_mode: str = "reranking_model" + reranking_enable: bool = True + reranking_model: DifyKnowledgeRerankingModelConfig | None = None + weights: dict[str, JsonValue] | None = None + model: DifyKnowledgeModelConfig | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def validate_mode_specific_fields(self) -> DifyKnowledgeRetrievalConfig: + if self.mode == "multiple" and self.top_k is None: + raise ValueError("retrieval.top_k is required for multiple mode") + if self.mode == "single" and self.model is None: + raise ValueError("retrieval.model is required for single mode") + return self + + def to_request_payload(self) -> dict[str, JsonValue]: + """Serialize the retrieval config into the inner API request shape.""" + payload: dict[str, JsonValue] = { + "mode": self.mode, + "score_threshold": self.score_threshold, + "reranking_mode": self.reranking_mode, + "reranking_enable": self.reranking_enable, + } + if self.mode == "multiple": + payload["top_k"] = self.top_k + payload["reranking_model"] = ( + self.reranking_model.model_dump(mode="json") if self.reranking_model is not None else None + ) + payload["weights"] = self.weights + else: + payload["model"] = self.model.model_dump(mode="json") if self.model is not None else None + return payload + + +class DifyKnowledgeMetadataCondition(BaseModel): + """One manual metadata filter clause.""" + + name: str + comparison_operator: DifyKnowledgeMetadataComparisonOperator + value: str | list[str] | int | float | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeMetadataConditions(BaseModel): + """Boolean composition for manual metadata filtering.""" + + logical_operator: Literal["and", "or"] = "and" + conditions: list[DifyKnowledgeMetadataCondition] + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeMetadataFilteringConfig(BaseModel): + """Static metadata filtering controls for the inner API request.""" + + mode: Literal["disabled", "automatic", "manual"] = "disabled" + metadata_model_config: DifyKnowledgeModelConfig | None = Field(default=None, alias="model_config") + conditions: DifyKnowledgeMetadataConditions | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", populate_by_name=True) + + @model_validator(mode="after") + def validate_mode_specific_fields(self) -> DifyKnowledgeMetadataFilteringConfig: + if self.mode == "automatic" and self.metadata_model_config is None: + raise ValueError("metadata_filtering.model_config is required for automatic mode") + if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions): + raise ValueError("metadata_filtering.conditions is required for manual mode") + return self + + def to_request_payload(self) -> dict[str, JsonValue]: + """Serialize metadata filtering using the inner API request field names.""" + if self.mode == "disabled": + return {"mode": self.mode} + + payload: dict[str, JsonValue] = {"mode": self.mode} + if self.metadata_model_config is not None: + payload["model_config"] = self.metadata_model_config.model_dump(mode="json") + if self.conditions is not None: + payload["conditions"] = self.conditions.model_dump(mode="json") + return payload + + +class DifyKnowledgeBaseLayerConfig(LayerConfig): + """Public config for one model-visible knowledge search tool. + + The model only gets to choose whether to call the tool and what ``query`` + to send. Dataset ids, retrieval settings, metadata filtering, and caller + context remain config/runtime concerns outside the model-visible tool + schema. The tool name and description are fixed by the layer runtime and do + not appear in the public config DTO. + """ + + dataset_ids: list[str] + retrieval: DifyKnowledgeRetrievalConfig + metadata_filtering: DifyKnowledgeMetadataFilteringConfig = Field( + default_factory=DifyKnowledgeMetadataFilteringConfig + ) + max_result_content_chars: int = Field(default=2000, ge=1) + max_observation_chars: int = Field(default=12000, ge=1) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @field_validator("dataset_ids") + @classmethod + def validate_dataset_ids(cls, value: list[str]) -> list[str]: + if not value: + raise ValueError("dataset_ids must contain at least one item") + normalized_ids = [item.strip() for item in value] + if any(not item for item in normalized_ids): + raise ValueError("dataset_ids must not contain blank items") + return normalized_ids + + @model_validator(mode="after") + def validate_observation_limits(self) -> DifyKnowledgeBaseLayerConfig: + if self.max_observation_chars < self.max_result_content_chars: + raise ValueError("max_observation_chars must be greater than or equal to max_result_content_chars") + return self + + +__all__ = [ + "DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID", + "DifyKnowledgeBaseLayerConfig", + "DifyKnowledgeMetadataCondition", + "DifyKnowledgeMetadataConditions", + "DifyKnowledgeMetadataFilteringConfig", + "DifyKnowledgeModelConfig", + "DifyKnowledgeRerankingModelConfig", + "DifyKnowledgeRetrievalConfig", +] diff --git a/dify-agent/src/dify_agent/layers/knowledge/layer.py b/dify-agent/src/dify_agent/layers/knowledge/layer.py new file mode 100644 index 00000000000..16605a9ceb3 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/knowledge/layer.py @@ -0,0 +1,285 @@ +"""Dify knowledge-base layer exposing one model-visible search tool. + +The layer depends on ``DifyExecutionContextLayer`` for tenant/app/user/invoke +identity, keeps retrieval controls in config only, and borrows a lifespan-owned +HTTP client for each tool invocation. It never owns live clients or stores +retrieved source content in layer state. Tool identity is intentionally fixed at +runtime: callers cannot rename the knowledge tool or override its description +through public layer config because the model-visible surface must stay stable +across API-side Agent Soul mappings. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import ClassVar, cast + +import httpx +from pydantic_ai import RunContext, Tool +from pydantic_ai.tools import ToolDefinition +from typing_extensions import Self, override + +from agenton.layers import LayerDeps, PlainLayer +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.knowledge.client import ( + DifyKnowledgeBaseClient, + DifyKnowledgeBaseClientError, + DifyKnowledgeRetrieveResponse, +) +from dify_agent.layers.knowledge.configs import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig + +logger = logging.getLogger(__name__) + +# Fixed model-visible tool identity. These stay module-private on purpose so the +# public DTO cannot grow a parallel naming contract that diverges from the +# runtime knowledge-search surface. +_KNOWLEDGE_BASE_TOOL_NAME = "knowledge_base_search" +_KNOWLEDGE_BASE_TOOL_DESCRIPTION = "Search configured knowledge bases for information relevant to the query." +BLANK_QUERY_OBSERVATION = "knowledge base search requires a non-empty query" +NO_RESULTS_OBSERVATION = "No relevant knowledge base results were found." +TEMPORARY_UNAVAILABLE_OBSERVATION = ( + "Knowledge base search is temporarily unavailable. Please continue without it if possible." +) +QUERY_TOOL_SCHEMA = { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query for the configured knowledge bases.", + } + }, + "required": ["query"], + "additionalProperties": False, +} + + +class DifyKnowledgeBaseDeps(LayerDeps): + """Dependencies required by ``DifyKnowledgeBaseLayer``.""" + + execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBaseLayerConfig]): + """Layer that resolves one config-scoped knowledge search tool.""" + + type_id: ClassVar[str | None] = DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID + + config: DifyKnowledgeBaseLayerConfig + dify_api_inner_url: str + dify_api_inner_api_key: str + + @classmethod + @override + def from_config(cls, config: DifyKnowledgeBaseLayerConfig) -> Self: + """Reject construction without server-injected Dify API settings.""" + del config + raise TypeError( + "DifyKnowledgeBaseLayer requires server-side Dify API settings and must use a provider factory." + ) + + @classmethod + def from_config_with_settings( + cls, + config: DifyKnowledgeBaseLayerConfig, + *, + dify_api_inner_url: str, + dify_api_inner_api_key: str, + ) -> Self: + """Create the layer from public config plus server-only API settings.""" + return cls( + config=DifyKnowledgeBaseLayerConfig.model_validate(config), + dify_api_inner_url=dify_api_inner_url, + dify_api_inner_api_key=dify_api_inner_api_key, + ) + + async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + """Build one Pydantic AI tool that exposes only ``query`` to the model. + + Knowledge tools depend on execution-context identity that is optional for + other run types but mandatory here: ``tenant_id``, ``user_id``, + ``user_from``, ``app_id``, and ``invoke_from`` must all be present before + any HTTP request is attempted. Tool execution then follows a strict + observation policy: + + - blank ``query`` returns a local validation observation; + - retryable client failures (timeouts, connection failures, HTTP + ``429``/``502``) become a temporary-unavailable observation; + - non-retryable client failures are raised so the run fails fast. + """ + if http_client.is_closed: + raise RuntimeError("DifyKnowledgeBaseLayer.get_tools() requires an open shared HTTP client.") + + execution_context = self.deps.execution_context.config + caller = _build_caller_context(execution_context) + client = DifyKnowledgeBaseClient( + base_url=self.dify_api_inner_url, + api_key=self.dify_api_inner_api_key, + http_client=http_client, + ) + + async def knowledge_base_search(_ctx: RunContext[object], query: str) -> str: + normalized_query = query.strip() + if not normalized_query: + return BLANK_QUERY_OBSERVATION + try: + response = await client.retrieve( + tenant_id=caller["tenant_id"], + user_id=caller["user_id"], + app_id=caller["app_id"], + user_from=caller["user_from"], + invoke_from=caller["invoke_from"], + dataset_ids=list(self.config.dataset_ids), + query=normalized_query, + retrieval=self.config.retrieval, + metadata_filtering=self.config.metadata_filtering, + ) + except DifyKnowledgeBaseClientError as exc: + if exc.retryable: + logger.warning( + "knowledge base search temporarily unavailable", + extra={ + "tenant_id": caller["tenant_id"], + "app_id": caller["app_id"], + "invoke_from": caller["invoke_from"], + "error_code": exc.error_code, + "status_code": exc.status_code, + }, + ) + return TEMPORARY_UNAVAILABLE_OBSERVATION + logger.error( + "knowledge base search failed", + extra={ + "tenant_id": caller["tenant_id"], + "app_id": caller["app_id"], + "invoke_from": caller["invoke_from"], + "error_code": exc.error_code, + "status_code": exc.status_code, + }, + ) + raise + return _format_observation(response, self.config) + + async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition: + return ToolDefinition( + name=tool_def.name, + description=tool_def.description, + parameters_json_schema=QUERY_TOOL_SCHEMA, + strict=tool_def.strict, + sequential=tool_def.sequential, + metadata=tool_def.metadata, + timeout=tool_def.timeout, + defer_loading=tool_def.defer_loading, + kind=tool_def.kind, + return_schema=tool_def.return_schema, + include_return_schema=tool_def.include_return_schema, + ) + + return [ + Tool( + knowledge_base_search, + takes_ctx=True, + name=_KNOWLEDGE_BASE_TOOL_NAME, + description=_KNOWLEDGE_BASE_TOOL_DESCRIPTION, + prepare=prepare_tool_definition, + ) + ] + + +def _build_caller_context(execution_context: object) -> dict[str, str]: + """Extract the inner-API caller identity from execution-context config. + + The public execution-context DTO keeps several fields optional for general + runs, but knowledge retrieval requires all of ``tenant_id``, ``user_id``, + ``user_from``, ``app_id``, and ``invoke_from``. Missing or blank values are + rejected here so misconfigured runs fail before transport rather than being + softened into tool observations. + """ + tenant_id = getattr(execution_context, "tenant_id", None) + user_id = getattr(execution_context, "user_id", None) + user_from = getattr(execution_context, "user_from", None) + app_id = getattr(execution_context, "app_id", None) + invoke_from = getattr(execution_context, "invoke_from", None) + + missing_fields = [ + field_name + for field_name, value in ( + ("tenant_id", tenant_id), + ("user_id", user_id), + ("user_from", user_from), + ("app_id", app_id), + ("invoke_from", invoke_from), + ) + if not isinstance(value, str) or not value.strip() + ] + if missing_fields: + joined_fields = ", ".join(missing_fields) + raise ValueError(f"Dify knowledge base layer requires execution context fields: {joined_fields}") + + normalized_tenant_id = cast(str, tenant_id).strip() + normalized_user_id = cast(str, user_id).strip() + normalized_user_from = cast(str, user_from).strip() + normalized_app_id = cast(str, app_id).strip() + normalized_invoke_from = cast(str, invoke_from).strip() + + return { + "tenant_id": normalized_tenant_id, + "user_id": normalized_user_id, + "user_from": normalized_user_from, + "app_id": normalized_app_id, + "invoke_from": normalized_invoke_from, + } + + +def _format_observation(response: DifyKnowledgeRetrieveResponse, config: DifyKnowledgeBaseLayerConfig) -> str: + """Render inner-API retrieval results into the model-visible tool response. + + The formatting contract is intentionally simple and stable for the model: + + - empty ``results`` returns ``NO_RESULTS_OBSERVATION``; + - non-empty results become a numbered list headed by + ``"Knowledge base search results:"``; + - each item includes title plus dataset/document/score metadata when those + fields are present; + - each content snippet is truncated by ``max_result_content_chars``; + - the final observation is truncated by ``max_observation_chars``. + """ + if not response.results: + return NO_RESULTS_OBSERVATION + + lines = ["Knowledge base search results:"] + for index, result in enumerate(response.results, start=1): + metadata = result.metadata + title = result.title or metadata.document_name or "Untitled" + lines.append(f"{index}. Title: {title}") + if metadata.dataset_name: + lines.append(f" Dataset: {metadata.dataset_name}") + if metadata.document_name: + lines.append(f" Document: {metadata.document_name}") + if metadata.score is not None: + lines.append(f" Score: {metadata.score}") + content = _truncate_text(result.content or result.summary or "", config.max_result_content_chars) + if content: + lines.append(f" Content: {content}") + lines.append("") + + return _truncate_text("\n".join(lines).rstrip(), config.max_observation_chars) + + +def _truncate_text(text: str, max_chars: int) -> str: + if len(text) <= max_chars: + return text + if max_chars <= 3: + return text[:max_chars] + return f"{text[: max_chars - 3]}..." + + +__all__ = [ + "BLANK_QUERY_OBSERVATION", + "DifyKnowledgeBaseDeps", + "DifyKnowledgeBaseLayer", + "NO_RESULTS_OBSERVATION", + "QUERY_TOOL_SCHEMA", + "TEMPORARY_UNAVAILABLE_OBSERVATION", +] diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 81bfcd48e2f..a637dce7767 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -4,23 +4,23 @@ Only explicitly allowed provider type ids are constructible here. The default provider set contains prompt layers, the optional pydantic-ai history layer, the state-free Dify structured output layer, the optional Dify ask-human layer, the Dify execution-context layer, the stateful Dify shell layer, and the Dify -plugin business-layer family: +plugin/knowledge business-layer family: - ``dify.drive`` for the inert Skills & Files drive declaration, - ``dify.execution_context`` for shared tenant/user/run daemon context, - ``dify.shell`` for shellctl-backed shell job control, -- ``dify.plugin.llm`` for plugin-backed model selection, and -- ``dify.plugin.tools`` for prepared plugin tool exposure. +- ``dify.plugin.llm`` for plugin-backed model selection, +- ``dify.plugin.tools`` for prepared plugin tool exposure, and +- ``dify.knowledge_base`` for inner-API-backed knowledge search tools. Public DTOs provide Dify context plus plugin/model/tool data, while server-only -plugin daemon settings are injected through the provider factory for -``DifyExecutionContextLayer`` and the optional shellctl entrypoint/auth token plus -client factory plus optional Agent Stub URL/token issuer are injected for -``DifyShellLayer``. The resulting ``Compositor`` -remains Agenton state-only at the snapshot boundary: live resources such as -HTTP clients are injected by runtime-owned providers, may be held on active -layer instances inside ``resource_context()``, and never enter session -snapshots. +plugin daemon settings and Dify API inner settings are injected through provider +factories. Optional shellctl entrypoint/auth token, client factory, and Agent +Stub URL/token issuer are injected for ``DifyShellLayer``. The resulting +``Compositor`` remains Agenton state-only at the snapshot boundary: live +resources such as HTTP clients are injected by runtime-owned providers, may be +held on active layer instances inside ``resource_context()``, and never enter +session snapshots. """ from collections.abc import Mapping, Sequence @@ -41,6 +41,8 @@ from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer from dify_agent.layers.drive.layer import DifyDriveLayer from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig +from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.layers.output.output_layer import DifyOutputLayer from dify_agent.layers.shell.configs import DifyShellLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer, create_shellctl_client_factory @@ -52,6 +54,8 @@ def create_default_layer_providers( *, plugin_daemon_url: str = "http://localhost:5002", plugin_daemon_api_key: str = "", + dify_api_inner_url: str = "http://localhost:5001", + dify_api_inner_api_key: str = "", shellctl_entrypoint: str | None = None, shellctl_auth_token: str | None = None, agent_stub_url: str | None = None, @@ -109,6 +113,14 @@ def create_default_layer_providers( ), LayerProvider.from_layer_type(DifyPluginLLMLayer), LayerProvider.from_layer_type(DifyPluginToolsLayer), + LayerProvider.from_factory( + layer_type=DifyKnowledgeBaseLayer, + create=lambda config: DifyKnowledgeBaseLayer.from_config_with_settings( + DifyKnowledgeBaseLayerConfig.model_validate(config), + dify_api_inner_url=dify_api_inner_url, + dify_api_inner_api_key=dify_api_inner_api_key, + ), + ), ) diff --git a/dify-agent/src/dify_agent/runtime/run_scheduler.py b/dify-agent/src/dify_agent/runtime/run_scheduler.py index 9dfc93b8465..4186b6afd76 100644 --- a/dify-agent/src/dify_agent/runtime/run_scheduler.py +++ b/dify-agent/src/dify_agent/runtime/run_scheduler.py @@ -69,6 +69,7 @@ class RunScheduler: runner_factory: RunRunnerFactory layer_providers: tuple[LayerProviderInput, ...] plugin_daemon_http_client: httpx.AsyncClient + dify_api_http_client: httpx.AsyncClient _lifecycle_lock: asyncio.Lock def __init__( @@ -76,6 +77,7 @@ class RunScheduler: *, store: RunStore, plugin_daemon_http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, shutdown_grace_seconds: float = 30, layer_providers: tuple[LayerProviderInput, ...] | None = None, runner_factory: RunRunnerFactory | None = None, @@ -85,6 +87,7 @@ class RunScheduler: self.active_tasks = {} self.stopping = False self.plugin_daemon_http_client = plugin_daemon_http_client + self.dify_api_http_client = dify_api_http_client self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers() self.runner_factory = runner_factory or self._default_runner_factory self._lifecycle_lock = asyncio.Lock() @@ -141,6 +144,7 @@ class RunScheduler: request=request, run_id=record.run_id, plugin_daemon_http_client=self.plugin_daemon_http_client, + dify_api_http_client=self.dify_api_http_client, layer_providers=self.layer_providers, ) diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index 8a6d7b9bd91..d10b1843e9a 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -19,7 +19,9 @@ publishes that deferred request through the normal ``run_succeeded`` event as ``deferred_tool_call`` instead of a final ``output``. Invalid structured outputs or invalid deferred-tool behavior still trigger normal retries/failures before Dify Agent emits success. Layers still never own the FastAPI lifespan-owned -plugin daemon HTTP client. +plugin daemon or Dify API inner HTTP clients. Successful terminal events contain +both the JSON-safe final output or deferred tool call and the session snapshot; +there are no separate output or snapshot events to correlate. """ from collections.abc import AsyncIterable @@ -38,6 +40,7 @@ from agenton.layers.types import PydanticAITool from dify_agent.layers.ask_human.layer import get_ask_human_layer, validate_ask_human_layer_composition from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer +from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.protocol.schemas import ( CreateRunRequest, DIFY_AGENT_MODEL_LAYER_ID, @@ -91,6 +94,7 @@ class AgentRunRunner: run_id: str layer_providers: tuple[LayerProviderInput, ...] plugin_daemon_http_client: httpx.AsyncClient + dify_api_http_client: httpx.AsyncClient def __init__( self, @@ -99,12 +103,14 @@ class AgentRunRunner: request: CreateRunRequest, run_id: str, plugin_daemon_http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, layer_providers: tuple[LayerProviderInput, ...] | None = None, ) -> None: self.sink = sink self.request = request self.run_id = run_id self.plugin_daemon_http_client = plugin_daemon_http_client + self.dify_api_http_client = dify_api_http_client self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers() async def run(self) -> None: @@ -187,7 +193,11 @@ class AgentRunRunner: ask_human_layer = get_ask_human_layer(run) llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer) model = llm_layer.get_model(http_client=self.plugin_daemon_http_client) - tools = await _resolve_run_tools(run, http_client=self.plugin_daemon_http_client) + tools = await _resolve_run_tools( + run, + plugin_daemon_http_client=self.plugin_daemon_http_client, + dify_api_http_client=self.dify_api_http_client, + ) except (KeyError, TypeError, RuntimeError, ValueError) as exc: raise AgentRunValidationError(str(exc)) from exc @@ -266,14 +276,17 @@ def _resolve_deferred_tool_results(request: CreateRunRequest) -> DeferredToolRes async def _resolve_run_tools( run: Any, *, - http_client: httpx.AsyncClient, + plugin_daemon_http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, ) -> list[PydanticAITool[object]]: - """Return the static compositor tools plus any Dify plugin runtime tools.""" + """Return the static compositor tools plus any Dify runtime tools.""" resolved_tools = list(cast(list[PydanticAITool[object]], run.tools)) for slot in run.slots.values(): layer = slot.layer if isinstance(layer, DifyPluginToolsLayer): - resolved_tools.extend(await layer.get_tools(http_client=http_client)) + resolved_tools.extend(await layer.get_tools(http_client=plugin_daemon_http_client)) + if isinstance(layer, DifyKnowledgeBaseLayer): + resolved_tools.extend(await layer.get_tools(http_client=dify_api_http_client)) _validate_unique_tool_names(resolved_tools) return resolved_tools diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index f4eab601a2e..dab6caaca07 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -1,15 +1,16 @@ """FastAPI application factory for the Dify Agent run server. -The HTTP process owns Redis clients, one shared plugin daemon ``httpx.AsyncClient``, -route wiring, and a process-local scheduler. Run execution happens in background -``asyncio`` tasks rather than request handlers, so client disconnects do not -cancel the agent runtime. Redis persists run records and per-run event streams -with configured retention only; it is not used as a job queue. Agenton layers and -providers stay state-only: they borrow the lifespan-owned plugin daemon client -through the runner and receive shell-layer server settings through provider -construction rather than reading environment variables themselves. The standard -server always mounts the HTTP Agent Stub router and additionally starts the -optional grpclib Agent Stub server when ``DIFY_AGENT_STUB_URL`` uses ``grpc://``. +The HTTP process owns Redis clients plus separate shared ``httpx.AsyncClient`` +instances for plugin-daemon and Dify API inner calls, route wiring, and a +process-local scheduler. Run execution happens in background ``asyncio`` tasks +rather than request handlers, so client disconnects do not cancel the agent +runtime. Redis persists run records and per-run event streams with configured +retention only; it is not used as a job queue. Agenton layers and providers +stay state-only: they borrow the lifespan-owned clients through the runner and +receive shell-layer server settings through provider construction rather than +reading environment variables themselves. The standard server always mounts the +HTTP Agent Stub router and additionally starts the optional grpclib Agent Stub +server when ``DIFY_AGENT_STUB_URL`` uses ``grpc://``. """ from collections.abc import AsyncGenerator @@ -39,6 +40,8 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: layer_providers = create_default_layer_providers( plugin_daemon_url=resolved_settings.plugin_daemon_url, plugin_daemon_api_key=resolved_settings.plugin_daemon_api_key, + dify_api_inner_url=resolved_settings.dify_api_inner_url, + dify_api_inner_api_key=resolved_settings.dify_api_inner_api_key or "", shellctl_entrypoint=resolved_settings.shellctl_entrypoint, shellctl_auth_token=resolved_settings.shellctl_auth_token, agent_stub_url=resolved_settings.agent_stub_url, @@ -53,6 +56,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: redis = Redis.from_url(resolved_settings.redis_url) plugin_daemon_http_client = create_plugin_daemon_http_client(resolved_settings) + dify_api_inner_http_client = create_dify_api_inner_http_client(resolved_settings) store = RedisRunStore( redis, prefix=resolved_settings.redis_prefix, @@ -61,6 +65,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: scheduler = RunScheduler( store=store, plugin_daemon_http_client=plugin_daemon_http_client, + dify_api_http_client=dify_api_inner_http_client, shutdown_grace_seconds=resolved_settings.shutdown_grace_seconds, layer_providers=layer_providers, ) @@ -83,6 +88,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: if grpc_server is not None: await grpc_server.aclose() await scheduler.shutdown() + await dify_api_inner_http_client.aclose() await plugin_daemon_http_client.aclose() await redis.aclose() @@ -112,17 +118,33 @@ def create_plugin_daemon_http_client(settings: ServerSettings) -> httpx.AsyncCli process and must be closed by the app lifespan after the scheduler has stopped using it. """ + return _create_shared_http_client(settings) + + +def create_dify_api_inner_http_client(settings: ServerSettings) -> httpx.AsyncClient: + """Create the lifespan-owned Dify API inner HTTP client. + + The Dify API inner client intentionally shares the generic outbound HTTP + timeout and connection-pool settings with the plugin daemon client so + operational tuning stays in one place while endpoint URL/API keys remain + distinct server settings. + """ + return _create_shared_http_client(settings) + + +def _create_shared_http_client(settings: ServerSettings) -> httpx.AsyncClient: + """Build one shared HTTP client using generic outbound timeout/pool settings.""" return httpx.AsyncClient( timeout=httpx.Timeout( - connect=settings.plugin_daemon_connect_timeout, - read=settings.plugin_daemon_read_timeout, - write=settings.plugin_daemon_write_timeout, - pool=settings.plugin_daemon_pool_timeout, + connect=settings.outbound_http_connect_timeout, + read=settings.outbound_http_read_timeout, + write=settings.outbound_http_write_timeout, + pool=settings.outbound_http_pool_timeout, ), limits=httpx.Limits( - max_connections=settings.plugin_daemon_max_connections, - max_keepalive_connections=settings.plugin_daemon_max_keepalive_connections, - keepalive_expiry=settings.plugin_daemon_keepalive_expiry, + max_connections=settings.outbound_http_max_connections, + max_keepalive_connections=settings.outbound_http_max_keepalive_connections, + keepalive_expiry=settings.outbound_http_keepalive_expiry, ), trust_env=False, ) @@ -131,4 +153,4 @@ def create_plugin_daemon_http_client(settings: ServerSettings) -> httpx.AsyncCli app = create_app() -__all__ = ["app", "create_app", "create_plugin_daemon_http_client"] +__all__ = ["app", "create_app", "create_dify_api_inner_http_client", "create_plugin_daemon_http_client"] diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index 7c24fbb9f9b..2b4aff62e50 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -1,12 +1,13 @@ """Configuration for the FastAPI run server. -Plugin daemon HTTP client settings describe the single FastAPI lifespan-owned -``httpx.AsyncClient`` shared by local run tasks. Layers and Agenton providers do -not own that client, so these settings are process resource limits rather than -per-run lifecycle knobs. The Agent Stub now also uses this main server settings -model directly: the public Agent Stub URL, server secret, optional gRPC bind -override, and optional Dify inner API file-request settings all live here under -the longstanding ``DIFY_AGENT_...`` environment-variable namespace. +Outbound HTTP client settings describe the FastAPI lifespan-owned +``httpx.AsyncClient`` instances shared by local run tasks for plugin-daemon and +Dify API inner calls. Layers and Agenton providers do not own those clients, so +these settings are process resource limits rather than per-run lifecycle knobs. +Endpoint URLs and API keys stay service-specific. The Agent Stub also uses this +settings model directly: the public Agent Stub URL, server secret, optional gRPC +bind override, and optional Dify inner API file-request settings all live here +under the longstanding ``DIFY_AGENT_...`` environment-variable namespace. """ from typing import ClassVar @@ -23,7 +24,7 @@ DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60 class ServerSettings(BaseSettings): - """Environment-backed settings for Redis, scheduling, plugin, and shell access.""" + """Environment-backed settings for Redis, scheduling, outbound HTTP, and shell access.""" redis_url: str = "redis://localhost:6379/0" redis_prefix: str = "dify-agent" @@ -31,6 +32,7 @@ class ServerSettings(BaseSettings): run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1) plugin_daemon_url: str = "http://localhost:5002" plugin_daemon_api_key: str = "" + dify_api_inner_url: str = "http://localhost:5001" dify_api_base_url: str | None = None dify_api_inner_api_key: str | None = None shellctl_entrypoint: str | None = None @@ -38,13 +40,13 @@ class ServerSettings(BaseSettings): agent_stub_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_URL") agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS") server_secret_key: str | None = None - plugin_daemon_connect_timeout: float = Field(default=10.0, ge=0) - plugin_daemon_read_timeout: float = Field(default=600.0, ge=0) - plugin_daemon_write_timeout: float = Field(default=30.0, ge=0) - plugin_daemon_pool_timeout: float = Field(default=10.0, ge=0) - plugin_daemon_max_connections: int = Field(default=100, ge=1) - plugin_daemon_max_keepalive_connections: int = Field(default=20, ge=0) - plugin_daemon_keepalive_expiry: float = Field(default=30.0, ge=0) + outbound_http_connect_timeout: float = Field(default=10.0, ge=0) + outbound_http_read_timeout: float = Field(default=600.0, ge=0) + outbound_http_write_timeout: float = Field(default=30.0, ge=0) + outbound_http_pool_timeout: float = Field(default=10.0, ge=0) + outbound_http_max_connections: int = Field(default=100, ge=1) + outbound_http_max_keepalive_connections: int = Field(default=20, ge=0) + outbound_http_keepalive_expiry: float = Field(default=30.0, ge=0) model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( env_prefix="DIFY_AGENT_", @@ -116,7 +118,7 @@ class ServerSettings(BaseSettings): @model_validator(mode="after") def validate_agent_stub_requirements(self) -> "ServerSettings": - """Require the server secret and Dify API file settings in valid pairs.""" + """Require Agent Stub settings while allowing knowledge-only inner API keys.""" if self.agent_stub_url is not None and self.server_secret_key is None: raise ValueError("DIFY_AGENT_SERVER_SECRET_KEY is required when DIFY_AGENT_STUB_URL is set.") if self.agent_stub_grpc_bind_address is not None: @@ -124,8 +126,8 @@ class ServerSettings(BaseSettings): raise ValueError("DIFY_AGENT_STUB_URL is required when DIFY_AGENT_STUB_GRPC_BIND_ADDRESS is set.") if not parse_agent_stub_endpoint(self.agent_stub_url).is_grpc: raise ValueError("DIFY_AGENT_STUB_GRPC_BIND_ADDRESS requires a grpc:// DIFY_AGENT_STUB_URL.") - if (self.dify_api_base_url is None) != (self.dify_api_inner_api_key is None): - raise ValueError("DIFY_AGENT_DIFY_API_BASE_URL and DIFY_AGENT_DIFY_API_INNER_API_KEY must be set together.") + if self.dify_api_base_url is not None and self.dify_api_inner_api_key is None: + raise ValueError("DIFY_AGENT_DIFY_API_INNER_API_KEY is required when DIFY_AGENT_DIFY_API_BASE_URL is set.") return self def create_agent_stub_token_codec(self) -> AgentStubTokenCodec | None: diff --git a/dify-agent/tests/local/dify_agent/layers/knowledge/__init__.py b/dify-agent/tests/local/dify_agent/layers/knowledge/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dify-agent/tests/local/dify_agent/layers/knowledge/test_client.py b/dify-agent/tests/local/dify_agent/layers/knowledge/test_client.py new file mode 100644 index 00000000000..9e2ca5462f8 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/knowledge/test_client.py @@ -0,0 +1,248 @@ +import json +from unittest.mock import AsyncMock + +import httpx +import pytest + +from dify_agent.layers.knowledge.client import DifyKnowledgeBaseClient, DifyKnowledgeBaseClientError +from dify_agent.layers.knowledge.configs import ( + DifyKnowledgeMetadataFilteringConfig, + DifyKnowledgeRetrievalConfig, +) + + +def _retrieval_config() -> DifyKnowledgeRetrievalConfig: + return DifyKnowledgeRetrievalConfig(mode="multiple", top_k=4, score_threshold=0.2) + + +def _metadata_filtering() -> DifyKnowledgeMetadataFilteringConfig: + return DifyKnowledgeMetadataFilteringConfig(mode="disabled") + + +def test_knowledge_client_posts_inner_api_request_with_static_controls() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "http://dify-api/inner/api/knowledge/retrieve" + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + payload = json.loads(request.content.decode("utf-8")) + assert payload == { + "caller": { + "tenant_id": "tenant-1", + "user_id": "user-1", + "app_id": "app-1", + "user_from": "account", + "invoke_from": "agent_app", + }, + "dataset_ids": ["dataset-1"], + "query": "reset password", + "retrieval": { + "mode": "multiple", + "top_k": 4, + "score_threshold": 0.2, + "reranking_mode": "reranking_model", + "reranking_enable": True, + "reranking_model": None, + "weights": None, + }, + "metadata_filtering": {"mode": "disabled"}, + "attachment_ids": [], + } + return httpx.Response( + 200, + json={ + "results": [ + { + "metadata": { + "_source": "knowledge", + "dataset_name": "Docs", + "document_name": "FAQ.md", + "score": 0.9, + }, + "title": "FAQ", + "files": [], + "content": "Use the reset link.", + "summary": None, + } + ], + "usage": {}, + }, + ) + + async def scenario() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", + api_key="inner-secret", + http_client=http_client, + ) + response = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert response.results[0].metadata.dataset_name == "Docs" + + import asyncio + + asyncio.run(scenario()) + + +def test_knowledge_client_marks_retryable_http_failures() -> None: + async def scenario() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _request: httpx.Response( + 502, json={"code": "external_knowledge_failed", "message": "bad gateway"} + ) + ) + ) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.status_code == 502 + assert exc_info.value.error_code == "external_knowledge_failed" + assert exc_info.value.retryable is True + + import asyncio + + asyncio.run(scenario()) + + +def test_knowledge_client_marks_non_retryable_http_failures() -> None: + async def scenario() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _request: httpx.Response( + 403, + json={"code": "dataset_tenant_mismatch", "message": "forbidden"}, + ) + ) + ) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.status_code == 403 + assert exc_info.value.error_code == "dataset_tenant_mismatch" + assert exc_info.value.retryable is False + + import asyncio + + asyncio.run(scenario()) + + +def test_knowledge_client_rejects_malformed_success_response() -> None: + async def scenario() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"bad": []})) + ) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.error_code == "invalid_response" + assert exc_info.value.retryable is False + + import asyncio + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "error_factory", + [ + lambda request: httpx.ReadTimeout("timed out", request=request), + lambda request: httpx.ConnectError("connection failed", request=request), + ], +) +def test_knowledge_client_marks_transport_failures_retryable(error_factory) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise error_factory(request) + + async def scenario() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.retryable is True + + import asyncio + + asyncio.run(scenario()) + + +def test_knowledge_client_treats_invalid_url_errors_as_non_retryable_configuration_error() -> None: + async def scenario() -> None: + async with httpx.AsyncClient() as http_client: + http_client.post = AsyncMock(side_effect=httpx.UnsupportedProtocol("unsupported protocol")) + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.retryable is False + + import asyncio + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py b/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py new file mode 100644 index 00000000000..f28939e329b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py @@ -0,0 +1,65 @@ +import pytest +from pydantic import ValidationError + +from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig + + +def _valid_config() -> dict[str, object]: + return { + "dataset_ids": ["dataset-1"], + "retrieval": { + "mode": "multiple", + "top_k": 4, + }, + } + + +def test_knowledge_base_config_accepts_valid_multiple_mode() -> None: + config = DifyKnowledgeBaseLayerConfig.model_validate(_valid_config()) + + assert config.dataset_ids == ["dataset-1"] + assert config.retrieval.top_k == 4 + assert config.metadata_filtering.mode == "disabled" + + +@pytest.mark.parametrize( + "payload, expected_message", + [ + ({"dataset_ids": [], "retrieval": {"mode": "multiple", "top_k": 4}}, "dataset_ids"), + ({"tool_name": "knowledge_base_search", **_valid_config()}, "Extra inputs are not permitted"), + ({"tool_description": "Search knowledge", **_valid_config()}, "Extra inputs are not permitted"), + ({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "multiple"}}, "top_k"), + ({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "single"}}, "retrieval.model"), + ( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + "metadata_filtering": {"mode": "automatic"}, + }, + "metadata_filtering.model_config", + ), + ( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + "metadata_filtering": {"mode": "manual"}, + }, + "metadata_filtering.conditions", + ), + ], +) +def test_knowledge_base_config_rejects_invalid_inputs(payload: dict[str, object], expected_message: str) -> None: + with pytest.raises(ValidationError, match=expected_message): + _ = DifyKnowledgeBaseLayerConfig.model_validate(payload) + + +def test_knowledge_base_config_rejects_observation_limit_smaller_than_result_limit() -> None: + with pytest.raises(ValidationError, match="max_observation_chars"): + _ = DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + "max_result_content_chars": 50, + "max_observation_chars": 20, + } + ) diff --git a/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py b/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py new file mode 100644 index 00000000000..5db74d6f452 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py @@ -0,0 +1,417 @@ +import asyncio +import json + +import httpx +import pytest +from pydantic_ai import Tool + +from agenton.compositor import Compositor, LayerNode, LayerProvider +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.knowledge.client import DifyKnowledgeBaseClientError +from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig +from dify_agent.layers.knowledge.layer import ( + BLANK_QUERY_OBSERVATION, + DifyKnowledgeBaseLayer, + NO_RESULTS_OBSERVATION, + TEMPORARY_UNAVAILABLE_OBSERVATION, +) + + +def _execution_context_config(**overrides: object) -> DifyExecutionContextLayerConfig: + payload: dict[str, object] = { + "tenant_id": "tenant-1", + "user_id": "user-1", + "user_from": "account", + "app_id": "app-1", + "agent_mode": "agent_app", + "invoke_from": "web-app", + } + payload.update(overrides) + return DifyExecutionContextLayerConfig.model_validate(payload) + + +def _knowledge_config(**overrides: object) -> DifyKnowledgeBaseLayerConfig: + payload: dict[str, object] = { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + } + payload.update(overrides) + return DifyKnowledgeBaseLayerConfig.model_validate(payload) + + +def _execution_context_provider() -> LayerProvider[DifyExecutionContextLayer]: + return LayerProvider.from_factory( + layer_type=DifyExecutionContextLayer, + create=lambda config: DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig.model_validate(config), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ), + ) + + +def _knowledge_provider() -> LayerProvider[DifyKnowledgeBaseLayer]: + return LayerProvider.from_factory( + layer_type=DifyKnowledgeBaseLayer, + create=lambda config: DifyKnowledgeBaseLayer.from_config_with_settings( + DifyKnowledgeBaseLayerConfig.model_validate(config), + dify_api_inner_url="http://dify-api", + dify_api_inner_api_key="inner-secret", + ), + ) + + +def test_knowledge_layer_exposes_one_query_only_tool_definition() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient() as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType] + assert isinstance(tool, Tool) + assert tool.name == "knowledge_base_search" + assert tool.description == "Search configured knowledge bases for information relevant to the query." + assert tool_def is not None + assert ( + tool_def.description == "Search configured knowledge bases for information relevant to the query." + ) + assert tool_def.parameters_json_schema == { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query for the configured knowledge bases.", + } + }, + "required": ["query"], + "additionalProperties": False, + } + + asyncio.run(scenario()) + + +def test_knowledge_layer_rejects_blank_query_locally() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient() as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": " "}, None) # pyright: ignore[reportArgumentType] + assert result == BLANK_QUERY_OBSERVATION + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + ("field_name", "field_value"), + [ + ("user_id", None), + ("user_from", None), + ("app_id", None), + ], +) +def test_knowledge_layer_fails_fast_when_execution_context_is_missing_required_fields( + field_name: str, + field_value: object, +) -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient() as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + execution_context_layer = run.get_layer("execution_context", DifyExecutionContextLayer) + setattr(execution_context_layer.config, field_name, field_value) + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + with pytest.raises(ValueError, match=field_name): + _ = await knowledge_layer.get_tools(http_client=http_client) + + asyncio.run(scenario()) + + +def test_knowledge_layer_formats_results_and_truncates_observation() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "results": [ + { + "metadata": { + "_source": "knowledge", + "dataset_name": "Docs", + "document_name": "Guide.md", + "score": 0.9, + }, + "title": "Guide", + "files": [], + "content": "ABCDEFGHIJKL", + "summary": None, + } + ], + "usage": {}, + }, + ) + + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(max_result_content_chars=8, max_observation_chars=160), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result.startswith("Knowledge base search results:\n1. Title: Guide") + assert "Dataset: Docs" in result + assert "Document: Guide.md" in result + assert "Score: 0.9" in result + assert "Content: ABCDE..." in result + assert len(result) <= 160 + + asyncio.run(scenario()) + + +def test_knowledge_layer_returns_no_results_observation() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"results": [], "usage": {}})) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result == NO_RESULTS_OBSERVATION + + asyncio.run(scenario()) + + +def test_knowledge_layer_converts_retryable_failures_into_observation() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _request: httpx.Response(429, json={"code": "knowledge_rate_limited", "message": "slow down"}) + ) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result == TEMPORARY_UNAVAILABLE_OBSERVATION + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "transport_error", + [ + lambda request: httpx.ReadTimeout("timed out", request=request), + lambda request: httpx.ConnectError("connection failed", request=request), + ], +) +def test_knowledge_layer_converts_retryable_transport_failures_into_observation(transport_error) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise transport_error(request) + + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result == TEMPORARY_UNAVAILABLE_OBSERVATION + + asyncio.run(scenario()) + + +def test_knowledge_layer_raises_non_retryable_client_errors() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _request: httpx.Response(403, json={"code": "dataset_tenant_mismatch", "message": "forbidden"}) + ) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert exc_info.value.status_code == 403 + + asyncio.run(scenario()) + + +def test_knowledge_layer_raises_for_malformed_success_responses() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"bad": []})) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert exc_info.value.error_code == "invalid_response" + assert exc_info.value.retryable is False + + asyncio.run(scenario()) + + +def test_knowledge_layer_sends_execution_context_and_static_config_to_inner_api() -> None: + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode("utf-8")) + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + assert payload["caller"] == { + "tenant_id": "tenant-1", + "user_id": "user-1", + "app_id": "app-1", + "user_from": "account", + "invoke_from": "web-app", + } + assert payload["dataset_ids"] == ["dataset-1", "dataset-2"] + assert payload["query"] == "reset" + assert payload["retrieval"]["top_k"] == 2 + assert payload["metadata_filtering"] == { + "mode": "manual", + "conditions": { + "logical_operator": "and", + "conditions": [ + { + "name": "category", + "comparison_operator": "contains", + "value": "auth", + } + ], + }, + } + return httpx.Response(200, json={"results": [], "usage": {}}) + + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config( + dataset_ids=["dataset-1", "dataset-2"], + retrieval={"mode": "multiple", "top_k": 2}, + metadata_filtering={ + "mode": "manual", + "conditions": { + "logical_operator": "and", + "conditions": [ + { + "name": "category", + "comparison_operator": "contains", + "value": "auth", + } + ], + }, + }, + ), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result == NO_RESULTS_OBSERVATION + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py b/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py index a4a5ad8429b..e1124560ac6 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py @@ -120,6 +120,7 @@ def test_create_run_starts_background_task_and_returns_running() -> None: scheduler = RunScheduler( store=store, plugin_daemon_http_client=client, + dify_api_http_client=client, runner_factory=lambda _record, _request: ControlledRunner(started=started, release=release), ) @@ -144,6 +145,7 @@ def test_shutdown_marks_unfinished_runs_failed_and_appends_event() -> None: scheduler = RunScheduler( store=store, plugin_daemon_http_client=client, + dify_api_http_client=client, shutdown_grace_seconds=0, runner_factory=lambda _record, _request: ControlledRunner(started=started, release=asyncio.Event()), ) @@ -165,7 +167,7 @@ def test_create_run_accepts_blank_prompt_and_runner_fails_asynchronously() -> No async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, dify_api_http_client=client) record = await scheduler.create_run(_request(["", " "])) await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1) @@ -182,7 +184,7 @@ def test_create_run_accepts_invalid_output_schema_and_runner_fails_asynchronousl async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, dify_api_http_client=client) record = await scheduler.create_run( _request( @@ -205,7 +207,12 @@ def test_create_run_honors_explicit_empty_layer_providers_by_failing_after_persi async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, layer_providers=()) + scheduler = RunScheduler( + store=store, + plugin_daemon_http_client=client, + dify_api_http_client=client, + layer_providers=(), + ) record = await scheduler.create_run(_request()) await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1) @@ -222,7 +229,7 @@ def test_create_run_accepts_closed_session_snapshot_and_runner_fails_asynchronou async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, dify_api_http_client=client) request = _request() request.session_snapshot = CompositorSessionSnapshot( layers=[ @@ -248,7 +255,7 @@ def test_create_run_accepts_closed_session_snapshot_and_runner_fails_asynchronou def test_create_run_rejects_after_shutdown_starts() -> None: async def scenario() -> None: async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=FakeStore(), plugin_daemon_http_client=client) + scheduler = RunScheduler(store=FakeStore(), plugin_daemon_http_client=client, dify_api_http_client=client) await scheduler.shutdown() with pytest.raises(SchedulerStoppingError): @@ -261,7 +268,7 @@ def test_create_run_rejects_invalid_request_after_shutdown_without_persisting() async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, dify_api_http_client=client) await scheduler.shutdown() with pytest.raises(SchedulerStoppingError): @@ -282,6 +289,7 @@ def test_shutdown_waits_for_in_flight_create_to_register_before_cancelling() -> scheduler = RunScheduler( store=store, plugin_daemon_http_client=client, + dify_api_http_client=client, shutdown_grace_seconds=0, runner_factory=lambda _record, _request: ControlledRunner( started=runner_started, release=asyncio.Event() diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index c910b7c3dd9..f5ddeb72367 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -41,6 +41,8 @@ from dify_agent.layers.dify_plugin.configs import ( ) from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer +from dify_agent.layers.knowledge.configs import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig +from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID from dify_agent.protocol.schemas import ( @@ -357,6 +359,7 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa request=request, run_id="run-1", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() assert seen_clients == [client] assert client.is_closed is False @@ -406,6 +409,7 @@ def test_runner_preserves_explicit_json_null_output(monkeypatch: pytest.MonkeyPa request=request, run_id="run-null-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -462,6 +466,7 @@ def test_runner_emits_deferred_tool_call_and_persists_pending_history(monkeypatc request=request, run_id="run-ask-human", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -558,6 +563,7 @@ def test_runner_resumes_with_deferred_tool_results_and_no_user_prompt(monkeypatc request=request, run_id="run-ask-human-initial", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() initial_terminal = sink.events["run-ask-human-initial"][-1] @@ -582,6 +588,7 @@ def test_runner_resumes_with_deferred_tool_results_and_no_user_prompt(monkeypatc request=resumed_request, run_id="run-ask-human-resume", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -657,6 +664,7 @@ def test_runner_can_emit_second_deferred_tool_call_after_resume(monkeypatch: pyt request=request, run_id="run-ask-human-turn-1", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() first_terminal = sink.events["run-ask-human-turn-1"][-1] @@ -681,6 +689,7 @@ def test_runner_can_emit_second_deferred_tool_call_after_resume(monkeypatch: pyt request=resumed_request, run_id="run-ask-human-turn-2", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -736,6 +745,7 @@ def test_runner_rejects_deferred_tool_call_without_history_layer(monkeypatch: py request=request, run_id="run-ask-human-no-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -785,6 +795,7 @@ def test_runner_rejects_resume_with_deferred_tool_results_without_history_layer( request=request, run_id="run-ask-human-resume-no-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -823,6 +834,7 @@ def test_runner_rejects_multiple_deferred_tool_calls(monkeypatch: pytest.MonkeyP request=request, run_id="run-ask-human-multi", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -861,6 +873,7 @@ def test_runner_rejects_deferred_approval_requests(monkeypatch: pytest.MonkeyPat request=request, run_id="run-ask-human-approval", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -960,6 +973,7 @@ def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.Mo request=request, run_id="run-tools", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -970,6 +984,105 @@ def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.Mo assert terminal.data.output == "done" +def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest.MonkeyPatch) -> None: + seen_tools: list[Tool[object]] = [] + + async def knowledge_tool() -> str: + return "knowledge" + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + async def fake_get_tools(self: DifyKnowledgeBaseLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + assert self.config.dataset_ids == ["dataset-1"] + assert http_client.headers.get("X-Test-Client") == "dify-api" + return [Tool(knowledge_tool, name="knowledge_base_search")] + + class FakeResult: + output: str = "done" + + def new_messages(self) -> list[ModelMessage]: + return [] + + class FakeAgent: + async def run(self, *_args: object, **_kwargs: object) -> FakeResult: + return FakeResult() + + def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent: + del model, output_type + seen_tools.extend(tools) + return FakeAgent() + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr(DifyKnowledgeBaseLayer, "get_tools", fake_get_tools) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent) + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + app_id="app-1", + agent_mode="workflow_run", + invoke_from="service-api", + ), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"execution_context": "execution_context"}, + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + RunLayerSpec( + name="knowledge", + type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ), + ), + ] + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with ( + httpx.AsyncClient() as plugin_client, + httpx.AsyncClient(headers={"X-Test-Client": "dify-api"}) as dify_api_client, + ): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-knowledge-tools", + plugin_daemon_http_client=plugin_client, + dify_api_http_client=dify_api_client, + ).run() + + asyncio.run(scenario()) + + assert [tool.name for tool in seen_tools] == ["knowledge_base_search"] + + def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1075,6 +1188,7 @@ def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers( request=request, run_id="run-duplicate-tools", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1182,6 +1296,7 @@ def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools( request=request, run_id="run-static-dynamic-duplicate-tools", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=layer_providers, ).run() @@ -1297,6 +1412,7 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( request=request, run_id="run-shell-duplicate-tools", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=layer_providers, ).run() @@ -1325,6 +1441,7 @@ def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monk request=_request("current user"), run_id="run-no-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1368,6 +1485,7 @@ def test_runner_prepends_current_system_prompt_to_stored_history_and_appends_onl request=request, run_id="run-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1418,6 +1536,7 @@ def test_runner_with_empty_history_layer_still_sends_system_prompt_and_saves_onl request=request, run_id="run-empty-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1468,6 +1587,7 @@ def test_runner_failure_with_history_layer_emits_failed_terminal_event_without_s request=request, run_id="run-history-failure", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1499,6 +1619,7 @@ def test_runner_applies_on_exit_overrides_to_success_snapshot(monkeypatch: pytes request=request, run_id="run-exit", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1559,6 +1680,7 @@ def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_resu request=request, run_id="run-structured-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() first_terminal = sink.events["run-structured-output"][-1] @@ -1572,6 +1694,7 @@ def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_resu request=resumed_request, run_id="run-structured-output-resume", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1645,6 +1768,7 @@ def test_runner_retries_invalid_structured_output_and_eventually_succeeds(monkey request=request, run_id="run-output-retry-success", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1698,6 +1822,7 @@ def test_runner_fails_when_invalid_structured_output_exhausts_retries(monkeypatc request=request, run_id="run-output-retry-failed", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1734,6 +1859,7 @@ def test_runner_rejects_invalid_output_layer_before_model_resolution(monkeypatch request=request, run_id="run-invalid-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1808,6 +1934,7 @@ def test_runner_rejects_misnamed_output_layer_before_model_resolution(monkeypatc request=request, run_id="run-misnamed-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1894,6 +2021,7 @@ def test_runner_rejects_multiple_output_layers_before_model_resolution(monkeypat request=request, run_id="run-duplicate-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1965,6 +2093,7 @@ def test_runner_rejects_reserved_output_name_with_wrong_layer_type_before_model_ request=request, run_id="run-wrong-output-type", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2009,6 +2138,7 @@ def test_runner_rejects_misnamed_output_layer_before_provider_checks() -> None: request=request, run_id="run-misnamed-output-before-providers", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=(), ).run() @@ -2033,6 +2163,7 @@ def test_runner_rejects_unknown_on_exit_layer_id() -> None: request=request, run_id="run-unknown-signal", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2053,6 +2184,7 @@ def test_runner_honors_explicit_empty_layer_providers() -> None: request=request, run_id="run-empty-providers", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=(), ).run() @@ -2074,6 +2206,7 @@ def test_runner_fails_empty_user_prompts() -> None: request=request, run_id="run-2", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2094,6 +2227,7 @@ def test_runner_fails_blank_string_user_prompt_list() -> None: request=request, run_id="run-3", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2114,6 +2248,7 @@ def test_runner_requires_llm_layer_id() -> None: request=request, run_id="run-4", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2153,6 +2288,7 @@ def test_runner_rejects_closed_session_snapshot_as_validation_error() -> None: request=request, run_id="run-closed-snapshot", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2205,6 +2341,7 @@ def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None: request=request, run_id="run-missing-shell-entrypoint", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2282,6 +2419,7 @@ def test_runner_treats_invalid_shell_snapshot_offsets_as_validation_error() -> N request=request, run_id="run-invalid-shell-offset", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=create_default_layer_providers(shellctl_entrypoint="http://shellctl"), ).run() diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index 534b42e764e..b12a636381e 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -13,10 +13,12 @@ from shell_session_manager.shellctl.client import ShellctlClient import dify_agent.server.app as app_module from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig +from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.layers.shell import DifyShellLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider -from dify_agent.server.app import create_app, create_plugin_daemon_http_client +from dify_agent.server.app import create_app, create_dify_api_inner_http_client, create_plugin_daemon_http_client from dify_agent.server.settings import ServerSettings from dify_agent.storage.redis_run_store import RedisRunStore @@ -67,6 +69,7 @@ class FakeRunScheduler: shutdown_grace_seconds: float layer_providers: tuple[DifyAgentLayerProvider, ...] plugin_daemon_http_client: FakePluginDaemonHttpClient + dify_api_http_client: FakePluginDaemonHttpClient shutdown_called: bool def __init__( @@ -74,6 +77,7 @@ class FakeRunScheduler: *, store: object, plugin_daemon_http_client: FakePluginDaemonHttpClient, + dify_api_http_client: FakePluginDaemonHttpClient, shutdown_grace_seconds: float, layer_providers: tuple[DifyAgentLayerProvider, ...], ) -> None: @@ -81,6 +85,7 @@ class FakeRunScheduler: self.shutdown_grace_seconds = shutdown_grace_seconds self.layer_providers = layer_providers self.plugin_daemon_http_client = plugin_daemon_http_client + self.dify_api_http_client = dify_api_http_client self.shutdown_called = False self.created.append(self) @@ -160,7 +165,22 @@ class FakeHttpxModule: def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pytest.MonkeyPatch) -> None: - fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) + fake_redis = FakeRedis() + fake_http_client = FakePluginDaemonHttpClient() + fake_dify_api_http_client = FakePluginDaemonHttpClient() + FakeRunScheduler.created.clear() + FakeRedisModule.fake_redis = fake_redis + monkeypatch.setattr(app_module, "Redis", FakeRedisModule) + monkeypatch.setattr(app_module, "RunScheduler", FakeRunScheduler) + + def fake_create_plugin_daemon_http_client(_settings: ServerSettings) -> FakePluginDaemonHttpClient: + return fake_http_client + + def fake_create_dify_api_inner_http_client(_settings: ServerSettings) -> FakePluginDaemonHttpClient: + return fake_dify_api_http_client + + monkeypatch.setattr(app_module, "create_plugin_daemon_http_client", fake_create_plugin_daemon_http_client) + monkeypatch.setattr(app_module, "create_dify_api_inner_http_client", fake_create_dify_api_inner_http_client) settings = ServerSettings( redis_url="redis://example.invalid/0", @@ -169,19 +189,20 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt run_retention_seconds=7, plugin_daemon_url="http://plugin-daemon", plugin_daemon_api_key="daemon-secret", + dify_api_inner_url="http://dify-api", shellctl_entrypoint="http://shellctl", shellctl_auth_token="shell-secret", agent_stub_url="https://agent.example.com/agent-stub", server_secret_key=_base64url_secret(b"1" * 32), dify_api_base_url="https://api.example.com", dify_api_inner_api_key="inner-secret", - plugin_daemon_connect_timeout=1, - plugin_daemon_read_timeout=2, - plugin_daemon_write_timeout=3, - plugin_daemon_pool_timeout=4, - plugin_daemon_max_connections=5, - plugin_daemon_max_keepalive_connections=3, - plugin_daemon_keepalive_expiry=6, + outbound_http_connect_timeout=1, + outbound_http_read_timeout=2, + outbound_http_write_timeout=3, + outbound_http_pool_timeout=4, + outbound_http_max_connections=5, + outbound_http_max_keepalive_connections=3, + outbound_http_keepalive_expiry=6, ) with TestClient(create_app(settings)): @@ -207,6 +228,18 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt assert isinstance(shell_layer, DifyShellLayer) assert execution_context_layer.daemon_url == "http://plugin-daemon" assert execution_context_layer.daemon_api_key == "daemon-secret" + knowledge_provider = next(provider for provider in layer_providers if provider.type_id == "dify.knowledge_base") + knowledge_layer = knowledge_provider.create_layer( + DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 2}, + } + ) + ) + assert isinstance(knowledge_layer, DifyKnowledgeBaseLayer) + assert knowledge_layer.dify_api_inner_url == "http://dify-api" + assert knowledge_layer.dify_api_inner_api_key == "inner-secret" assert shell_layer.shellctl_entrypoint == "http://shellctl" assert shell_layer.agent_stub_url == "https://agent.example.com/agent-stub" shellctl_client = shell_layer.shellctl_client_factory("http://shellctl") @@ -216,6 +249,8 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt http_client = scheduler.plugin_daemon_http_client assert http_client is fake_http_client assert http_client.is_closed is False + assert scheduler.dify_api_http_client is fake_dify_api_http_client + assert scheduler.dify_api_http_client.is_closed is False store = scheduler.store assert isinstance(store, RedisRunStore) assert store.run_retention_seconds == 7 @@ -229,6 +264,7 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt ) assert FakeRunScheduler.created[0].shutdown_called is True + assert FakeRunScheduler.created[0].dify_api_http_client.is_closed is True assert FakeRunScheduler.created[0].plugin_daemon_http_client.is_closed is True assert fake_redis.closed is True @@ -326,21 +362,75 @@ def test_create_app_starts_and_stops_agent_stub_grpc_server_for_grpc_url(monkeyp assert fake_redis.closed is True -def test_create_plugin_daemon_http_client_uses_configured_httpx_construction_args( +def test_create_plugin_daemon_http_client_uses_generic_outbound_httpx_construction_args( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(app_module, "httpx", FakeHttpxModule) - client = create_plugin_daemon_http_client(ServerSettings()) + client = create_plugin_daemon_http_client( + ServerSettings( + outbound_http_connect_timeout=1, + outbound_http_read_timeout=2, + outbound_http_write_timeout=3, + outbound_http_pool_timeout=4, + outbound_http_max_connections=5, + outbound_http_max_keepalive_connections=3, + outbound_http_keepalive_expiry=6, + ) + ) assert isinstance(client, FakePluginDaemonHttpClient) assert isinstance(client.timeout, FakeTimeout) - assert client.timeout.connect == 10 - assert client.timeout.read == 600 - assert client.timeout.write == 30 - assert client.timeout.pool == 10 + assert client.timeout.connect == 1 + assert client.timeout.read == 2 + assert client.timeout.write == 3 + assert client.timeout.pool == 4 assert isinstance(client.limits, FakeLimits) - assert client.limits.max_connections == 100 - assert client.limits.max_keepalive_connections == 20 - assert client.limits.keepalive_expiry == 30 + assert client.limits.max_connections == 5 + assert client.limits.max_keepalive_connections == 3 + assert client.limits.keepalive_expiry == 6 assert client.trust_env is False + + +def test_create_dify_api_inner_http_client_uses_generic_outbound_httpx_construction_args( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(app_module, "httpx", FakeHttpxModule) + + client = create_dify_api_inner_http_client( + ServerSettings( + outbound_http_connect_timeout=1, + outbound_http_read_timeout=2, + outbound_http_write_timeout=3, + outbound_http_pool_timeout=4, + outbound_http_max_connections=5, + outbound_http_max_keepalive_connections=3, + outbound_http_keepalive_expiry=6, + ) + ) + + assert isinstance(client, FakePluginDaemonHttpClient) + assert isinstance(client.timeout, FakeTimeout) + assert client.timeout.connect == 1 + assert client.timeout.read == 2 + assert client.timeout.write == 3 + assert client.timeout.pool == 4 + assert isinstance(client.limits, FakeLimits) + assert client.limits.max_connections == 5 + assert client.limits.max_keepalive_connections == 3 + assert client.limits.keepalive_expiry == 6 + assert client.trust_env is False + + +def test_server_settings_use_generic_outbound_http_args_for_shared_clients() -> None: + model_fields = ServerSettings.model_fields + + assert "dify_api_inner_url" in model_fields + assert "dify_api_inner_api_key" in model_fields + assert "outbound_http_connect_timeout" in model_fields + assert "outbound_http_read_timeout" in model_fields + assert "outbound_http_write_timeout" in model_fields + assert "outbound_http_pool_timeout" in model_fields + assert "outbound_http_max_connections" in model_fields + assert "outbound_http_max_keepalive_connections" in model_fields + assert "outbound_http_keepalive_expiry" in model_fields diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py index 07b8e09f53d..fb444f840c2 100644 --- a/dify-agent/tests/local/dify_agent/server/test_settings.py +++ b/dify-agent/tests/local/dify_agent/server/test_settings.py @@ -129,12 +129,13 @@ def test_server_settings_normalizes_dify_api_base_url_from_env(monkeypatch: pyte assert settings.dify_api_inner_api_key == "inner-secret" -def test_server_settings_requires_dify_api_base_url_and_key_together() -> None: - with pytest.raises(ValidationError, match="DIFY_AGENT_DIFY_API_BASE_URL"): +def test_server_settings_requires_inner_api_key_when_dify_api_base_url_is_set() -> None: + with pytest.raises(ValidationError, match="DIFY_AGENT_DIFY_API_INNER_API_KEY"): _ = ServerSettings(dify_api_base_url="https://api.example.com") - with pytest.raises(ValidationError, match="DIFY_AGENT_DIFY_API_BASE_URL"): - _ = ServerSettings(dify_api_inner_api_key="inner-secret") + settings = ServerSettings(dify_api_inner_api_key="inner-secret") + assert settings.dify_api_inner_api_key == "inner-secret" + assert settings.dify_api_base_url is None def test_server_settings_rejects_dify_api_base_url_with_query_or_fragment() -> None: diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 0ac1d77615b..ecc2d548574 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -84,6 +84,8 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "dify_agent.layers.ask_human.layer", "dify_agent.layers.dify_plugin.llm_layer", "dify_agent.layers.dify_plugin.tools_layer", + "dify_agent.layers.knowledge.client", + "dify_agent.layers.knowledge.layer", "dify_agent.layers.output.output_layer", "dify_agent.layers.shell.layer", "dify_agent.runtime", @@ -103,6 +105,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "dify_agent.layers.execution_context", "dify_agent.layers.ask_human", "dify_agent.layers.dify_plugin", + "dify_agent.layers.knowledge", "dify_agent.layers.output", "dify_agent.layers.shell", ], @@ -112,6 +115,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']", "assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']", "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", + "assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig']", "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", "assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']", ], From 48452aefbc4df5b9ee9748021ad816e3a0bba14c Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 17 Jun 2026 17:28:43 +0800 Subject: [PATCH 14/62] feat: app deploy (#35670) Co-authored-by: zhangx1n Co-authored-by: yyh Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .agents/skills/component-refactoring/SKILL.md | 440 ---- .../references/complexity-patterns.md | 495 ----- .../references/component-splitting.md | 477 ----- .../references/hook-extraction.md | 281 --- .../skills/how-to-write-component/SKILL.md | 69 +- api/controllers/inner_api/__init__.py | 2 + .../inner_api/runtime_credentials.py | 205 ++ api/openapi/markdown/console-openapi.md | 1 + api/openapi/markdown/web-openapi.md | 1 + api/services/feature_service.py | 4 + .../services/test_feature_service.py | 5 + .../inner_api/test_runtime_credentials.py | 208 ++ .../test_feature_service_enable_app_deploy.py | 37 + eslint-suppressions.json | 18 - .../api/console/system-features/types.gen.ts | 1 + .../api/console/system-features/zod.gen.ts | 1 + .../contracts/generated/api/web/types.gen.ts | 1 + .../contracts/generated/api/web/zod.gen.ts | 1 + .../generated/enterprise/orpc.gen.ts | 612 ++++++ .../generated/enterprise/types.gen.ts | 1802 ++++++++++++++++- .../contracts/generated/enterprise/zod.gen.ts | 1675 ++++++++++++++- .../contracts/openapi-ts.enterprise.config.ts | 230 ++- pnpm-lock.yaml | 54 + pnpm-workspace.yaml | 3 + .../app/app-access-control-flow.test.tsx | 4 +- web/__tests__/app/app-publisher-flow.test.tsx | 8 +- .../apps/app-card-operations-flow.test.tsx | 8 +- .../__tests__/role-route-guard.spec.tsx | 58 +- .../[appInstanceId]/access/page.tsx | 8 + .../[appInstanceId]/api-tokens/page.tsx | 8 + .../[appInstanceId]/instances/page.tsx | 8 + .../deployments/[appInstanceId]/layout.tsx | 15 + .../[appInstanceId]/overview/page.tsx | 8 + .../deployments/[appInstanceId]/page.tsx | 8 + .../[appInstanceId]/releases/page.tsx | 8 + .../deployments/create/page.tsx | 12 + web/app/(commonLayout)/deployments/layout.tsx | 13 + web/app/(commonLayout)/deployments/page.tsx | 10 + web/app/(commonLayout)/layout.tsx | 5 +- web/app/(commonLayout)/role-route-guard.tsx | 22 +- .../__tests__/access-control-dialog.spec.tsx | 2 +- .../__tests__/access-control-item.spec.tsx | 48 +- .../access-control-radio-group-harness.tsx | 17 + .../__tests__/access-control-test-utils.ts | 71 + .../__tests__/access-control.spec.tsx | 169 +- .../add-member-or-group-pop.spec.tsx | 50 +- .../__tests__/index.spec.tsx | 76 +- .../specific-groups-or-members.spec.tsx | 68 +- .../access-control-dialog-content.tsx | 110 + .../access-control-dialog.tsx | 15 +- .../access-control-item.tsx | 45 +- .../access-subject-selector/add-button.tsx | 208 ++ .../selection-list.tsx | 207 ++ .../subject-options.tsx | 217 ++ .../access-subject-selector/types.ts | 15 + .../access-subject-selector/utils.ts | 64 + .../add-member-or-group-pop.tsx | 376 +--- .../app/app-access-control/index.tsx | 167 +- .../specific-groups-or-members.tsx | 133 +- .../app/app-access-control/store-provider.tsx | 34 + .../app/app-access-control/store.ts | 52 + .../__tests__/features-wrapper.spec.tsx | 2 +- .../app-publisher/__tests__/index.spec.tsx | 26 +- .../app/app-publisher/features-wrapper.tsx | 2 +- .../components/app/app-publisher/index.tsx | 108 +- .../__tests__/app-card-sections.spec.tsx | 2 +- .../app/overview/__tests__/app-card.spec.tsx | 2 +- .../app/overview/app-card-sections.tsx | 2 +- .../apps/__tests__/app-card.spec.tsx | 8 +- .../components/apps/__tests__/list.spec.tsx | 3 + web/app/components/apps/app-card.tsx | 70 +- web/app/components/apps/list.tsx | 2 +- .../header/__tests__/index.spec.tsx | 8 +- .../dataset-nav/__tests__/index.spec.tsx | 28 +- .../components/header/dataset-nav/index.tsx | 128 +- .../explore-nav/__tests__/index.spec.tsx | 2 +- .../components/header/explore-nav/index.tsx | 14 +- web/app/components/header/index.tsx | 37 +- .../header/nav/__tests__/index.spec.tsx | 21 +- web/app/components/header/nav/index.tsx | 16 +- .../nav/nav-selector/__tests__/index.spec.tsx | 21 +- .../header/nav/nav-selector/index.tsx | 103 +- .../header/tools-nav/__tests__/index.spec.tsx | 2 +- web/app/components/header/tools-nav/index.tsx | 14 +- .../main-nav/__tests__/index.spec.tsx | 76 +- web/app/components/main-nav/index.tsx | 46 +- .../__tests__/features-trigger.spec.tsx | 2 +- .../workflow-header/features-trigger.tsx | 2 +- web/app/components/workflow/utils/common.ts | 2 +- web/context/access-control-store.ts | 34 - web/context/query-client.tsx | 18 +- web/contract/console/explore.ts | 15 + web/contract/router.ts | 2 + .../__tests__/env-var-bindings-utils.spec.ts | 53 + .../runtime-credential-bindings-utils.spec.ts | 129 ++ .../deployment-actions/delete-dialog.tsx | 88 + .../deployment-actions/edit-dialog.tsx | 206 ++ .../deployment-actions/index.spec.tsx | 34 + .../components/deployment-actions/index.tsx | 101 + .../deployments/components/empty-state.tsx | 143 ++ .../components/env-var-bindings-utils.ts | 29 + .../components/env-var-bindings.tsx | 245 +++ .../runtime-credential-bindings-utils.ts | 115 ++ .../runtime-credential-bindings.tsx | 197 ++ .../components/title-tooltip.spec.tsx | 71 + .../deployments/components/title-tooltip.tsx | 74 + .../unsupported-dsl-nodes-alert.tsx | 69 + .../deployments/create-guide/index.tsx | 35 + .../create-guide/state/environment.ts | 9 + .../deployments/create-guide/state/index.ts | 920 +++++++++ .../create-guide/state/provider.tsx | 15 + .../deployments/create-guide/ui/layout.tsx | 215 ++ .../create-guide/ui/release-step.tsx | 209 ++ .../deployments/create-guide/ui/shell.tsx | 78 + .../create-guide/ui/source-step.tsx | 360 ++++ .../create-guide/ui/target-step.tsx | 356 ++++ .../deployments/create-release/index.tsx | 72 + .../deployments/create-release/state/index.ts | 122 ++ .../deployments/create-release/state/types.ts | 19 + .../state/use-create-release-form.ts | 24 + .../deployments/create-release/ui/actions.tsx | 91 + .../create-release/ui/content-feedback.tsx | 63 + .../deployments/create-release/ui/dialog.tsx | 203 ++ .../create-release/ui/metadata-fields.tsx | 106 + .../create-release/ui/source-app-mode.ts | 11 + .../ui/source-app-picker-value.ts | 23 + .../create-release/ui/source-app-picker.tsx | 241 +++ .../create-release/ui/source-section.tsx | 153 ++ .../ui/use-create-release-submission.ts | 109 + .../ui/use-release-content-check.ts | 124 ++ .../deployments/deploy-drawer/index.tsx | 64 + .../state/__tests__/index.spec.ts | 451 +++++ .../deployments/deploy-drawer/state/index.ts | 442 ++++ .../deploy-drawer/state/release-options.ts | 25 + .../deploy-drawer/ui/form-sections.tsx | 197 ++ .../deploy-drawer/ui/form-skeleton.tsx | 42 + .../deployments/deploy-drawer/ui/form.tsx | 269 +++ .../deployments/deploy-drawer/ui/select.tsx | 135 ++ .../deploy-drawer/ui/status-badge.tsx | 22 + .../deployments/detail/access-tab.tsx | 35 + web/features/deployments/detail/common.tsx | 96 + .../deployments/detail/deploy-tab.tsx | 125 ++ .../deployment-environment-list.tsx | 163 ++ .../deploy-tab/deployment-error-dialog.tsx | 76 + .../deployment-row-actions-menu.tsx | 135 ++ .../deploy-tab/deployment-row-actions.tsx | 109 + .../deploy-tab/deployment-status-summary.tsx | 107 + .../deploy-tab/new-deployment-button.tsx | 45 + .../deploy-tab/undeploy-deployment-dialog.tsx | 65 + .../deployments/detail/deployment-sidebar.tsx | 321 +++ .../deployments/detail/developer-api-tab.tsx | 13 + web/features/deployments/detail/index.tsx | 89 + .../deployments/detail/overview-tab.tsx | 122 ++ .../overview-tab/access-status-section.tsx | 240 +++ .../detail/overview-tab/card-styles.ts | 10 + .../detail/overview-tab/environment-strip.tsx | 148 ++ .../environment-tile-utils.spec.ts | 143 ++ .../overview-tab/environment-tile-utils.ts | 152 ++ .../detail/overview-tab/environment-tile.tsx | 170 ++ .../detail/overview-tab/overview-drift.ts | 29 + .../detail/overview-tab/release-hero.tsx | 168 ++ .../access/__tests__/access-policy.spec.ts | 159 ++ .../__tests__/api-key-generate-menu.spec.tsx | 51 + .../__tests__/channels-section.spec.tsx | 62 + .../access/__tests__/permissions.spec.tsx | 134 ++ .../settings-tab/access/access-policy.ts | 169 ++ .../settings-tab/access/api-docs-drawer.tsx | 130 ++ .../access/api-key-generate-menu.tsx | 237 +++ .../settings-tab/access/api-key-list.tsx | 253 +++ .../settings-tab/access/api-token-name.ts | 39 + .../settings-tab/access/channels-section.tsx | 251 +++ .../detail/settings-tab/access/common.tsx | 78 + .../developer-api-created-token-dialog.tsx | 97 + .../access/developer-api-section.tsx | 268 +++ .../access/developer-api-skeleton.tsx | 112 + .../access/permission-row-components.tsx | 163 ++ .../access/permissions-section.tsx | 75 + .../settings-tab/access/permissions.tsx | 205 ++ .../detail/settings-tab/access/url.ts | 10 + .../deployments/detail/table-styles.ts | 31 + web/features/deployments/detail/table.tsx | 91 + web/features/deployments/detail/tabs.ts | 9 + .../deployments/detail/versions-tab.tsx | 12 + .../__tests__/deploy-release-menu.spec.tsx | 104 + .../__tests__/release-history-rows.spec.tsx | 85 + .../versions-tab/delete-release-dialog.tsx | 63 + .../deploy-release-menu-utils.spec.ts | 65 + .../versions-tab/deploy-release-menu-utils.ts | 150 ++ .../versions-tab/deploy-release-menu.tsx | 239 +++ .../detail/versions-tab/deployed-to-badge.tsx | 41 + .../versions-tab/edit-release-dialog.tsx | 188 ++ .../versions-tab/release-deployments.ts | 49 + .../detail/versions-tab/release-dsl-export.ts | 44 + .../detail/versions-tab/release-dsl.ts | 12 + .../release-history-deployments.tsx | 20 + .../versions-tab/release-history-rows.tsx | 241 +++ .../release-history-table-skeleton.tsx | 96 + .../versions-tab/release-history-table.tsx | 88 + .../versions-tab/release-history-types.ts | 10 + web/features/deployments/list/index.tsx | 41 + web/features/deployments/list/state/index.ts | 87 + .../list/ui/create-deployment-button.tsx | 24 + .../list/ui/environment-filter.tsx | 111 + .../list/ui/instance-card-sections.tsx | 236 +++ .../list/ui/instance-card-utils.ts | 21 + .../deployments/list/ui/instance-card.tsx | 129 ++ web/features/deployments/list/ui/shell.tsx | 224 ++ web/features/deployments/nav/index.tsx | 114 ++ web/features/deployments/shared/domain/dsl.ts | 150 ++ .../deployments/shared/domain/error.ts | 137 ++ .../deployments/shared/domain/idempotency.ts | 6 + .../deployments/shared/domain/pagination.ts | 12 + .../shared/domain/release-action.ts | 69 + .../deployments/shared/domain/release.ts | 17 + .../shared/domain/runtime-status.ts | 30 + .../deployment-status-badge.spec.tsx | 52 + .../shared/ui/deployment-status-badge.tsx | 123 ++ .../shared/ui/deployment-status-style.ts | 79 + web/features/system-features/config.ts | 1 + web/i18n-config/resources.ts | 3 + web/i18n/en-US/common.json | 1 + web/i18n/en-US/deployments.json | 623 ++++++ web/i18n/zh-Hans/common.json | 1 + web/i18n/zh-Hans/deployments.json | 623 ++++++ web/knip.config.ts | 2 + web/models/access-control.ts | 40 +- web/package.json | 3 + web/service/access-control.ts | 28 +- web/service/client.ts | 513 ++++- web/utils/clipboard.ts | 13 +- 230 files changed, 23796 insertions(+), 2956 deletions(-) delete mode 100644 .agents/skills/component-refactoring/SKILL.md delete mode 100644 .agents/skills/component-refactoring/references/complexity-patterns.md delete mode 100644 .agents/skills/component-refactoring/references/component-splitting.md delete mode 100644 .agents/skills/component-refactoring/references/hook-extraction.md create mode 100644 api/controllers/inner_api/runtime_credentials.py create mode 100644 api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py create mode 100644 api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py create mode 100644 web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx create mode 100644 web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx create mode 100644 web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx create mode 100644 web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx create mode 100644 web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx create mode 100644 web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx create mode 100644 web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx create mode 100644 web/app/(commonLayout)/deployments/create/page.tsx create mode 100644 web/app/(commonLayout)/deployments/layout.tsx create mode 100644 web/app/(commonLayout)/deployments/page.tsx create mode 100644 web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx create mode 100644 web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts create mode 100644 web/app/components/app/app-access-control/access-control-dialog-content.tsx create mode 100644 web/app/components/app/app-access-control/access-subject-selector/add-button.tsx create mode 100644 web/app/components/app/app-access-control/access-subject-selector/selection-list.tsx create mode 100644 web/app/components/app/app-access-control/access-subject-selector/subject-options.tsx create mode 100644 web/app/components/app/app-access-control/access-subject-selector/types.ts create mode 100644 web/app/components/app/app-access-control/access-subject-selector/utils.ts create mode 100644 web/app/components/app/app-access-control/store-provider.tsx create mode 100644 web/app/components/app/app-access-control/store.ts delete mode 100644 web/context/access-control-store.ts create mode 100644 web/features/deployments/components/__tests__/env-var-bindings-utils.spec.ts create mode 100644 web/features/deployments/components/__tests__/runtime-credential-bindings-utils.spec.ts create mode 100644 web/features/deployments/components/deployment-actions/delete-dialog.tsx create mode 100644 web/features/deployments/components/deployment-actions/edit-dialog.tsx create mode 100644 web/features/deployments/components/deployment-actions/index.spec.tsx create mode 100644 web/features/deployments/components/deployment-actions/index.tsx create mode 100644 web/features/deployments/components/empty-state.tsx create mode 100644 web/features/deployments/components/env-var-bindings-utils.ts create mode 100644 web/features/deployments/components/env-var-bindings.tsx create mode 100644 web/features/deployments/components/runtime-credential-bindings-utils.ts create mode 100644 web/features/deployments/components/runtime-credential-bindings.tsx create mode 100644 web/features/deployments/components/title-tooltip.spec.tsx create mode 100644 web/features/deployments/components/title-tooltip.tsx create mode 100644 web/features/deployments/components/unsupported-dsl-nodes-alert.tsx create mode 100644 web/features/deployments/create-guide/index.tsx create mode 100644 web/features/deployments/create-guide/state/environment.ts create mode 100644 web/features/deployments/create-guide/state/index.ts create mode 100644 web/features/deployments/create-guide/state/provider.tsx create mode 100644 web/features/deployments/create-guide/ui/layout.tsx create mode 100644 web/features/deployments/create-guide/ui/release-step.tsx create mode 100644 web/features/deployments/create-guide/ui/shell.tsx create mode 100644 web/features/deployments/create-guide/ui/source-step.tsx create mode 100644 web/features/deployments/create-guide/ui/target-step.tsx create mode 100644 web/features/deployments/create-release/index.tsx create mode 100644 web/features/deployments/create-release/state/index.ts create mode 100644 web/features/deployments/create-release/state/types.ts create mode 100644 web/features/deployments/create-release/state/use-create-release-form.ts create mode 100644 web/features/deployments/create-release/ui/actions.tsx create mode 100644 web/features/deployments/create-release/ui/content-feedback.tsx create mode 100644 web/features/deployments/create-release/ui/dialog.tsx create mode 100644 web/features/deployments/create-release/ui/metadata-fields.tsx create mode 100644 web/features/deployments/create-release/ui/source-app-mode.ts create mode 100644 web/features/deployments/create-release/ui/source-app-picker-value.ts create mode 100644 web/features/deployments/create-release/ui/source-app-picker.tsx create mode 100644 web/features/deployments/create-release/ui/source-section.tsx create mode 100644 web/features/deployments/create-release/ui/use-create-release-submission.ts create mode 100644 web/features/deployments/create-release/ui/use-release-content-check.ts create mode 100644 web/features/deployments/deploy-drawer/index.tsx create mode 100644 web/features/deployments/deploy-drawer/state/__tests__/index.spec.ts create mode 100644 web/features/deployments/deploy-drawer/state/index.ts create mode 100644 web/features/deployments/deploy-drawer/state/release-options.ts create mode 100644 web/features/deployments/deploy-drawer/ui/form-sections.tsx create mode 100644 web/features/deployments/deploy-drawer/ui/form-skeleton.tsx create mode 100644 web/features/deployments/deploy-drawer/ui/form.tsx create mode 100644 web/features/deployments/deploy-drawer/ui/select.tsx create mode 100644 web/features/deployments/deploy-drawer/ui/status-badge.tsx create mode 100644 web/features/deployments/detail/access-tab.tsx create mode 100644 web/features/deployments/detail/common.tsx create mode 100644 web/features/deployments/detail/deploy-tab.tsx create mode 100644 web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx create mode 100644 web/features/deployments/detail/deploy-tab/deployment-error-dialog.tsx create mode 100644 web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx create mode 100644 web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx create mode 100644 web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx create mode 100644 web/features/deployments/detail/deploy-tab/new-deployment-button.tsx create mode 100644 web/features/deployments/detail/deploy-tab/undeploy-deployment-dialog.tsx create mode 100644 web/features/deployments/detail/deployment-sidebar.tsx create mode 100644 web/features/deployments/detail/developer-api-tab.tsx create mode 100644 web/features/deployments/detail/index.tsx create mode 100644 web/features/deployments/detail/overview-tab.tsx create mode 100644 web/features/deployments/detail/overview-tab/access-status-section.tsx create mode 100644 web/features/deployments/detail/overview-tab/card-styles.ts create mode 100644 web/features/deployments/detail/overview-tab/environment-strip.tsx create mode 100644 web/features/deployments/detail/overview-tab/environment-tile-utils.spec.ts create mode 100644 web/features/deployments/detail/overview-tab/environment-tile-utils.ts create mode 100644 web/features/deployments/detail/overview-tab/environment-tile.tsx create mode 100644 web/features/deployments/detail/overview-tab/overview-drift.ts create mode 100644 web/features/deployments/detail/overview-tab/release-hero.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts create mode 100644 web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/access-policy.ts create mode 100644 web/features/deployments/detail/settings-tab/access/api-docs-drawer.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/api-key-list.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/api-token-name.ts create mode 100644 web/features/deployments/detail/settings-tab/access/channels-section.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/common.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/developer-api-created-token-dialog.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/developer-api-section.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/developer-api-skeleton.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/permission-row-components.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/permissions-section.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/permissions.tsx create mode 100644 web/features/deployments/detail/settings-tab/access/url.ts create mode 100644 web/features/deployments/detail/table-styles.ts create mode 100644 web/features/deployments/detail/table.tsx create mode 100644 web/features/deployments/detail/tabs.ts create mode 100644 web/features/deployments/detail/versions-tab.tsx create mode 100644 web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx create mode 100644 web/features/deployments/detail/versions-tab/__tests__/release-history-rows.spec.tsx create mode 100644 web/features/deployments/detail/versions-tab/delete-release-dialog.tsx create mode 100644 web/features/deployments/detail/versions-tab/deploy-release-menu-utils.spec.ts create mode 100644 web/features/deployments/detail/versions-tab/deploy-release-menu-utils.ts create mode 100644 web/features/deployments/detail/versions-tab/deploy-release-menu.tsx create mode 100644 web/features/deployments/detail/versions-tab/deployed-to-badge.tsx create mode 100644 web/features/deployments/detail/versions-tab/edit-release-dialog.tsx create mode 100644 web/features/deployments/detail/versions-tab/release-deployments.ts create mode 100644 web/features/deployments/detail/versions-tab/release-dsl-export.ts create mode 100644 web/features/deployments/detail/versions-tab/release-dsl.ts create mode 100644 web/features/deployments/detail/versions-tab/release-history-deployments.tsx create mode 100644 web/features/deployments/detail/versions-tab/release-history-rows.tsx create mode 100644 web/features/deployments/detail/versions-tab/release-history-table-skeleton.tsx create mode 100644 web/features/deployments/detail/versions-tab/release-history-table.tsx create mode 100644 web/features/deployments/detail/versions-tab/release-history-types.ts create mode 100644 web/features/deployments/list/index.tsx create mode 100644 web/features/deployments/list/state/index.ts create mode 100644 web/features/deployments/list/ui/create-deployment-button.tsx create mode 100644 web/features/deployments/list/ui/environment-filter.tsx create mode 100644 web/features/deployments/list/ui/instance-card-sections.tsx create mode 100644 web/features/deployments/list/ui/instance-card-utils.ts create mode 100644 web/features/deployments/list/ui/instance-card.tsx create mode 100644 web/features/deployments/list/ui/shell.tsx create mode 100644 web/features/deployments/nav/index.tsx create mode 100644 web/features/deployments/shared/domain/dsl.ts create mode 100644 web/features/deployments/shared/domain/error.ts create mode 100644 web/features/deployments/shared/domain/idempotency.ts create mode 100644 web/features/deployments/shared/domain/pagination.ts create mode 100644 web/features/deployments/shared/domain/release-action.ts create mode 100644 web/features/deployments/shared/domain/release.ts create mode 100644 web/features/deployments/shared/domain/runtime-status.ts create mode 100644 web/features/deployments/shared/ui/__tests__/deployment-status-badge.spec.tsx create mode 100644 web/features/deployments/shared/ui/deployment-status-badge.tsx create mode 100644 web/features/deployments/shared/ui/deployment-status-style.ts create mode 100644 web/i18n/en-US/deployments.json create mode 100644 web/i18n/zh-Hans/deployments.json diff --git a/.agents/skills/component-refactoring/SKILL.md b/.agents/skills/component-refactoring/SKILL.md deleted file mode 100644 index a7cae67e8f9..00000000000 --- a/.agents/skills/component-refactoring/SKILL.md +++ /dev/null @@ -1,440 +0,0 @@ ---- -name: component-refactoring -description: Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component --json` shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction, or when `pnpm analyze-component` warns to refactor before testing; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring. ---- - -# Dify Component Refactoring Skill - -Refactor high-complexity React components in the Dify frontend codebase with the patterns and workflow below. - -> **Complexity Threshold**: Components with complexity > 50 (measured by `pnpm analyze-component`) should be refactored before testing. - -## Quick Reference - -### Commands (run from `web/`) - -Use paths relative to `web/` (e.g., `app/components/...`). -Use `refactor-component` for refactoring prompts and `analyze-component` for testing prompts and metrics. - -```bash -cd web - -# Generate refactoring prompt -pnpm refactor-component - -# Output refactoring analysis as JSON -pnpm refactor-component --json - -# Generate testing prompt (after refactoring) -pnpm analyze-component - -# Output testing analysis as JSON -pnpm analyze-component --json -``` - -### Complexity Analysis - -```bash -# Analyze component complexity -pnpm analyze-component --json - -# Key metrics to check: -# - complexity: normalized score 0-100 (target < 50) -# - maxComplexity: highest single function complexity -# - lineCount: total lines (target < 300) -``` - -### Complexity Score Interpretation - -| Score | Level | Action | -|-------|-------|--------| -| 0-25 | 🟢 Simple | Ready for testing | -| 26-50 | 🟡 Medium | Consider minor refactoring | -| 51-75 | 🟠 Complex | **Refactor before testing** | -| 76-100 | 🔴 Very Complex | **Must refactor** | - -## Core Refactoring Patterns - -### Pattern 1: Extract Custom Hooks - -**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI. - -**Dify Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-.ts`. - -```typescript -// ❌ Before: Complex state logic in component -function Configuration() { - const [modelConfig, setModelConfig] = useState(...) - const [datasetConfigs, setDatasetConfigs] = useState(...) - const [completionParams, setCompletionParams] = useState({}) - - // 50+ lines of state management logic... - - return
...
-} - -// ✅ After: Extract to custom hook -// hooks/use-model-config.ts -export const useModelConfig = (appId: string) => { - const [modelConfig, setModelConfig] = useState(...) - const [completionParams, setCompletionParams] = useState({}) - - // Related state management logic here - - return { modelConfig, setModelConfig, completionParams, setCompletionParams } -} - -// Component becomes cleaner -function Configuration() { - const { modelConfig, setModelConfig } = useModelConfig(appId) - return
...
-} -``` - -**Dify Examples**: -- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts` -- `web/app/components/app/configuration/debug/hooks.tsx` -- `web/app/components/workflow/hooks/use-workflow.ts` - -### Pattern 2: Extract Sub-Components - -**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns. - -**Dify Convention**: Place sub-components in subdirectories or as separate files in the same directory. - -```typescript -// ❌ Before: Monolithic JSX with multiple sections -const AppInfo = () => { - return ( -
- {/* 100 lines of header UI */} - {/* 100 lines of operations UI */} - {/* 100 lines of modals */} -
- ) -} - -// ✅ After: Split into focused components -// app-info/ -// ├── index.tsx (orchestration only) -// ├── app-header.tsx (header UI) -// ├── app-operations.tsx (operations UI) -// └── app-modals.tsx (modal management) - -const AppInfo = () => { - const { showModal, setShowModal } = useAppInfoModals() - - return ( -
- - - setShowModal(null)} /> -
- ) -} -``` - -**Dify Examples**: -- `web/app/components/app/configuration/` directory structure -- `web/app/components/workflow/nodes/` per-node organization - -### Pattern 3: Simplify Conditional Logic - -**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains. - -```typescript -// ❌ Before: Deeply nested conditionals -const Template = useMemo(() => { - if (appDetail?.mode === AppModeEnum.CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { - // Another 15 lines... - } - // More conditions... -}, [appDetail, locale]) - -// ✅ After: Use lookup tables + early returns -const TEMPLATE_MAP = { - [AppModeEnum.CHAT]: { - [LanguagesSupported[1]]: TemplateChatZh, - [LanguagesSupported[7]]: TemplateChatJa, - default: TemplateChatEn, - }, - [AppModeEnum.ADVANCED_CHAT]: { - [LanguagesSupported[1]]: TemplateAdvancedChatZh, - // ... - }, -} - -const Template = useMemo(() => { - const modeTemplates = TEMPLATE_MAP[appDetail?.mode] - if (!modeTemplates) return null - - const TemplateComponent = modeTemplates[locale] || modeTemplates.default - return -}, [appDetail, locale]) -``` - -### Pattern 4: Extract API/Data Logic - -**When**: Component directly handles API calls, data transformation, or complex async operations. - -**Dify Convention**: -- This skill is for component decomposition, not query/mutation design. -- Do not introduce deprecated `useInvalid` / `useReset`. -- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state. - -**Dify Examples**: -- `web/service/use-workflow.ts` -- `web/service/use-common.ts` -- `web/service/knowledge/use-dataset.ts` -- `web/service/knowledge/use-document.ts` - -### Pattern 5: Extract Modal/Dialog Management - -**When**: Component manages multiple modals with complex open/close states. - -**Dify Convention**: Modals should be extracted with their state management. - -```typescript -// ❌ Before: Multiple modal states in component -const AppInfo = () => { - const [showEditModal, setShowEditModal] = useState(false) - const [showDuplicateModal, setShowDuplicateModal] = useState(false) - const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const [showSwitchModal, setShowSwitchModal] = useState(false) - const [showImportDSLModal, setShowImportDSLModal] = useState(false) - // 5+ more modal states... -} - -// ✅ After: Extract to modal management hook -type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null - -const useAppInfoModals = () => { - const [activeModal, setActiveModal] = useState(null) - - const openModal = useCallback((type: ModalType) => setActiveModal(type), []) - const closeModal = useCallback(() => setActiveModal(null), []) - - return { - activeModal, - openModal, - closeModal, - isOpen: (type: ModalType) => activeModal === type, - } -} -``` - -### Pattern 6: Extract Form Logic - -**When**: Complex form validation, submission handling, or field transformation. - -**Dify Convention**: Use `@tanstack/react-form` patterns from `web/app/components/base/form/`. - -```typescript -// ✅ Use existing form infrastructure -import { useAppForm } from '@/app/components/base/form' - -const ConfigForm = () => { - const form = useAppForm({ - defaultValues: { name: '', description: '' }, - onSubmit: handleSubmit, - }) - - return ... -} -``` - -## Dify-Specific Refactoring Guidelines - -### 1. Context Provider Extraction - -**When**: Component provides complex context values with multiple states. - -```typescript -// ❌ Before: Large context value object -const value = { - appId, isAPIKeySet, isTrailFinished, mode, modelModeType, - promptMode, isAdvancedMode, isAgent, isOpenAI, isFunctionCall, - // 50+ more properties... -} -return ... - -// ✅ After: Split into domain-specific contexts - - - - {children} - - - -``` - -**Dify Reference**: `web/context/` directory structure - -### 2. Workflow Node Components - -**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`). - -**Conventions**: -- Keep node logic in `use-interactions.ts` -- Extract panel UI to separate files -- Use `_base` components for common patterns - -``` -nodes// - ├── index.tsx # Node registration - ├── node.tsx # Node visual component - ├── panel.tsx # Configuration panel - ├── use-interactions.ts # Node-specific hooks - └── types.ts # Type definitions -``` - -### 3. Configuration Components - -**When**: Refactoring app configuration components. - -**Conventions**: -- Separate config sections into subdirectories -- Use existing patterns from `web/app/components/app/configuration/` -- Keep feature toggles in dedicated components - -### 4. Tool/Plugin Components - -**When**: Refactoring tool-related components (`web/app/components/tools/`). - -**Conventions**: -- Follow existing modal patterns -- Use service hooks from `web/service/use-tools.ts` -- Keep provider-specific logic isolated - -## Refactoring Workflow - -### Step 1: Generate Refactoring Prompt - -```bash -pnpm refactor-component -``` - -This command will: -- Analyze component complexity and features -- Identify specific refactoring actions needed -- Generate a prompt for AI assistant (auto-copied to clipboard on macOS) -- Provide detailed requirements based on detected patterns - -### Step 2: Analyze Details - -```bash -pnpm analyze-component --json -``` - -Identify: -- Total complexity score -- Max function complexity -- Line count -- Features detected (state, effects, API, etc.) - -### Step 3: Plan - -Create a refactoring plan based on detected features: - -| Detected Feature | Refactoring Action | -|------------------|-------------------| -| `hasState: true` + `hasEffects: true` | Extract custom hook | -| `hasAPI: true` | Extract data/service hook | -| `hasEvents: true` (many) | Extract event handlers | -| `lineCount > 300` | Split into sub-components | -| `maxComplexity > 50` | Simplify conditional logic | - -### Step 4: Execute Incrementally - -1. **Extract one piece at a time** -2. **Run lint, type-check, and tests after each extraction** -3. **Verify functionality before next step** - -``` -For each extraction: - ┌────────────────────────────────────────┐ - │ 1. Extract code │ - │ 2. Run: pnpm lint:fix │ - │ 3. Run: pnpm type-check │ - │ 4. Run: pnpm test │ - │ 5. Test functionality manually │ - │ 6. PASS? → Next extraction │ - │ FAIL? → Fix before continuing │ - └────────────────────────────────────────┘ -``` - -### Step 5: Verify - -After refactoring: - -```bash -# Re-run refactor command to verify improvements -pnpm refactor-component - -# If complexity < 25 and lines < 200, you'll see: -# ✅ COMPONENT IS WELL-STRUCTURED - -# For detailed metrics: -pnpm analyze-component --json - -# Target metrics: -# - complexity < 50 -# - lineCount < 300 -# - maxComplexity < 30 -``` - -## Common Mistakes to Avoid - -### ❌ Over-Engineering - -```typescript -// ❌ Too many tiny hooks -const useButtonText = () => useState('Click') -const useButtonDisabled = () => useState(false) -const useButtonLoading = () => useState(false) - -// ✅ Cohesive hook with related state -const useButtonState = () => { - const [text, setText] = useState('Click') - const [disabled, setDisabled] = useState(false) - const [loading, setLoading] = useState(false) - return { text, setText, disabled, setDisabled, loading, setLoading } -} -``` - -### ❌ Breaking Existing Patterns - -- Follow existing directory structures -- Maintain naming conventions -- Preserve export patterns for compatibility - -### ❌ Premature Abstraction - -- Only extract when there's clear complexity benefit -- Don't create abstractions for single-use code -- Keep refactored code in the same domain area - -## References - -### Dify Codebase Examples - -- **Hook extraction**: `web/app/components/app/configuration/hooks/` -- **Component splitting**: `web/app/components/app/configuration/` -- **Service hooks**: `web/service/use-*.ts` -- **Workflow patterns**: `web/app/components/workflow/hooks/` -- **Form patterns**: `web/app/components/base/form/` - -### Related Skills - -- `frontend-testing` - For testing refactored components -- `web/docs/test.md` - Testing specification diff --git a/.agents/skills/component-refactoring/references/complexity-patterns.md b/.agents/skills/component-refactoring/references/complexity-patterns.md deleted file mode 100644 index 2873630d4ba..00000000000 --- a/.agents/skills/component-refactoring/references/complexity-patterns.md +++ /dev/null @@ -1,495 +0,0 @@ -# Complexity Reduction Patterns - -This document provides patterns for reducing cognitive complexity in Dify React components. - -## Understanding Complexity - -### SonarJS Cognitive Complexity - -The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics: - -- **Total Complexity**: Sum of all functions' complexity in the file -- **Max Complexity**: Highest single function complexity - -### What Increases Complexity - -| Pattern | Complexity Impact | -|---------|-------------------| -| `if/else` | +1 per branch | -| Nested conditions | +1 per nesting level | -| `switch/case` | +1 per case | -| `for/while/do` | +1 per loop | -| `&&`/`||` chains | +1 per operator | -| Nested callbacks | +1 per nesting level | -| `try/catch` | +1 per catch | -| Ternary expressions | +1 per nesting | - -## Pattern 1: Replace Conditionals with Lookup Tables - -**Before** (complexity: ~15): - -```typescript -const Template = useMemo(() => { - if (appDetail?.mode === AppModeEnum.CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.WORKFLOW) { - // Similar pattern... - } - return null -}, [appDetail, locale]) -``` - -**After** (complexity: ~3): - -```typescript -import type { ComponentType } from 'react' - -// Define lookup table outside component -const TEMPLATE_MAP: Record>> = { - [AppModeEnum.CHAT]: { - [LanguagesSupported[1]]: TemplateChatZh, - [LanguagesSupported[7]]: TemplateChatJa, - default: TemplateChatEn, - }, - [AppModeEnum.ADVANCED_CHAT]: { - [LanguagesSupported[1]]: TemplateAdvancedChatZh, - [LanguagesSupported[7]]: TemplateAdvancedChatJa, - default: TemplateAdvancedChatEn, - }, - [AppModeEnum.WORKFLOW]: { - [LanguagesSupported[1]]: TemplateWorkflowZh, - [LanguagesSupported[7]]: TemplateWorkflowJa, - default: TemplateWorkflowEn, - }, - // ... -} - -// Clean component logic -const Template = useMemo(() => { - if (!appDetail?.mode) return null - - const templates = TEMPLATE_MAP[appDetail.mode] - if (!templates) return null - - const TemplateComponent = templates[locale] ?? templates.default - return -}, [appDetail, locale]) -``` - -## Pattern 2: Use Early Returns - -**Before** (complexity: ~10): - -```typescript -const handleSubmit = () => { - if (isValid) { - if (hasChanges) { - if (isConnected) { - submitData() - } else { - showConnectionError() - } - } else { - showNoChangesMessage() - } - } else { - showValidationError() - } -} -``` - -**After** (complexity: ~4): - -```typescript -const handleSubmit = () => { - if (!isValid) { - showValidationError() - return - } - - if (!hasChanges) { - showNoChangesMessage() - return - } - - if (!isConnected) { - showConnectionError() - return - } - - submitData() -} -``` - -## Pattern 3: Extract Complex Conditions - -**Before** (complexity: high): - -```typescript -const canPublish = (() => { - if (mode !== AppModeEnum.COMPLETION) { - if (!isAdvancedMode) - return true - - if (modelModeType === ModelModeType.completion) { - if (!hasSetBlockStatus.history || !hasSetBlockStatus.query) - return false - return true - } - return true - } - return !promptEmpty -})() -``` - -**After** (complexity: lower): - -```typescript -// Extract to named functions -const canPublishInCompletionMode = () => !promptEmpty - -const canPublishInChatMode = () => { - if (!isAdvancedMode) return true - if (modelModeType !== ModelModeType.completion) return true - return hasSetBlockStatus.history && hasSetBlockStatus.query -} - -// Clean main logic -const canPublish = mode === AppModeEnum.COMPLETION - ? canPublishInCompletionMode() - : canPublishInChatMode() -``` - -## Pattern 4: Replace Chained Ternaries - -**Before** (complexity: ~5): - -```typescript -const statusText = serverActivated - ? t('status.running') - : serverPublished - ? t('status.inactive') - : appUnpublished - ? t('status.unpublished') - : t('status.notConfigured') -``` - -**After** (complexity: ~2): - -```typescript -const getStatusText = () => { - if (serverActivated) return t('status.running') - if (serverPublished) return t('status.inactive') - if (appUnpublished) return t('status.unpublished') - return t('status.notConfigured') -} - -const statusText = getStatusText() -``` - -Or use lookup: - -```typescript -const STATUS_TEXT_MAP = { - running: 'status.running', - inactive: 'status.inactive', - unpublished: 'status.unpublished', - notConfigured: 'status.notConfigured', -} as const - -const getStatusKey = (): keyof typeof STATUS_TEXT_MAP => { - if (serverActivated) return 'running' - if (serverPublished) return 'inactive' - if (appUnpublished) return 'unpublished' - return 'notConfigured' -} - -const statusText = t(STATUS_TEXT_MAP[getStatusKey()]) -``` - -## Pattern 5: Flatten Nested Loops - -**Before** (complexity: high): - -```typescript -const processData = (items: Item[]) => { - const results: ProcessedItem[] = [] - - for (const item of items) { - if (item.isValid) { - for (const child of item.children) { - if (child.isActive) { - for (const prop of child.properties) { - if (prop.value !== null) { - results.push({ - itemId: item.id, - childId: child.id, - propValue: prop.value, - }) - } - } - } - } - } - } - - return results -} -``` - -**After** (complexity: lower): - -```typescript -// Use functional approach -const processData = (items: Item[]) => { - return items - .filter(item => item.isValid) - .flatMap(item => - item.children - .filter(child => child.isActive) - .flatMap(child => - child.properties - .filter(prop => prop.value !== null) - .map(prop => ({ - itemId: item.id, - childId: child.id, - propValue: prop.value, - })) - ) - ) -} -``` - -## Pattern 6: Extract Event Handler Logic - -**Before** (complexity: high in component): - -```typescript -const Component = () => { - const handleSelect = (data: DataSet[]) => { - if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) { - hideSelectDataSet() - return - } - - formattingChangedDispatcher() - let newDatasets = data - if (data.find(item => !item.name)) { - const newSelected = produce(data, (draft) => { - data.forEach((item, index) => { - if (!item.name) { - const newItem = dataSets.find(i => i.id === item.id) - if (newItem) - draft[index] = newItem - } - }) - }) - setDataSets(newSelected) - newDatasets = newSelected - } - else { - setDataSets(data) - } - hideSelectDataSet() - - // 40 more lines of logic... - } - - return
...
-} -``` - -**After** (complexity: lower): - -```typescript -// Extract to hook or utility -const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState) => { - const normalizeSelection = (data: DataSet[]) => { - const hasUnloadedItem = data.some(item => !item.name) - if (!hasUnloadedItem) return data - - return produce(data, (draft) => { - data.forEach((item, index) => { - if (!item.name) { - const existing = dataSets.find(i => i.id === item.id) - if (existing) draft[index] = existing - } - }) - }) - } - - const hasSelectionChanged = (newData: DataSet[]) => { - return !isEqual( - newData.map(item => item.id), - dataSets.map(item => item.id) - ) - } - - return { normalizeSelection, hasSelectionChanged } -} - -// Component becomes cleaner -const Component = () => { - const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets) - - const handleSelect = (data: DataSet[]) => { - if (!hasSelectionChanged(data)) { - hideSelectDataSet() - return - } - - formattingChangedDispatcher() - const normalized = normalizeSelection(data) - setDataSets(normalized) - hideSelectDataSet() - } - - return
...
-} -``` - -## Pattern 7: Reduce Boolean Logic Complexity - -**Before** (complexity: ~8): - -```typescript -const toggleDisabled = hasInsufficientPermissions - || appUnpublished - || missingStartNode - || triggerModeDisabled - || (isAdvancedApp && !currentWorkflow?.graph) - || (isBasicApp && !basicAppConfig.updated_at) -``` - -**After** (complexity: ~3): - -```typescript -// Extract meaningful boolean functions -const isAppReady = () => { - if (isAdvancedApp) return !!currentWorkflow?.graph - return !!basicAppConfig.updated_at -} - -const hasRequiredPermissions = () => { - return isCurrentWorkspaceEditor && !hasInsufficientPermissions -} - -const canToggle = () => { - if (!hasRequiredPermissions()) return false - if (!isAppReady()) return false - if (missingStartNode) return false - if (triggerModeDisabled) return false - return true -} - -const toggleDisabled = !canToggle() -``` - -## Pattern 8: Simplify useMemo/useCallback Dependencies - -**Before** (complexity: multiple recalculations): - -```typescript -const payload = useMemo(() => { - let parameters: Parameter[] = [] - let outputParameters: OutputParameter[] = [] - - if (!published) { - parameters = (inputs || []).map((item) => ({ - name: item.variable, - description: '', - form: 'llm', - required: item.required, - type: item.type, - })) - outputParameters = (outputs || []).map((item) => ({ - name: item.variable, - description: '', - type: item.value_type, - })) - } - else if (detail && detail.tool) { - parameters = (inputs || []).map((item) => ({ - // Complex transformation... - })) - outputParameters = (outputs || []).map((item) => ({ - // Complex transformation... - })) - } - - return { - icon: detail?.icon || icon, - label: detail?.label || name, - // ...more fields - } -}, [detail, published, workflowAppId, icon, name, description, inputs, outputs]) -``` - -**After** (complexity: separated concerns): - -```typescript -// Separate transformations -const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => { - return useMemo(() => { - if (!published) { - return inputs.map(item => ({ - name: item.variable, - description: '', - form: 'llm', - required: item.required, - type: item.type, - })) - } - - if (!detail?.tool) return [] - - return inputs.map(item => ({ - name: item.variable, - required: item.required, - type: item.type === 'paragraph' ? 'string' : item.type, - description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '', - form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm', - })) - }, [inputs, detail, published]) -} - -// Component uses hook -const parameters = useParameterTransform(inputs, detail, published) -const outputParameters = useOutputTransform(outputs, detail, published) - -const payload = useMemo(() => ({ - icon: detail?.icon || icon, - label: detail?.label || name, - parameters, - outputParameters, - // ... -}), [detail, icon, name, parameters, outputParameters]) -``` - -## Target Metrics After Refactoring - -| Metric | Target | -|--------|--------| -| Total Complexity | < 50 | -| Max Function Complexity | < 30 | -| Function Length | < 30 lines | -| Nesting Depth | ≤ 3 levels | -| Conditional Chains | ≤ 3 conditions | diff --git a/.agents/skills/component-refactoring/references/component-splitting.md b/.agents/skills/component-refactoring/references/component-splitting.md deleted file mode 100644 index 81c007e0050..00000000000 --- a/.agents/skills/component-refactoring/references/component-splitting.md +++ /dev/null @@ -1,477 +0,0 @@ -# Component Splitting Patterns - -This document provides detailed guidance on splitting large components into smaller, focused components in Dify. - -## When to Split Components - -Split a component when you identify: - -1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently -1. **Conditional rendering blocks** - Large `{condition && }` blocks -1. **Repeated patterns** - Similar UI structures used multiple times -1. **300+ lines** - Component exceeds manageable size -1. **Modal clusters** - Multiple modals rendered in one component - -## Splitting Strategies - -### Strategy 1: Section-Based Splitting - -Identify visual sections and extract each as a component. - -```typescript -// ❌ Before: Monolithic component (500+ lines) -const ConfigurationPage = () => { - return ( -
- {/* Header Section - 50 lines */} -
-

{t('configuration.title')}

-
- {isAdvancedMode && Advanced} - - -
-
- - {/* Config Section - 200 lines */} -
- -
- - {/* Debug Section - 150 lines */} -
- -
- - {/* Modals Section - 100 lines */} - {showSelectDataSet && } - {showHistoryModal && } - {showUseGPT4Confirm && } -
- ) -} - -// ✅ After: Split into focused components -// configuration/ -// ├── index.tsx (orchestration) -// ├── configuration-header.tsx -// ├── configuration-content.tsx -// ├── configuration-debug.tsx -// └── configuration-modals.tsx - -// configuration-header.tsx -interface ConfigurationHeaderProps { - isAdvancedMode: boolean - onPublish: () => void -} - -function ConfigurationHeader({ - isAdvancedMode, - onPublish, -}: ConfigurationHeaderProps) { - const { t } = useTranslation() - - return ( -
-

{t('configuration.title')}

-
- {isAdvancedMode && Advanced} - - -
-
- ) -} - -// index.tsx (orchestration only) -const ConfigurationPage = () => { - const { modelConfig, setModelConfig } = useModelConfig() - const { activeModal, openModal, closeModal } = useModalState() - - return ( -
- - - {!isMobile && ( - - )} - -
- ) -} -``` - -### Strategy 2: Conditional Block Extraction - -Extract large conditional rendering blocks. - -```typescript -// ❌ Before: Large conditional blocks -const AppInfo = () => { - return ( -
- {expand ? ( -
- {/* 100 lines of expanded view */} -
- ) : ( -
- {/* 50 lines of collapsed view */} -
- )} -
- ) -} - -// ✅ After: Separate view components -function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) { - return ( -
- {/* Clean, focused expanded view */} -
- ) -} - -function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) { - return ( -
- {/* Clean, focused collapsed view */} -
- ) -} - -const AppInfo = () => { - return ( -
- {expand - ? - : - } -
- ) -} -``` - -### Strategy 3: Modal Extraction - -Extract modals with their trigger logic. - -```typescript -// ❌ Before: Multiple modals in one component -const AppInfo = () => { - const [showEdit, setShowEdit] = useState(false) - const [showDuplicate, setShowDuplicate] = useState(false) - const [showDelete, setShowDelete] = useState(false) - const [showSwitch, setShowSwitch] = useState(false) - - const onEdit = async (data) => { /* 20 lines */ } - const onDuplicate = async (data) => { /* 20 lines */ } - const onDelete = async () => { /* 15 lines */ } - - return ( -
- {/* Main content */} - - {showEdit && setShowEdit(false)} />} - {showDuplicate && setShowDuplicate(false)} />} - {showDelete && setShowDelete(false)} />} - {showSwitch && } -
- ) -} - -// ✅ After: Modal manager component -// app-info-modals.tsx -type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | null - -interface AppInfoModalsProps { - appDetail: AppDetail - activeModal: ModalType - onClose: () => void - onSuccess: () => void -} - -function AppInfoModals({ - appDetail, - activeModal, - onClose, - onSuccess, -}: AppInfoModalsProps) { - const handleEdit = async (data) => { /* logic */ } - const handleDuplicate = async (data) => { /* logic */ } - const handleDelete = async () => { /* logic */ } - - return ( - <> - {activeModal === 'edit' && ( - - )} - {activeModal === 'duplicate' && ( - - )} - {activeModal === 'delete' && ( - - )} - {activeModal === 'switch' && ( - - )} - - ) -} - -// Parent component -const AppInfo = () => { - const { activeModal, openModal, closeModal } = useModalState() - - return ( -
- {/* Main content with openModal triggers */} - - - -
- ) -} -``` - -### Strategy 4: List Item Extraction - -Extract repeated item rendering. - -```typescript -// ❌ Before: Inline item rendering -const OperationsList = () => { - return ( -
- {operations.map(op => ( -
- {op.icon} - {op.title} - {op.description} - - {op.badge && {op.badge}} - {/* More complex rendering... */} -
- ))} -
- ) -} - -// ✅ After: Extracted item component -interface OperationItemProps { - operation: Operation - onAction: (id: string) => void -} - -function OperationItem({ operation, onAction }: OperationItemProps) { - return ( -
- {operation.icon} - {operation.title} - {operation.description} - - {operation.badge && {operation.badge}} -
- ) -} - -const OperationsList = () => { - const handleAction = useCallback((id: string) => { - const op = operations.find(o => o.id === id) - op?.onClick() - }, [operations]) - - return ( -
- {operations.map(op => ( - - ))} -
- ) -} -``` - -## Directory Structure Patterns - -### Pattern A: Flat Structure (Simple Components) - -For components with 2-3 sub-components: - -``` -component-name/ - ├── index.tsx # Main component - ├── sub-component-a.tsx - ├── sub-component-b.tsx - └── types.ts # Shared types -``` - -### Pattern B: Nested Structure (Complex Components) - -For components with many sub-components: - -``` -component-name/ - ├── index.tsx # Main orchestration - ├── types.ts # Shared types - ├── hooks/ - │ ├── use-feature-a.ts - │ └── use-feature-b.ts - ├── components/ - │ ├── header/ - │ │ └── index.tsx - │ ├── content/ - │ │ └── index.tsx - │ └── modals/ - │ └── index.tsx - └── utils/ - └── helpers.ts -``` - -### Pattern C: Feature-Based Structure (Dify Standard) - -Following Dify's existing patterns: - -``` -configuration/ - ├── index.tsx # Main page component - ├── base/ # Base/shared components - │ ├── feature-panel/ - │ ├── group-name/ - │ └── operation-btn/ - ├── config/ # Config section - │ ├── index.tsx - │ ├── agent/ - │ └── automatic/ - ├── dataset-config/ # Dataset section - │ ├── index.tsx - │ ├── card-item/ - │ └── params-config/ - ├── debug/ # Debug section - │ ├── index.tsx - │ └── hooks.tsx - └── hooks/ # Shared hooks - └── use-advanced-prompt-config.ts -``` - -## Props Design - -### Minimal Props Principle - -Pass only what's needed: - -```typescript -// ❌ Bad: Passing entire objects when only some fields needed - - -// ✅ Good: Destructure to minimum required - -``` - -### Callback Props Pattern - -Use callbacks for child-to-parent communication: - -```typescript -// Parent -const Parent = () => { - const [value, setValue] = useState('') - - return ( - - ) -} - -// Child -interface ChildProps { - value: string - onChange: (value: string) => void - onSubmit: () => void -} - -function Child({ value, onChange, onSubmit }: ChildProps) { - return ( -
- onChange(e.target.value)} /> - -
- ) -} -``` - -### Render Props for Flexibility - -When sub-components need parent context: - -```typescript -interface ListProps { - items: T[] - renderItem: (item: T, index: number) => React.ReactNode - renderEmpty?: () => React.ReactNode -} - -function List({ items, renderItem, renderEmpty }: ListProps) { - if (items.length === 0 && renderEmpty) { - return <>{renderEmpty()} - } - - return ( -
- {items.map((item, index) => renderItem(item, index))} -
- ) -} - -// Usage - } - renderEmpty={() => } -/> -``` diff --git a/.agents/skills/component-refactoring/references/hook-extraction.md b/.agents/skills/component-refactoring/references/hook-extraction.md deleted file mode 100644 index 6fad2c8885e..00000000000 --- a/.agents/skills/component-refactoring/references/hook-extraction.md +++ /dev/null @@ -1,281 +0,0 @@ -# Hook Extraction Patterns - -This document provides detailed guidance on extracting custom hooks from complex components in Dify. - -## When to Extract Hooks - -Extract a custom hook when you identify: - -1. **Coupled state groups** - Multiple `useState` hooks that are always used together -1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic -1. **Business logic** - Data transformations, validations, or calculations -1. **Reusable patterns** - Logic that appears in multiple components - -## Extraction Process - -### Step 1: Identify State Groups - -Look for state variables that are logically related: - -```typescript -// ❌ These belong together - extract to hook -const [modelConfig, setModelConfig] = useState(...) -const [completionParams, setCompletionParams] = useState({}) -const [modelModeType, setModelModeType] = useState(...) - -// These are model-related state that should be in useModelConfig() -``` - -### Step 2: Identify Related Effects - -Find effects that modify the grouped state: - -```typescript -// ❌ These effects belong with the state above -useEffect(() => { - if (hasFetchedDetail && !modelModeType) { - const mode = currModel?.model_properties.mode - if (mode) { - const newModelConfig = produce(modelConfig, (draft) => { - draft.mode = mode - }) - setModelConfig(newModelConfig) - } - } -}, [textGenerationModelList, hasFetchedDetail, modelModeType, currModel]) -``` - -### Step 3: Create the Hook - -```typescript -// hooks/use-model-config.ts -import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { ModelConfig } from '@/models/debug' -import { produce } from 'immer' -import { useEffect, useState } from 'react' -import { ModelModeType } from '@/types/app' - -interface UseModelConfigParams { - initialConfig?: Partial - currModel?: { model_properties?: { mode?: ModelModeType } } - hasFetchedDetail: boolean -} - -interface UseModelConfigReturn { - modelConfig: ModelConfig - setModelConfig: (config: ModelConfig) => void - completionParams: FormValue - setCompletionParams: (params: FormValue) => void - modelModeType: ModelModeType -} - -export const useModelConfig = ({ - initialConfig, - currModel, - hasFetchedDetail, -}: UseModelConfigParams): UseModelConfigReturn => { - const [modelConfig, setModelConfig] = useState({ - provider: 'langgenius/openai/openai', - model_id: 'gpt-3.5-turbo', - mode: ModelModeType.unset, - // ... default values - ...initialConfig, - }) - - const [completionParams, setCompletionParams] = useState({}) - - const modelModeType = modelConfig.mode - - // Fill old app data missing model mode - useEffect(() => { - if (hasFetchedDetail && !modelModeType) { - const mode = currModel?.model_properties?.mode - if (mode) { - setModelConfig(produce(modelConfig, (draft) => { - draft.mode = mode - })) - } - } - }, [hasFetchedDetail, modelModeType, currModel]) - - return { - modelConfig, - setModelConfig, - completionParams, - setCompletionParams, - modelModeType, - } -} -``` - -### Step 4: Update Component - -```typescript -// Before: 50+ lines of state management -function Configuration() { - const [modelConfig, setModelConfig] = useState(...) - // ... lots of related state and effects -} - -// After: Clean component -function Configuration() { - const { - modelConfig, - setModelConfig, - completionParams, - setCompletionParams, - modelModeType, - } = useModelConfig({ - currModel, - hasFetchedDetail, - }) - - // Component now focuses on UI -} -``` - -## Naming Conventions - -### Hook Names - -- Use `use` prefix: `useModelConfig`, `useDatasetConfig` -- Be specific: `useAdvancedPromptConfig` not `usePrompt` -- Include domain: `useWorkflowVariables`, `useMCPServer` - -### File Names - -- Kebab-case: `use-model-config.ts` -- Place in `hooks/` subdirectory when multiple hooks exist -- Place alongside component for single-use hooks - -### Return Type Names - -- Suffix with `Return`: `UseModelConfigReturn` -- Suffix params with `Params`: `UseModelConfigParams` - -## Common Hook Patterns in Dify - -### 1. Data Fetching / Mutation Hooks - -When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns. - -- Do not introduce deprecated `useInvalid` / `useReset`. -- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks. - -### 2. Form State Hook - -```typescript -// Pattern: Form state + validation + submission -export const useConfigForm = (initialValues: ConfigFormValues) => { - const [values, setValues] = useState(initialValues) - const [errors, setErrors] = useState>({}) - const [isSubmitting, setIsSubmitting] = useState(false) - - const validate = useCallback(() => { - const newErrors: Record = {} - if (!values.name) newErrors.name = 'Name is required' - setErrors(newErrors) - return Object.keys(newErrors).length === 0 - }, [values]) - - const handleChange = useCallback((field: string, value: any) => { - setValues(prev => ({ ...prev, [field]: value })) - }, []) - - const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise) => { - if (!validate()) return - setIsSubmitting(true) - try { - await onSubmit(values) - } finally { - setIsSubmitting(false) - } - }, [values, validate]) - - return { values, errors, isSubmitting, handleChange, handleSubmit } -} -``` - -### 3. Modal State Hook - -```typescript -// Pattern: Multiple modal management -type ModalType = 'edit' | 'delete' | 'duplicate' | null - -export const useModalState = () => { - const [activeModal, setActiveModal] = useState(null) - const [modalData, setModalData] = useState(null) - - const openModal = useCallback((type: ModalType, data?: any) => { - setActiveModal(type) - setModalData(data) - }, []) - - const closeModal = useCallback(() => { - setActiveModal(null) - setModalData(null) - }, []) - - return { - activeModal, - modalData, - openModal, - closeModal, - isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]), - } -} -``` - -### 4. Toggle/Boolean Hook - -```typescript -// Pattern: Boolean state with convenience methods -export const useToggle = (initialValue = false) => { - const [value, setValue] = useState(initialValue) - - const toggle = useCallback(() => setValue(v => !v), []) - const setTrue = useCallback(() => setValue(true), []) - const setFalse = useCallback(() => setValue(false), []) - - return [value, { toggle, setTrue, setFalse, set: setValue }] as const -} - -// Usage -const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle() -``` - -## Testing Extracted Hooks - -After extraction, test hooks in isolation: - -```typescript -// use-model-config.spec.ts -import { renderHook, act } from '@testing-library/react' -import { useModelConfig } from './use-model-config' - -describe('useModelConfig', () => { - it('should initialize with default values', () => { - const { result } = renderHook(() => useModelConfig({ - hasFetchedDetail: false, - })) - - expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai') - expect(result.current.modelModeType).toBe(ModelModeType.unset) - }) - - it('should update model config', () => { - const { result } = renderHook(() => useModelConfig({ - hasFetchedDetail: true, - })) - - act(() => { - result.current.setModelConfig({ - ...result.current.modelConfig, - model_id: 'gpt-4', - }) - }) - - expect(result.current.modelConfig.model_id).toBe('gpt-4') - }) -}) -``` diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 55ad08941c4..738ec9de95a 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -1,6 +1,6 @@ --- name: how-to-write-component -description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling. +description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling. --- # How To Write A Component @@ -12,26 +12,79 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit. - Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them. - Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature. +- Prefer local code and purpose-named helpers over catch-all utility modules; do not group workflow-specific defaults, validation, payload shaping, or metadata merging in a generic utils file just because they share a DTO. +- Keep source/default selection, validation, and payload shaping close to the workflow that owns the behavior. Do not extract a shared helper just because two flows read the same DTO when their priority order, fallback behavior, or submit semantics differ. +- Prefer direct, readable conditionals at the use site for small branch-specific decisions, especially form source selection and request payload assembly. Extract only when the helper name captures a stable domain rule and removes repeated complexity without hiding flow-specific behavior. +- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together. - Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance. +## Feature Workflow Layout + +- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI. +- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions. +- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region. +- Folders represent groups of related files with a shared owner and a stable reason to change together. +- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching. + ## Ownership - Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home. - Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing. - Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children. +- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state. +- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon. - Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow. +- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth. +- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need. - Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action. - Prefer uncontrolled DOM state and CSS variables before adding controlled props. +## Feature-Scoped Jotai State + +- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration. +- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports. +- Derived atom names read as business facts. Write atom names read as user or workflow commands. +- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms. +- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract. +- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow. +- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface. +- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient. + ## Components, Props, And Types - Type component signatures directly; do not use `FC` or `React.FC`. - Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs. - Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files. - Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer. -- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them. -- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary. -- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks. +- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them. +- Do not create type aliases that only rename another type. Use an alias only when it encodes a real UI concept, refinement, or reusable local contract. +- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary. +- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states. +- Do not extract fallback helpers whose only behavior is hiding missing display data. The component that renders the surface owns the empty, disabled, hidden, or placeholder state. + +## Generated API Contracts + +- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`. +- Do not hand-write local request/response/reply/page/cache-data types that mirror generated DTOs. Import or infer the generated type. +- Do not widen generated fields or enums for compatibility. Normalize legacy input at the boundary, then return the generated field type. +- Do not repair generated or API-returned contract fields in components unless the API contract or product requirement says they need normalization. Treat enums, statuses, and presence flags as exact contract values. +- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum. +- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful. +- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string. +- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors. +- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data. +- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers. + +## Nullable API Data + +- Prefer nullable-tolerant call boundaries. Pass API-returned types through for render-only rows, and let the component render fallback, disabled state, or nothing. +- Narrow only where a real value is required, such as mutation params, route hrefs, select values, or query input. Build that target model with `flatMap`, a local loop, or an early return so the required value is captured in the same branch. +- If design says a field is the display value, use that field. Only the final component should decide whether a nullable display value renders a placeholder, hides content, or disables an action. +- Do not wrap required arrays or fields in null-fallback helpers. Use empty collection fallbacks only for not-yet-loaded query data or genuinely nullable collections at the owning render boundary. +- Do not drop rows only to satisfy props or React keys; use a stable fallback key when possible. +- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by a narrowing filter. +- Avoid truthiness type guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after those filters. +- Use type guards only for meaningful domain or runtime validation, such as enum membership, object shape, or a reusable business invariant. ## Queries And Mutations @@ -39,7 +92,8 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`. - Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it. - Keep feature hooks for real orchestration, workflow state, or shared domain behavior. -- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid. +- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`. +- For generated oRPC `queryOptions()` / `infiniteOptions()`, do not pass `skipToken` as `input`; keep a valid placeholder input shape and use `enabled` to gate missing required params because the OpenAPI codec encodes input eagerly. - Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows. - Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules. - Do not use deprecated `useInvalid` or `useReset`. @@ -48,12 +102,13 @@ Use this as the decision guide for React/TypeScript component structure. Existin ## Component Boundaries - Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner. +- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner. - Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer. - Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary. - Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow. - Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment. - Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible. -- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. +- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface. ## You Might Not Need An Effect @@ -68,4 +123,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin ## Navigation And Performance - Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission. +- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched. +- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree. - Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason. diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index c0e079eeb2c..20fd651b759 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -16,6 +16,7 @@ api = ExternalApi( inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/") from . import mail as _mail +from . import runtime_credentials as _runtime_credentials from .app import dsl as _app_dsl from .knowledge import retrieval as _knowledge_retrieval from .plugin import agent_drive as _agent_drive @@ -30,6 +31,7 @@ __all__ = [ "_knowledge_retrieval", "_mail", "_plugin", + "_runtime_credentials", "_workspace", "api", "bp", diff --git a/api/controllers/inner_api/runtime_credentials.py b/api/controllers/inner_api/runtime_credentials.py new file mode 100644 index 00000000000..bea65230d73 --- /dev/null +++ b/api/controllers/inner_api/runtime_credentials.py @@ -0,0 +1,205 @@ +"""Inner API endpoints for runtime credential resolution. + +Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint +returns decrypted model and tool credentials for in-memory runtime use only. +""" + +import json +import logging +from json import JSONDecodeError +from typing import Any + +from flask_restx import Resource +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from controllers.common.schema import register_schema_model +from controllers.console.wraps import setup_required +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import enterprise_inner_api_only +from core.helper import encrypter +from core.helper.provider_cache import ToolProviderCredentialsCache +from core.helper.provider_encryption import create_provider_encrypter +from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager +from core.tools.tool_manager import ToolManager +from extensions.ext_database import db +from models.provider import ProviderCredential +from models.tools import BuiltinToolProvider + +logger = logging.getLogger(__name__) + +_KIND_MODEL = "model" +_KIND_TOOL = "tool" + +# (body, status) pair returned by a resolver helper when resolution fails. +ResolveError = tuple[dict[str, str], int] + + +class InnerRuntimeCredentialResolveItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + credential_id: str = Field(description="Credential id") + provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai") + kind: str = Field(description="Credential kind, either 'model' or 'tool'") + + +class InnerRuntimeCredentialsResolvePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + tenant_id: str = Field(description="Workspace id") + credentials: list[InnerRuntimeCredentialResolveItem] = Field(default_factory=list) + + +register_schema_model(inner_api_ns, InnerRuntimeCredentialsResolvePayload) + + +@inner_api_ns.route("/enterprise/credentials/resolve") +class EnterpriseRuntimeCredentialsResolve(Resource): + @setup_required + @enterprise_inner_api_only + @inner_api_ns.doc( + "enterprise_runtime_credentials_resolve", + responses={ + 200: "Credentials resolved", + 400: "Invalid request or credential config", + 404: "Provider or credential not found", + }, + ) + @inner_api_ns.expect(inner_api_ns.models[InnerRuntimeCredentialsResolvePayload.__name__]) + def post(self): + args = InnerRuntimeCredentialsResolvePayload.model_validate(inner_api_ns.payload or {}) + if not args.credentials: + return {"credentials": []}, 200 + + # Model resolution shares one provider configuration set; build it lazily + # so a tool-only request never pays for the plugin daemon round trip. + model_configurations = None + + resolved: list[dict[str, Any]] = [] + for item in args.credentials: + if item.kind == _KIND_MODEL: + if model_configurations is None: + provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id) + model_configurations = provider_manager.get_configurations(args.tenant_id) + values, error = _resolve_model(args.tenant_id, model_configurations, item) + elif item.kind == _KIND_TOOL: + values, error = _resolve_tool(args.tenant_id, item) + else: + return {"message": f"unsupported credential kind '{item.kind}'"}, 400 + + if error is not None: + return error + resolved.append( + { + "credential_id": item.credential_id, + "kind": item.kind, + "provider": item.provider, + "values": values, + } + ) + + return {"credentials": resolved}, 200 + + +def _resolve_model( + tenant_id: str, provider_configurations: Any, item: InnerRuntimeCredentialResolveItem +) -> tuple[dict[str, Any] | None, ResolveError | None]: + provider_configuration = provider_configurations.get(item.provider) + if provider_configuration is None: + return None, ({"message": f"provider '{item.provider}' not found"}, 404) + + provider_schema = provider_configuration.provider.provider_credential_schema + secret_variables = provider_configuration.extract_secret_variables( + provider_schema.credential_form_schemas if provider_schema else [] + ) + + with Session(db.engine) as session: + stmt = select(ProviderCredential).where( + ProviderCredential.id == item.credential_id, + ProviderCredential.tenant_id == tenant_id, + ProviderCredential.provider_name.in_(provider_configuration._get_provider_names()), + ) + credential = session.execute(stmt).scalar_one_or_none() + + if credential is None or not credential.encrypted_config: + return None, ({"message": f"credential '{item.credential_id}' not found"}, 404) + + try: + values = json.loads(credential.encrypted_config) + except JSONDecodeError: + return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400) + if not isinstance(values, dict): + return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400) + + for key in secret_variables: + value = values.get(key) + if value is None: + continue + try: + values[key] = encrypter.decrypt_token(tenant_id=tenant_id, token=value) + except Exception as exc: + logger.warning( + "failed to resolve runtime model credential", + extra={ + "credential_id": item.credential_id, + "provider": item.provider, + "tenant_id": tenant_id, + "error": type(exc).__name__, + }, + ) + return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400) + + return values, None + + +def _resolve_tool( + tenant_id: str, item: InnerRuntimeCredentialResolveItem +) -> tuple[dict[str, Any] | None, ResolveError | None]: + try: + provider_controller = ToolManager.get_builtin_provider(item.provider, tenant_id) + except Exception as exc: + logger.warning( + "failed to load runtime tool provider", + extra={"provider": item.provider, "tenant_id": tenant_id, "error": type(exc).__name__}, + ) + return None, ({"message": f"tool provider '{item.provider}' not found"}, 404) + + with Session(db.engine) as session: + stmt = select(BuiltinToolProvider).where( + BuiltinToolProvider.id == item.credential_id, + BuiltinToolProvider.provider == item.provider, + BuiltinToolProvider.tenant_id == tenant_id, + ) + builtin_provider = session.execute(stmt).scalar_one_or_none() + + if builtin_provider is None: + return None, ({"message": f"credential '{item.credential_id}' not found"}, 404) + + try: + # Tool credentials are stored as a single encrypted dict; the secret + # fields are decided by the schema bound to this credential type. + provider_encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=[ + schema.to_basic_provider_config() + for schema in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type) + ], + cache=ToolProviderCredentialsCache( + tenant_id=tenant_id, provider=item.provider, credential_id=builtin_provider.id + ), + ) + values = dict(provider_encrypter.decrypt(builtin_provider.credentials)) + except Exception as exc: + logger.warning( + "failed to resolve runtime tool credential", + extra={ + "credential_id": item.credential_id, + "provider": item.provider, + "tenant_id": tenant_id, + "error": type(exc).__name__, + }, + ) + return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400) + + return values, None diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 3e21ed4fe06..b5dd329c80c 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -18454,6 +18454,7 @@ Model class for provider system configuration response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | branding | [BrandingModel](#brandingmodel) | | Yes | +| enable_app_deploy | boolean | | Yes | | enable_change_email | boolean,
**Default:** true | | Yes | | enable_collaboration_mode | boolean,
**Default:** true | | Yes | | enable_creators_platform | boolean | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 302c2a55e43..ddbb6f51c79 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1597,6 +1597,7 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | branding | [BrandingModel](#brandingmodel) | | Yes | +| enable_app_deploy | boolean | | Yes | | enable_change_email | boolean,
**Default:** true | | Yes | | enable_collaboration_mode | boolean,
**Default:** true | | Yes | | enable_creators_platform | boolean | | Yes | diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 4d460c288ac..10a15a0492b 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -160,6 +160,7 @@ class PluginManagerModel(FeatureResponseModel): class SystemFeatureModel(FeatureResponseModel): + enable_app_deploy: bool = False sso_enforced_for_signin: bool = False sso_enforced_for_signin_protocol: str = "" enable_marketplace: bool = False @@ -419,6 +420,9 @@ class FeatureService: if "IsAllowCreateWorkspace" in enterprise_info: features.is_allow_create_workspace = enterprise_info["IsAllowCreateWorkspace"] + if "EnableAppDeploy" in enterprise_info: + features.enable_app_deploy = enterprise_info["EnableAppDeploy"] + if "Branding" in enterprise_info: features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "") features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "") diff --git a/api/tests/test_containers_integration_tests/services/test_feature_service.py b/api/tests/test_containers_integration_tests/services/test_feature_service.py index a678e37b41d..4e5bfc6ac1d 100644 --- a/api/tests/test_containers_integration_tests/services/test_feature_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feature_service.py @@ -61,6 +61,7 @@ class TestFeatureService: }, "WebAppAuth": {"allowSso": True, "allowEmailCodeLogin": True, "allowEmailPasswordLogin": False}, "SSOEnforcedForWebProtocol": "oidc", + "EnableAppDeploy": True, "License": { "status": "active", "expiredAt": "2025-12-31", @@ -291,6 +292,7 @@ class TestFeatureService: assert isinstance(result, SystemFeatureModel) # Verify enterprise features + assert result.enable_app_deploy is True assert result.branding.enabled is True assert result.webapp_auth.enabled is True assert result.enable_change_email is False @@ -377,6 +379,7 @@ class TestFeatureService: # Ensure that data required for frontend rendering remains accessible. # Branding should match the mock data + assert result.enable_app_deploy is True assert result.branding.enabled is True assert result.branding.application_title == "Test Enterprise" assert result.branding.login_page_logo == "https://example.com/logo.png" @@ -424,6 +427,7 @@ class TestFeatureService: assert isinstance(result, SystemFeatureModel) # Verify basic configuration + assert result.enable_app_deploy is False assert result.branding.enabled is False assert result.webapp_auth.enabled is False assert result.enable_change_email is True @@ -625,6 +629,7 @@ class TestFeatureService: assert isinstance(result, SystemFeatureModel) # Verify enterprise features are disabled + assert result.enable_app_deploy is False assert result.branding.enabled is False assert result.webapp_auth.enabled is False assert result.enable_change_email is True diff --git a/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py b/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py new file mode 100644 index 00000000000..87511a32b8c --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py @@ -0,0 +1,208 @@ +"""Unit tests for runtime credential inner API.""" + +import inspect +from unittest.mock import MagicMock, patch + +from flask import Flask + +from controllers.inner_api.runtime_credentials import ( + EnterpriseRuntimeCredentialsResolve, + InnerRuntimeCredentialsResolvePayload, +) + + +def test_runtime_credentials_payload_accepts_items(): + payload = InnerRuntimeCredentialsResolvePayload.model_validate( + { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/openai/openai", + "kind": "model", + } + ], + } + ) + + assert payload.tenant_id == "tenant-1" + assert payload.credentials[0].provider == "langgenius/openai/openai" + assert payload.credentials[0].kind == "model" + + +@patch("controllers.inner_api.runtime_credentials.encrypter.decrypt_token") +@patch("controllers.inner_api.runtime_credentials.db") +@patch("controllers.inner_api.runtime_credentials.Session") +@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager") +def test_runtime_model_credentials_resolve_returns_decrypted_values( + mock_provider_manager_factory, + mock_session_cls, + mock_db, + mock_decrypt_token, + app: Flask, +): + provider_configuration = MagicMock() + provider_configuration.provider.provider_credential_schema.credential_form_schemas = [] + provider_configuration.extract_secret_variables.return_value = ["openai_api_key"] + provider_configuration._get_provider_names.return_value = ["langgenius/openai/openai", "openai"] + + provider_configurations = MagicMock() + provider_configurations.get.return_value = provider_configuration + provider_manager = MagicMock() + provider_manager.get_configurations.return_value = provider_configurations + mock_provider_manager_factory.return_value = provider_manager + + credential = MagicMock() + credential.encrypted_config = '{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}' + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.scalar_one_or_none.return_value = credential + mock_session_cls.return_value = session + mock_db.engine = MagicMock() + mock_decrypt_token.return_value = "sk-test" + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/openai/openai", + "kind": "model", + } + ], + } + body, status_code = unwrapped(handler) + + assert status_code == 200 + assert body["credentials"][0]["kind"] == "model" + assert body["credentials"][0]["values"]["openai_api_key"] == "sk-test" + assert body["credentials"][0]["values"]["api_base"] == "https://api.openai.com/v1" + mock_decrypt_token.assert_called_once_with(tenant_id="tenant-1", token="encrypted") + + +@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager") +def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provider_manager_factory, app: Flask): + provider_configurations = MagicMock() + provider_configurations.get.return_value = None + provider_manager = MagicMock() + provider_manager.get_configurations.return_value = provider_configurations + mock_provider_manager_factory.return_value = provider_manager + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "credential-1", "provider": "missing", "kind": "model"}], + } + body, status_code = unwrapped(handler) + + assert status_code == 404 + assert "provider" in body["message"] + + +@patch("controllers.inner_api.runtime_credentials.create_provider_encrypter") +@patch("controllers.inner_api.runtime_credentials.ToolProviderCredentialsCache") +@patch("controllers.inner_api.runtime_credentials.db") +@patch("controllers.inner_api.runtime_credentials.Session") +@patch("controllers.inner_api.runtime_credentials.ToolManager") +def test_runtime_tool_credentials_resolve_returns_decrypted_values( + mock_tool_manager, + mock_session_cls, + mock_db, + mock_cache_cls, + mock_create_encrypter, + app: Flask, +): + provider_controller = MagicMock() + provider_controller.get_credentials_schema_by_type.return_value = [] + mock_tool_manager.get_builtin_provider.return_value = provider_controller + + builtin_provider = MagicMock() + builtin_provider.id = "credential-1" + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.scalar_one_or_none.return_value = builtin_provider + mock_session_cls.return_value = session + mock_db.engine = MagicMock() + + provider_encrypter = MagicMock() + provider_encrypter.decrypt.return_value = {"tavily_api_key": "tvly-secret"} + mock_create_encrypter.return_value = (provider_encrypter, MagicMock()) + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/tavily/tavily", + "kind": "tool", + } + ], + } + body, status_code = unwrapped(handler) + + assert status_code == 200 + assert body["credentials"][0]["kind"] == "tool" + assert body["credentials"][0]["provider"] == "langgenius/tavily/tavily" + assert body["credentials"][0]["values"]["tavily_api_key"] == "tvly-secret" + compiled = str(session.execute.call_args.args[0].compile(compile_kwargs={"literal_binds": True})) + assert "tool_builtin_providers.provider = 'langgenius/tavily/tavily'" in compiled + + +@patch("controllers.inner_api.runtime_credentials.db") +@patch("controllers.inner_api.runtime_credentials.Session") +@patch("controllers.inner_api.runtime_credentials.ToolManager") +def test_runtime_tool_credentials_resolve_rejects_unknown_credential( + mock_tool_manager, + mock_session_cls, + mock_db, + app: Flask, +): + mock_tool_manager.get_builtin_provider.return_value = MagicMock() + + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.scalar_one_or_none.return_value = None + mock_session_cls.return_value = session + mock_db.engine = MagicMock() + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "missing", "provider": "langgenius/tavily/tavily", "kind": "tool"}], + } + body, status_code = unwrapped(handler) + + assert status_code == 404 + assert "credential" in body["message"] + + +def test_runtime_credentials_resolve_rejects_unknown_kind(app: Flask): + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "credential-1", "provider": "x", "kind": "secret"}], + } + body, status_code = unwrapped(handler) + + assert status_code == 400 + assert "kind" in body["message"] diff --git a/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py new file mode 100644 index 00000000000..1c3b4fdbc1f --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py @@ -0,0 +1,37 @@ +import pytest + +from services import feature_service as feature_service_module +from services.feature_service import FeatureService, SystemFeatureModel + + +@pytest.mark.parametrize( + ("enterprise_info", "initial", "expected"), + [ + # Enterprise reports the feature on -> mirrored through. + ({"EnableAppDeploy": True}, False, True), + # Enterprise may turn it off; the read runs after the hardcoded default + # and overrides it (forward-compat with a future entitlement gate). + ({"EnableAppDeploy": False}, True, False), + # Old enterprise without the key -> the existing value is left untouched. + ({}, True, True), + ], + ids=["enabled", "override_off", "missing_keeps_default"], +) +def test_fulfill_params_from_enterprise_enable_app_deploy( + monkeypatch: pytest.MonkeyPatch, + enterprise_info: dict, + initial: bool, + expected: bool, +): + monkeypatch.setattr( + feature_service_module.EnterpriseService, + "get_info", + staticmethod(lambda: enterprise_info), + ) + + features = SystemFeatureModel() + features.enable_app_deploy = initial + + FeatureService._fulfill_params_from_enterprise(features) + + assert features.enable_app_deploy is expected diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 87230e947ef..8a75a82a9da 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -487,14 +487,6 @@ "count": 1 } }, - "web/app/components/app/app-access-control/access-control-item.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/app/app-publisher/sections.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -7649,11 +7641,6 @@ "count": 1 } }, - "web/models/access-control.ts": { - "erasable-syntax-only/enums": { - "count": 2 - } - }, "web/models/app.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -8051,11 +8038,6 @@ "count": 17 } }, - "web/utils/clipboard.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/utils/completion-params.spec.ts": { "ts/no-explicit-any": { "count": 3 diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index 4f2ef2fa94e..01c77ed076d 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -6,6 +6,7 @@ export type ClientOptions = { export type SystemFeatureModel = { branding: BrandingModel + enable_app_deploy: boolean enable_change_email: boolean enable_collaboration_mode: boolean enable_creators_platform: boolean diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index 7464057a391..a7a8946ad50 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -98,6 +98,7 @@ export const zSystemFeatureModel = z.object({ login_page_logo: '', workspace_logo: '', }), + enable_app_deploy: z.boolean().default(false), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), enable_creators_platform: z.boolean().default(false), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 9d16cb99524..47eaed612cf 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -540,6 +540,7 @@ export type SuggestedQuestionsResponse = { export type SystemFeatureModel = { branding: BrandingModel + enable_app_deploy: boolean enable_change_email: boolean enable_collaboration_mode: boolean enable_creators_platform: boolean diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index cb731344ab6..aa96e4b3231 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -824,6 +824,7 @@ export const zSystemFeatureModel = z.object({ login_page_logo: '', workspace_logo: '', }), + enable_app_deploy: z.boolean().default(false), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), enable_creators_platform: z.boolean().default(false), diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 6b9b76470aa..61503a7f742 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -4,9 +4,97 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zAccessServiceCreateApiKeyBody, + zAccessServiceCreateApiKeyPath, + zAccessServiceCreateApiKeyResponse, + zAccessServiceDeleteApiKeyPath, + zAccessServiceDeleteApiKeyResponse, + zAccessServiceGetAccessChannelsPath, + zAccessServiceGetAccessChannelsResponse, + zAccessServiceGetAccessPolicyPath, + zAccessServiceGetAccessPolicyResponse, + zAccessServiceGetAccessSettingsPath, + zAccessServiceGetAccessSettingsResponse, + zAccessServiceGetDeveloperApiSettingsPath, + zAccessServiceGetDeveloperApiSettingsResponse, + zAccessServiceListApiKeysPath, + zAccessServiceListApiKeysResponse, + zAccessServiceUpdateAccessChannelsBody, + zAccessServiceUpdateAccessChannelsPath, + zAccessServiceUpdateAccessChannelsResponse, + zAccessServiceUpdateAccessPolicyBody, + zAccessServiceUpdateAccessPolicyPath, + zAccessServiceUpdateAccessPolicyResponse, + zAccessSubjectServiceListAccessSubjectsQuery, + zAccessSubjectServiceListAccessSubjectsResponse, + zAppInstanceServiceCreateAppInstanceBody, + zAppInstanceServiceCreateAppInstanceResponse, + zAppInstanceServiceDeleteAppInstancePath, + zAppInstanceServiceDeleteAppInstanceResponse, + zAppInstanceServiceGetAppInstanceOverviewPath, + zAppInstanceServiceGetAppInstanceOverviewResponse, + zAppInstanceServiceGetAppInstancePath, + zAppInstanceServiceGetAppInstanceResponse, + zAppInstanceServiceListAppInstancesQuery, + zAppInstanceServiceListAppInstancesResponse, + zAppInstanceServiceListAppInstanceSummariesQuery, + zAppInstanceServiceListAppInstanceSummariesResponse, + zAppInstanceServiceUpdateAppInstanceBody, + zAppInstanceServiceUpdateAppInstancePath, + zAppInstanceServiceUpdateAppInstanceResponse, zConsoleSsoOAuth2LoginResponse, zConsoleSsoOidcLoginResponse, zConsoleSsoSamlLoginResponse, + zDeploymentServiceCancelDeploymentBody, + zDeploymentServiceCancelDeploymentPath, + zDeploymentServiceCancelDeploymentResponse, + zDeploymentServiceDeployBody, + zDeploymentServiceDeployResponse, + zDeploymentServiceListDeploymentsPath, + zDeploymentServiceListDeploymentsQuery, + zDeploymentServiceListDeploymentsResponse, + zDeploymentServiceListEnvironmentDeploymentsPath, + zDeploymentServiceListEnvironmentDeploymentsResponse, + zDeploymentServiceListRollbackTargetsPath, + zDeploymentServiceListRollbackTargetsQuery, + zDeploymentServiceListRollbackTargetsResponse, + zDeploymentServicePromoteBody, + zDeploymentServicePromotePath, + zDeploymentServicePromoteResponse, + zDeploymentServiceRollbackBody, + zDeploymentServiceRollbackPath, + zDeploymentServiceRollbackResponse, + zDeploymentServiceUndeployBody, + zDeploymentServiceUndeployPath, + zDeploymentServiceUndeployResponse, + zEnvironmentServiceListEnvironmentsQuery, + zEnvironmentServiceListEnvironmentsResponse, + zReleaseServiceComputeDeploymentOptionsBody, + zReleaseServiceComputeDeploymentOptionsResponse, + zReleaseServiceComputeReleaseDeploymentViewPath, + zReleaseServiceComputeReleaseDeploymentViewQuery, + zReleaseServiceComputeReleaseDeploymentViewResponse, + zReleaseServiceCreateReleaseBody, + zReleaseServiceCreateReleaseResponse, + zReleaseServiceDeleteReleasePath, + zReleaseServiceDeleteReleaseResponse, + zReleaseServiceExportReleaseDslPath, + zReleaseServiceExportReleaseDslResponse, + zReleaseServiceGetReleasePath, + zReleaseServiceGetReleaseResponse, + zReleaseServiceListReleaseCredentialCandidatesPath, + zReleaseServiceListReleaseCredentialCandidatesResponse, + zReleaseServiceListReleasesPath, + zReleaseServiceListReleasesQuery, + zReleaseServiceListReleasesResponse, + zReleaseServiceListReleaseSummariesPath, + zReleaseServiceListReleaseSummariesQuery, + zReleaseServiceListReleaseSummariesResponse, + zReleaseServicePrecheckReleaseBody, + zReleaseServicePrecheckReleaseResponse, + zReleaseServiceUpdateReleaseBody, + zReleaseServiceUpdateReleasePath, + zReleaseServiceUpdateReleaseResponse, zWebAppAuthGetGroupSubjectsQuery, zWebAppAuthGetGroupSubjectsResponse, zWebAppAuthGetWebAppAccessModeQuery, @@ -21,6 +109,524 @@ import { zWebAppAuthUpdateWebAppWhitelistSubjectsResponse, } from './zod.gen' +export const listAccessSubjects = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessSubjectService_ListAccessSubjects', + path: '/enterprise/access-subjects', + tags: ['AccessSubjectService'], + }) + .input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() })) + .output(zAccessSubjectServiceListAccessSubjectsResponse) + +export const accessSubjectService = { + listAccessSubjects, +} + +export const listAppInstanceSummaries = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_ListAppInstanceSummaries', + path: '/enterprise/app-deploy/appInstanceSummaries', + tags: ['AppInstanceService'], + }) + .input(z.object({ query: zAppInstanceServiceListAppInstanceSummariesQuery.optional() })) + .output(zAppInstanceServiceListAppInstanceSummariesResponse) + +export const listAppInstances = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_ListAppInstances', + path: '/enterprise/app-deploy/appInstances', + tags: ['AppInstanceService'], + }) + .input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() })) + .output(zAppInstanceServiceListAppInstancesResponse) + +export const createAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'AppInstanceService_CreateAppInstance', + path: '/enterprise/app-deploy/appInstances', + tags: ['AppInstanceService'], + }) + .input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody })) + .output(zAppInstanceServiceCreateAppInstanceResponse) + +export const deleteAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'AppInstanceService_DeleteAppInstance', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}', + tags: ['AppInstanceService'], + }) + .input(z.object({ params: zAppInstanceServiceDeleteAppInstancePath })) + .output(zAppInstanceServiceDeleteAppInstanceResponse) + +export const getAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_GetAppInstance', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}', + tags: ['AppInstanceService'], + }) + .input(z.object({ params: zAppInstanceServiceGetAppInstancePath })) + .output(zAppInstanceServiceGetAppInstanceResponse) + +export const updateAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'AppInstanceService_UpdateAppInstance', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}', + tags: ['AppInstanceService'], + }) + .input( + z.object({ + body: zAppInstanceServiceUpdateAppInstanceBody, + params: zAppInstanceServiceUpdateAppInstancePath, + }), + ) + .output(zAppInstanceServiceUpdateAppInstanceResponse) + +export const getAppInstanceOverview = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_GetAppInstanceOverview', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}:getOverview', + tags: ['AppInstanceService'], + }) + .input(z.object({ params: zAppInstanceServiceGetAppInstanceOverviewPath })) + .output(zAppInstanceServiceGetAppInstanceOverviewResponse) + +export const appInstanceService = { + listAppInstanceSummaries, + listAppInstances, + createAppInstance, + deleteAppInstance, + getAppInstance, + updateAppInstance, + getAppInstanceOverview, +} + +export const getAccessChannels = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetAccessChannels', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetAccessChannelsPath })) + .output(zAccessServiceGetAccessChannelsResponse) + +export const updateAccessChannels = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'AccessService_UpdateAccessChannels', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels', + tags: ['AccessService'], + }) + .input( + z.object({ + body: zAccessServiceUpdateAccessChannelsBody, + params: zAccessServiceUpdateAccessChannelsPath, + }), + ) + .output(zAccessServiceUpdateAccessChannelsResponse) + +export const getAccessSettings = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetAccessSettings', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessSettings', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetAccessSettingsPath })) + .output(zAccessServiceGetAccessSettingsResponse) + +export const getDeveloperApiSettings = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetDeveloperApiSettings', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/developerApiSettings', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetDeveloperApiSettingsPath })) + .output(zAccessServiceGetDeveloperApiSettingsResponse) + +export const getAccessPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetAccessPolicy', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetAccessPolicyPath })) + .output(zAccessServiceGetAccessPolicyResponse) + +export const updateAccessPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'AccessService_UpdateAccessPolicy', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy', + tags: ['AccessService'], + }) + .input( + z.object({ + body: zAccessServiceUpdateAccessPolicyBody, + params: zAccessServiceUpdateAccessPolicyPath, + }), + ) + .output(zAccessServiceUpdateAccessPolicyResponse) + +export const listApiKeys = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_ListApiKeys', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceListApiKeysPath })) + .output(zAccessServiceListApiKeysResponse) + +export const createApiKey = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'AccessService_CreateApiKey', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys', + tags: ['AccessService'], + }) + .input(z.object({ body: zAccessServiceCreateApiKeyBody, params: zAccessServiceCreateApiKeyPath })) + .output(zAccessServiceCreateApiKeyResponse) + +export const deleteApiKey = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'AccessService_DeleteApiKey', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys/{apiKeyId}', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceDeleteApiKeyPath })) + .output(zAccessServiceDeleteApiKeyResponse) + +export const accessService = { + getAccessChannels, + updateAccessChannels, + getAccessSettings, + getDeveloperApiSettings, + getAccessPolicy, + updateAccessPolicy, + listApiKeys, + createApiKey, + deleteApiKey, +} + +export const listDeployments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'DeploymentService_ListDeployments', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/deployments', + tags: ['DeploymentService'], + }) + .input( + z.object({ + params: zDeploymentServiceListDeploymentsPath, + query: zDeploymentServiceListDeploymentsQuery.optional(), + }), + ) + .output(zDeploymentServiceListDeploymentsResponse) + +export const listEnvironmentDeployments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'DeploymentService_ListEnvironmentDeployments', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environmentDeployments', + tags: ['DeploymentService'], + }) + .input(z.object({ params: zDeploymentServiceListEnvironmentDeploymentsPath })) + .output(zDeploymentServiceListEnvironmentDeploymentsResponse) + +export const listRollbackTargets = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'DeploymentService_ListRollbackTargets', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/rollbackTargets', + tags: ['DeploymentService'], + }) + .input( + z.object({ + params: zDeploymentServiceListRollbackTargetsPath, + query: zDeploymentServiceListRollbackTargetsQuery.optional(), + }), + ) + .output(zDeploymentServiceListRollbackTargetsResponse) + +/** + * CancelDeployment cancels the in-flight deployment on the environment. + */ +export const cancelDeployment = oc + .route({ + description: 'CancelDeployment cancels the in-flight deployment on the environment.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_CancelDeployment', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:cancelDeployment', + tags: ['DeploymentService'], + }) + .input( + z.object({ + body: zDeploymentServiceCancelDeploymentBody, + params: zDeploymentServiceCancelDeploymentPath, + }), + ) + .output(zDeploymentServiceCancelDeploymentResponse) + +export const promote = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Promote', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:promote', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServicePromoteBody, params: zDeploymentServicePromotePath })) + .output(zDeploymentServicePromoteResponse) + +export const rollback = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Rollback', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:rollback', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServiceRollbackBody, params: zDeploymentServiceRollbackPath })) + .output(zDeploymentServiceRollbackResponse) + +export const undeploy = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Undeploy', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:undeploy', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServiceUndeployBody, params: zDeploymentServiceUndeployPath })) + .output(zDeploymentServiceUndeployResponse) + +export const deploy = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Deploy', + path: '/enterprise/app-deploy/appInstances:deploy', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServiceDeployBody })) + .output(zDeploymentServiceDeployResponse) + +export const deploymentService = { + listDeployments, + listEnvironmentDeployments, + listRollbackTargets, + cancelDeployment, + promote, + rollback, + undeploy, + deploy, +} + +export const listReleaseSummaries = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ListReleaseSummaries', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/releaseSummaries', + tags: ['ReleaseService'], + }) + .input( + z.object({ + params: zReleaseServiceListReleaseSummariesPath, + query: zReleaseServiceListReleaseSummariesQuery.optional(), + }), + ) + .output(zReleaseServiceListReleaseSummariesResponse) + +export const listReleases = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ListReleases', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/releases', + tags: ['ReleaseService'], + }) + .input( + z.object({ + params: zReleaseServiceListReleasesPath, + query: zReleaseServiceListReleasesQuery.optional(), + }), + ) + .output(zReleaseServiceListReleasesResponse) + +export const computeReleaseDeploymentView = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ComputeReleaseDeploymentView', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}:computeReleaseDeploymentView', + tags: ['ReleaseService'], + }) + .input( + z.object({ + params: zReleaseServiceComputeReleaseDeploymentViewPath, + query: zReleaseServiceComputeReleaseDeploymentViewQuery.optional(), + }), + ) + .output(zReleaseServiceComputeReleaseDeploymentViewResponse) + +export const createRelease = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ReleaseService_CreateRelease', + path: '/enterprise/app-deploy/releases', + tags: ['ReleaseService'], + }) + .input(z.object({ body: zReleaseServiceCreateReleaseBody })) + .output(zReleaseServiceCreateReleaseResponse) + +export const deleteRelease = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'ReleaseService_DeleteRelease', + path: '/enterprise/app-deploy/releases/{releaseId}', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceDeleteReleasePath })) + .output(zReleaseServiceDeleteReleaseResponse) + +export const getRelease = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_GetRelease', + path: '/enterprise/app-deploy/releases/{releaseId}', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceGetReleasePath })) + .output(zReleaseServiceGetReleaseResponse) + +export const updateRelease = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'ReleaseService_UpdateRelease', + path: '/enterprise/app-deploy/releases/{releaseId}', + tags: ['ReleaseService'], + }) + .input( + z.object({ body: zReleaseServiceUpdateReleaseBody, params: zReleaseServiceUpdateReleasePath }), + ) + .output(zReleaseServiceUpdateReleaseResponse) + +export const exportReleaseDsl = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ExportReleaseDSL', + path: '/enterprise/app-deploy/releases/{releaseId}:exportDsl', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceExportReleaseDslPath })) + .output(zReleaseServiceExportReleaseDslResponse) + +export const listReleaseCredentialCandidates = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ListReleaseCredentialCandidates', + path: '/enterprise/app-deploy/releases/{releaseId}:listCredentialCandidates', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceListReleaseCredentialCandidatesPath })) + .output(zReleaseServiceListReleaseCredentialCandidatesResponse) + +export const computeDeploymentOptions = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ReleaseService_ComputeDeploymentOptions', + path: '/enterprise/app-deploy/releases:computeDeploymentOptions', + tags: ['ReleaseService'], + }) + .input(z.object({ body: zReleaseServiceComputeDeploymentOptionsBody })) + .output(zReleaseServiceComputeDeploymentOptionsResponse) + +export const precheckRelease = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ReleaseService_PrecheckRelease', + path: '/enterprise/app-deploy/releases:precheck', + tags: ['ReleaseService'], + }) + .input(z.object({ body: zReleaseServicePrecheckReleaseBody })) + .output(zReleaseServicePrecheckReleaseResponse) + +export const releaseService = { + listReleaseSummaries, + listReleases, + computeReleaseDeploymentView, + createRelease, + deleteRelease, + getRelease, + updateRelease, + exportReleaseDsl, + listReleaseCredentialCandidates, + computeDeploymentOptions, + precheckRelease, +} + +/** + * ListEnvironments returns only the environments the current user can + * deploy to. + */ +export const listEnvironments = oc + .route({ + description: 'ListEnvironments returns only the environments the current user can\n deploy to.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'EnvironmentService_ListEnvironments', + path: '/enterprise/app-deploy/environments', + tags: ['EnvironmentService'], + }) + .input(z.object({ query: zEnvironmentServiceListEnvironmentsQuery.optional() })) + .output(zEnvironmentServiceListEnvironmentsResponse) + +export const environmentService = { + listEnvironments, +} + export const oAuth2Login = oc .route({ inputStructure: 'detailed', @@ -133,6 +739,12 @@ export const webAppAuth = { } export const contract = { + accessSubjectService, + appInstanceService, + accessService, + deploymentService, + releaseService, + environmentService, consoleSso, webAppAuth, } diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index b747c4baa89..0c990967a3c 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -4,6 +4,1051 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } +export const AccessMode = { + ACCESS_MODE_UNSPECIFIED: 'ACCESS_MODE_UNSPECIFIED', + ACCESS_MODE_PUBLIC: 'ACCESS_MODE_PUBLIC', + ACCESS_MODE_PRIVATE: 'ACCESS_MODE_PRIVATE', + ACCESS_MODE_PRIVATE_ALL: 'ACCESS_MODE_PRIVATE_ALL', +} as const + +export type AccessMode = (typeof AccessMode)[keyof typeof AccessMode] + +export const SubjectType = { + SUBJECT_TYPE_UNSPECIFIED: 'SUBJECT_TYPE_UNSPECIFIED', + SUBJECT_TYPE_ACCOUNT: 'SUBJECT_TYPE_ACCOUNT', + SUBJECT_TYPE_GROUP: 'SUBJECT_TYPE_GROUP', +} as const + +export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType] + +export const AppRunnerLogStatus = { + APP_RUNNER_LOG_STATUS_UNSPECIFIED: 'APP_RUNNER_LOG_STATUS_UNSPECIFIED', + APP_RUNNER_LOG_STATUS_RUNNING: 'APP_RUNNER_LOG_STATUS_RUNNING', + APP_RUNNER_LOG_STATUS_SUCCEEDED: 'APP_RUNNER_LOG_STATUS_SUCCEEDED', + APP_RUNNER_LOG_STATUS_FAILED: 'APP_RUNNER_LOG_STATUS_FAILED', + APP_RUNNER_LOG_STATUS_PARTIAL_SUCCEEDED: 'APP_RUNNER_LOG_STATUS_PARTIAL_SUCCEEDED', +} as const + +export type AppRunnerLogStatus = (typeof AppRunnerLogStatus)[keyof typeof AppRunnerLogStatus] + +export const AssignmentOperation = { + ASSIGNMENT_OPERATION_UNSPECIFIED: 'ASSIGNMENT_OPERATION_UNSPECIFIED', + ASSIGNMENT_OPERATION_LOAD: 'ASSIGNMENT_OPERATION_LOAD', + ASSIGNMENT_OPERATION_UNLOAD: 'ASSIGNMENT_OPERATION_UNLOAD', +} as const + +export type AssignmentOperation = (typeof AssignmentOperation)[keyof typeof AssignmentOperation] + +export const EnvironmentMode = { + ENVIRONMENT_MODE_UNSPECIFIED: 'ENVIRONMENT_MODE_UNSPECIFIED', + ENVIRONMENT_MODE_SHARED: 'ENVIRONMENT_MODE_SHARED', + ENVIRONMENT_MODE_ISOLATED: 'ENVIRONMENT_MODE_ISOLATED', +} as const + +export type EnvironmentMode = (typeof EnvironmentMode)[keyof typeof EnvironmentMode] + +export const RuntimeBackend = { + RUNTIME_BACKEND_UNSPECIFIED: 'RUNTIME_BACKEND_UNSPECIFIED', + RUNTIME_BACKEND_K8S: 'RUNTIME_BACKEND_K8S', + RUNTIME_BACKEND_EXTERNAL: 'RUNTIME_BACKEND_EXTERNAL', +} as const + +export type RuntimeBackend = (typeof RuntimeBackend)[keyof typeof RuntimeBackend] + +export const PluginCategory = { + PLUGIN_CATEGORY_UNSPECIFIED: 'PLUGIN_CATEGORY_UNSPECIFIED', + PLUGIN_CATEGORY_MODEL: 'PLUGIN_CATEGORY_MODEL', + PLUGIN_CATEGORY_TOOL: 'PLUGIN_CATEGORY_TOOL', +} as const + +export type PluginCategory = (typeof PluginCategory)[keyof typeof PluginCategory] + +export const DeploymentStatus = { + DEPLOYMENT_STATUS_UNSPECIFIED: 'DEPLOYMENT_STATUS_UNSPECIFIED', + DEPLOYMENT_STATUS_DEPLOYING: 'DEPLOYMENT_STATUS_DEPLOYING', + DEPLOYMENT_STATUS_READY: 'DEPLOYMENT_STATUS_READY', + DEPLOYMENT_STATUS_FAILED: 'DEPLOYMENT_STATUS_FAILED', + DEPLOYMENT_STATUS_CANCELLED: 'DEPLOYMENT_STATUS_CANCELLED', +} as const + +export type DeploymentStatus = (typeof DeploymentStatus)[keyof typeof DeploymentStatus] + +export const DeploymentAction = { + DEPLOYMENT_ACTION_UNSPECIFIED: 'DEPLOYMENT_ACTION_UNSPECIFIED', + DEPLOYMENT_ACTION_DEPLOY: 'DEPLOYMENT_ACTION_DEPLOY', + DEPLOYMENT_ACTION_PROMOTE: 'DEPLOYMENT_ACTION_PROMOTE', + DEPLOYMENT_ACTION_ROLLBACK: 'DEPLOYMENT_ACTION_ROLLBACK', + DEPLOYMENT_ACTION_UNDEPLOY: 'DEPLOYMENT_ACTION_UNDEPLOY', +} as const + +export type DeploymentAction = (typeof DeploymentAction)[keyof typeof DeploymentAction] + +export const DeveloperApiUrlStatus = { + DEVELOPER_API_URL_STATUS_UNSPECIFIED: 'DEVELOPER_API_URL_STATUS_UNSPECIFIED', + DEVELOPER_API_URL_STATUS_CONFIGURED: 'DEVELOPER_API_URL_STATUS_CONFIGURED', + DEVELOPER_API_URL_STATUS_NOT_CONFIGURED: 'DEVELOPER_API_URL_STATUS_NOT_CONFIGURED', +} as const + +export type DeveloperApiUrlStatus + = (typeof DeveloperApiUrlStatus)[keyof typeof DeveloperApiUrlStatus] + +export const EnvVarValueSource = { + ENV_VAR_VALUE_SOURCE_UNSPECIFIED: 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', + ENV_VAR_VALUE_SOURCE_LITERAL: 'ENV_VAR_VALUE_SOURCE_LITERAL', + ENV_VAR_VALUE_SOURCE_DSL_DEFAULT: 'ENV_VAR_VALUE_SOURCE_DSL_DEFAULT', + ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT: 'ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT', +} as const + +export type EnvVarValueSource = (typeof EnvVarValueSource)[keyof typeof EnvVarValueSource] + +export const EnvVarValueType = { + ENV_VAR_VALUE_TYPE_UNSPECIFIED: 'ENV_VAR_VALUE_TYPE_UNSPECIFIED', + ENV_VAR_VALUE_TYPE_STRING: 'ENV_VAR_VALUE_TYPE_STRING', + ENV_VAR_VALUE_TYPE_NUMBER: 'ENV_VAR_VALUE_TYPE_NUMBER', + ENV_VAR_VALUE_TYPE_SECRET: 'ENV_VAR_VALUE_TYPE_SECRET', +} as const + +export type EnvVarValueType = (typeof EnvVarValueType)[keyof typeof EnvVarValueType] + +export const EnvironmentStatus = { + ENVIRONMENT_STATUS_UNSPECIFIED: 'ENVIRONMENT_STATUS_UNSPECIFIED', + ENVIRONMENT_STATUS_ADMISSION: 'ENVIRONMENT_STATUS_ADMISSION', + ENVIRONMENT_STATUS_BOOTSTRAPPING: 'ENVIRONMENT_STATUS_BOOTSTRAPPING', + ENVIRONMENT_STATUS_READY: 'ENVIRONMENT_STATUS_READY', + ENVIRONMENT_STATUS_FAILED: 'ENVIRONMENT_STATUS_FAILED', + ENVIRONMENT_STATUS_DELETING: 'ENVIRONMENT_STATUS_DELETING', +} as const + +export type EnvironmentStatus = (typeof EnvironmentStatus)[keyof typeof EnvironmentStatus] + +export const RuntimeInstanceStatus = { + RUNTIME_INSTANCE_STATUS_UNSPECIFIED: 'RUNTIME_INSTANCE_STATUS_UNSPECIFIED', + RUNTIME_INSTANCE_STATUS_UNDEPLOYED: 'RUNTIME_INSTANCE_STATUS_UNDEPLOYED', + RUNTIME_INSTANCE_STATUS_DEPLOYING: 'RUNTIME_INSTANCE_STATUS_DEPLOYING', + RUNTIME_INSTANCE_STATUS_READY: 'RUNTIME_INSTANCE_STATUS_READY', + RUNTIME_INSTANCE_STATUS_FAILED: 'RUNTIME_INSTANCE_STATUS_FAILED', + RUNTIME_INSTANCE_STATUS_DRIFTED: 'RUNTIME_INSTANCE_STATUS_DRIFTED', + RUNTIME_INSTANCE_STATUS_INVALID: 'RUNTIME_INSTANCE_STATUS_INVALID', + RUNTIME_INSTANCE_STATUS_UNDEPLOYING: 'RUNTIME_INSTANCE_STATUS_UNDEPLOYING', +} as const + +export type RuntimeInstanceStatus + = (typeof RuntimeInstanceStatus)[keyof typeof RuntimeInstanceStatus] + +export const AppRunnerLaunchProfileMode = { + APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED: 'APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED', + APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG: 'APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG', +} as const + +export type AppRunnerLaunchProfileMode + = (typeof AppRunnerLaunchProfileMode)[keyof typeof AppRunnerLaunchProfileMode] + +export const OperatorType = { + OPERATOR_TYPE_UNSPECIFIED: 'OPERATOR_TYPE_UNSPECIFIED', + OPERATOR_TYPE_END_USER: 'OPERATOR_TYPE_END_USER', + OPERATOR_TYPE_ACCOUNT: 'OPERATOR_TYPE_ACCOUNT', + OPERATOR_TYPE_SERVICE_ACCOUNT: 'OPERATOR_TYPE_SERVICE_ACCOUNT', + OPERATOR_TYPE_SYSTEM: 'OPERATOR_TYPE_SYSTEM', +} as const + +export type OperatorType = (typeof OperatorType)[keyof typeof OperatorType] + +export const ReleaseSource = { + RELEASE_SOURCE_UNSPECIFIED: 'RELEASE_SOURCE_UNSPECIFIED', + RELEASE_SOURCE_SOURCE_APP: 'RELEASE_SOURCE_SOURCE_APP', + RELEASE_SOURCE_UPLOAD: 'RELEASE_SOURCE_UPLOAD', +} as const + +export type ReleaseSource = (typeof ReleaseSource)[keyof typeof ReleaseSource] + +export const ReleaseEnvironmentActionKind = { + RELEASE_ENVIRONMENT_ACTION_KIND_UNSPECIFIED: 'RELEASE_ENVIRONMENT_ACTION_KIND_UNSPECIFIED', + RELEASE_ENVIRONMENT_ACTION_KIND_PROMOTE: 'RELEASE_ENVIRONMENT_ACTION_KIND_PROMOTE', + RELEASE_ENVIRONMENT_ACTION_KIND_ROLLBACK: 'RELEASE_ENVIRONMENT_ACTION_KIND_ROLLBACK', + RELEASE_ENVIRONMENT_ACTION_KIND_CURRENT: 'RELEASE_ENVIRONMENT_ACTION_KIND_CURRENT', + RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING: 'RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING', + RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED: 'RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED', +} as const + +export type ReleaseEnvironmentActionKind + = (typeof ReleaseEnvironmentActionKind)[keyof typeof ReleaseEnvironmentActionKind] + +export const AckStatus = { + ACK_STATUS_UNSPECIFIED: 'ACK_STATUS_UNSPECIFIED', + ACK_STATUS_READY: 'ACK_STATUS_READY', + ACK_STATUS_FAILED: 'ACK_STATUS_FAILED', +} as const + +export type AckStatus = (typeof AckStatus)[keyof typeof AckStatus] + +export const SlotType = { + SLOT_TYPE_UNSPECIFIED: 'SLOT_TYPE_UNSPECIFIED', + SLOT_TYPE_PLUGIN_CREDENTIAL: 'SLOT_TYPE_PLUGIN_CREDENTIAL', + SLOT_TYPE_ENV_VAR: 'SLOT_TYPE_ENV_VAR', +} as const + +export type SlotType = (typeof SlotType)[keyof typeof SlotType] + +export const RouteTargetKind = { + ROUTE_TARGET_KIND_UNSPECIFIED: 'ROUTE_TARGET_KIND_UNSPECIFIED', + ROUTE_TARGET_KIND_K8S_SERVICE: 'ROUTE_TARGET_KIND_K8S_SERVICE', + ROUTE_TARGET_KIND_DIRECT_UPSTREAM: 'ROUTE_TARGET_KIND_DIRECT_UPSTREAM', +} as const + +export type RouteTargetKind = (typeof RouteTargetKind)[keyof typeof RouteTargetKind] + +export const PasswordChangeReason = { + PASSWORD_CHANGE_REASON_UNSPECIFIED: 'PASSWORD_CHANGE_REASON_UNSPECIFIED', + PASSWORD_CHANGE_REASON_TEMP: 'PASSWORD_CHANGE_REASON_TEMP', + PASSWORD_CHANGE_REASON_EXPIRED: 'PASSWORD_CHANGE_REASON_EXPIRED', + PASSWORD_CHANGE_REASON_POLICY: 'PASSWORD_CHANGE_REASON_POLICY', +} as const + +export type PasswordChangeReason = (typeof PasswordChangeReason)[keyof typeof PasswordChangeReason] + +export const OtelEndpointMode = { + OTEL_ENDPOINT_MODE_UNIFIED: 'OTEL_ENDPOINT_MODE_UNIFIED', + OTEL_ENDPOINT_MODE_DEDICATED: 'OTEL_ENDPOINT_MODE_DEDICATED', +} as const + +export type OtelEndpointMode = (typeof OtelEndpointMode)[keyof typeof OtelEndpointMode] + +export const AppStatus = { + APP_STATUS_UNSPECIFIED: 'APP_STATUS_UNSPECIFIED', + APP_STATUS_PUBLISHED: 'APP_STATUS_PUBLISHED', + APP_STATUS_UNPUBLISHED: 'APP_STATUS_UNPUBLISHED', + APP_STATUS_DELETED: 'APP_STATUS_DELETED', +} as const + +export type AppStatus = (typeof AppStatus)[keyof typeof AppStatus] + +export const LimitType = { + LIMIT_TYPE_UNSPECIFIED: 'LIMIT_TYPE_UNSPECIFIED', + LIMIT_TYPE_RPM: 'LIMIT_TYPE_RPM', + LIMIT_TYPE_CONCURRENCY: 'LIMIT_TYPE_CONCURRENCY', + LIMIT_TYPE_TOKEN: 'LIMIT_TYPE_TOKEN', +} as const + +export type LimitType = (typeof LimitType)[keyof typeof LimitType] + +export const LimitAction = { + LIMIT_ACTION_UNSPECIFIED: 'LIMIT_ACTION_UNSPECIFIED', + LIMIT_ACTION_BLOCK: 'LIMIT_ACTION_BLOCK', + LIMIT_ACTION_TRACK: 'LIMIT_ACTION_TRACK', +} as const + +export type LimitAction = (typeof LimitAction)[keyof typeof LimitAction] + +export const PasswordStrengthLevel = { + PASSWORD_STRENGTH_LEVEL_UNSPECIFIED: 'PASSWORD_STRENGTH_LEVEL_UNSPECIFIED', + PASSWORD_STRENGTH_LEVEL_WEAK: 'PASSWORD_STRENGTH_LEVEL_WEAK', + PASSWORD_STRENGTH_LEVEL_MEDIUM: 'PASSWORD_STRENGTH_LEVEL_MEDIUM', + PASSWORD_STRENGTH_LEVEL_STRONG: 'PASSWORD_STRENGTH_LEVEL_STRONG', +} as const + +export type PasswordStrengthLevel + = (typeof PasswordStrengthLevel)[keyof typeof PasswordStrengthLevel] + +export const PluginInstallationScope = { + PLUGIN_INSTALLATION_SCOPE_ALL: 'PLUGIN_INSTALLATION_SCOPE_ALL', + PLUGIN_INSTALLATION_SCOPE_OFFICIAL_ONLY: 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_ONLY', + PLUGIN_INSTALLATION_SCOPE_OFFICIAL_AND_SPECIFIC_PARTNERS: + 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_AND_SPECIFIC_PARTNERS', + PLUGIN_INSTALLATION_SCOPE_NONE: 'PLUGIN_INSTALLATION_SCOPE_NONE', +} as const + +export type PluginInstallationScope + = (typeof PluginInstallationScope)[keyof typeof PluginInstallationScope] + +export const LimitStatus = { + LIMIT_STATUS_UNSPECIFIED: 'LIMIT_STATUS_UNSPECIFIED', + LIMIT_STATUS_NA: 'LIMIT_STATUS_NA', + LIMIT_STATUS_NORMAL: 'LIMIT_STATUS_NORMAL', + LIMIT_STATUS_THROTTLED: 'LIMIT_STATUS_THROTTLED', +} as const + +export type LimitStatus = (typeof LimitStatus)[keyof typeof LimitStatus] + +export type AccessChannels = { + id: string + appInstanceId: string + webAppEnabled: boolean + developerApiEnabled: boolean + updatedBy: Actor + createdAt: string + updatedAt: string +} + +export type AccessEndpoint = { + environment?: Environment + endpointUrl: string +} + +export type AccessPolicy = { + id: string + appInstanceId: string + environmentId: string + mode: AccessMode + subjects: Array + createdAt: string + updatedAt: string +} + +export type AccessSubject = { + subjectType: SubjectType + subjectId: string +} + +export type Actor = { + id: string + displayName: string +} + +export type ApiKey = { + id: string + appInstanceId: string + environmentId: string + displayName: string + maskedToken: string + createdBy: Actor + createdAt: string + lastUsedAt?: string +} + +export type ApiKeySummary = { + apiKeyCount: number + environmentCount: number + developerApiEnabled: boolean + developerApiUrl: DeveloperApiUrl +} + +export type AppInstance = { + id: string + tenantId: string + displayName: string + description: string + createdBy: Actor + updatedBy: Actor + createdAt: string + updatedAt: string +} + +export type AppInstanceSummary = { + appInstance: AppInstance + environmentDeployments: Array + latestRelease?: Release + accessChannels: AccessChannels + apiKeySummary: ApiKeySummary +} + +export type AppRunnerLog = { + id: string + timestamp: string + workflowRunId: string + status: AppRunnerLogStatus + durationSeconds: number + totalTokens: string + workspace: NamedRef + environment: NamedRef + appInstance: NamedRef + operator: Operator + invokeFrom: string + traceId: string + difyTraceId: string + gateCommitId: string + body?: string + attributesJson?: string + resourceAttributesJson?: string +} + +export type BatchResolveRuntimeArtifactsRequest = { + requests?: Array +} + +export type BatchResolveRuntimeArtifactsResponse = { + results?: Array +} + +export type BootstrapAssignment = { + appId?: string + environmentId?: string + workflowId?: string + runtimeInstanceId?: string + workspaceId?: string + runtimeInstanceVersion?: string + bindingSnapshotVersion?: string + executionTokenVersion?: string + executionToken?: string + releaseId?: string + operation?: AssignmentOperation + deploymentId?: string + requiresStatusReport?: boolean +} + +export type BootstrapRunnerRequest = { + runner?: RunnerInfo +} + +export type BootstrapRunnerResponse = { + runnerId?: string + assignmentRevision?: string + assignments?: Array +} + +export type CancelDeploymentRequest = { + appInstanceId?: string + environmentId?: string +} + +export type CancelDeploymentResponse = { + deployment: Deployment +} + +export type ComputeDeploymentOptionsRequest = { + environmentId?: string + appInstanceId?: string + dsl?: string + sourceAppId?: string + releaseId?: string +} + +export type ComputeDeploymentOptionsResponse = { + options: DeploymentOptions +} + +export type ComputeReleaseDeploymentViewResponse = { + releases: Array + environmentDeployments: Array + environmentActions: Array + options?: DeploymentOptions +} + +export type CreateApiKeyRequest = { + appInstanceId?: string + environmentId?: string + displayName: string +} + +export type CreateApiKeyResponse = { + apiKey: ApiKey + token: string +} + +export type CreateAppInstanceRequest = { + displayName: string + description?: string +} + +export type CreateAppInstanceResponse = { + appInstance: AppInstance +} + +export type CreateEnvironmentRequest = { + displayName: string + description?: string + mode?: EnvironmentMode + backend?: RuntimeBackend + k8s?: K8sEnvironmentConfig + external?: ExternalAppRunnerConfig + cpuCount?: number + idempotencyKey: string +} + +export type CreateEnvironmentResponse = { + environment?: Environment +} + +export type CreateReleaseRequest = { + createAppInstance?: boolean + appInstanceId?: string + displayName?: string + description?: string + dsl?: string + sourceAppId?: string +} + +export type CreateReleaseResponse = { + release: Release + appInstance: AppInstance +} + +export type CredentialCandidate = { + credentialId: string + providerId: string + category: PluginCategory + displayName: string + fromEnterprise: boolean +} + +export type CredentialSelectionInput = { + providerId: string + category?: PluginCategory + credentialId: string +} + +export type CredentialSlot = { + providerId: string + category: PluginCategory + candidates: Array + lastCredentialId: string +} + +export type DashboardListAppInstancesResponse = { + appInstances: Array + pagination: Pagination +} + +export type DashboardListEnvironmentDeploymentsResponse = { + deployments?: Array + pagination?: Pagination +} + +export type DeleteApiKeyResponse = { + [key: string]: unknown +} + +export type DeleteAppInstanceResponse = { + [key: string]: unknown +} + +export type DeleteEnvironmentResponse = { + [key: string]: unknown +} + +export type DeleteReleaseResponse = { + [key: string]: unknown +} + +export type DeployRequest = { + dsl?: string + sourceAppId?: string + newAppInstance?: NewAppInstance + environmentId: string + releaseName?: string + releaseDescription?: string + credentials?: Array + envVars?: Array + idempotencyKey: string + expectedDslDigest?: string +} + +export type DeployResponse = { + appInstance: AppInstance + release: Release + deployment: Deployment +} + +export type Deployment = { + id: string + appInstanceId: string + status: DeploymentStatus + action: DeploymentAction + environment: Environment + release: Release + error?: Error + createdBy: Actor + createdAt: string + finalizedAt?: string +} + +export type DeploymentOptions = { + dslDigest: string + appInstanceDefaults: DeploymentOptionsAppInstanceDefaults + releaseDefaults: DeploymentOptionsReleaseDefaults + credentialSlots: Array + envVarSlots: Array +} + +export type DeploymentOptionsAppInstanceDefaults = { + displayName: string + description: string +} + +export type DeploymentOptionsReleaseDefaults = { + displayName: string + description: string +} + +export type DeveloperApiUrl = { + apiUrl: string + status: DeveloperApiUrlStatus + error?: Error +} + +export type EnvVarInput = { + key: string + value?: string + valueSource?: EnvVarValueSource +} + +export type EnvVarSlot = { + key: string + valueType: EnvVarValueType + description: string + defaultValue?: string + lastValue?: string +} + +export type Environment = { + id: string + displayName: string + description: string + mode: EnvironmentMode + backend: RuntimeBackend + status: EnvironmentStatus + statusMessage: string + lastError?: Error + apiServer?: string + namespace?: string + managedBy?: string + runtimeEndpoint?: string + cpuCount: number + createdAt: string + updatedAt: string +} + +export type EnvironmentAccessPolicy = { + environment: Environment + policy?: AccessPolicy + resolvedSubjects: Array +} + +export type EnvironmentAppInstance = { + appInstance?: AppInstance + currentRelease?: Release + status?: RuntimeInstanceStatus + lastError?: Error + workspaceId?: string + workspaceName?: string +} + +export type EnvironmentDeployment = { + appInstanceId: string + environment: Environment + status: RuntimeInstanceStatus + currentRelease?: Release + desiredRelease?: Release + currentDeployment?: EnvironmentDeploymentRecord + error?: Error + updatedAt: string + releasesBehind?: number +} + +export type EnvironmentDeploymentHistoryItem = { + deployment?: Deployment + appInstanceId?: string + appInstanceName?: string + workspaceId?: string + workspaceName?: string +} + +export type EnvironmentDeploymentRecord = { + id: string + status: DeploymentStatus + createdAt: string + finalizedAt?: string +} + +export type Error = { + code?: string + message?: string + phase?: string + occurredAt?: string +} + +export type ExchangeControlTokenRequest = { + joinToken?: string +} + +export type ExchangeControlTokenResponse = { + accessToken?: string + expiresAt?: string +} + +export type ExportReleaseDslResponse = { + dsl: string +} + +export type ExternalAppRunnerConfig = { + runtimeEndpoint?: string +} + +export type GenerateAppRunnerLaunchProfileRequest = { + environmentId?: string + mode?: AppRunnerLaunchProfileMode + controlEndpoint: string + pluginDaemonBaseUrl: string + runtimeListenAddr: string + debugListenAddr?: string +} + +export type GenerateAppRunnerLaunchProfileResponse = { + environmentId?: string + joinToken?: string + configYaml?: string + runtimeEndpoint?: string + sourceCommands?: Array + dockerCommands?: Array +} + +export type GetAccessChannelsResponse = { + accessChannels: AccessChannels +} + +export type GetAccessPolicyResponse = { + policy: AccessPolicy +} + +export type GetAccessSettingsResponse = { + accessChannels: AccessChannels + environmentPolicies: Array + webAppEndpoints?: Array + cliEndpoint?: AccessEndpoint +} + +export type GetAppInstanceOverviewResponse = { + appInstance: AppInstance + environmentDeployments: Array + recentReleases: Array + accessChannels: AccessChannels + apiKeySummary: ApiKeySummary + totalReleaseCount: number +} + +export type GetAppInstanceResponse = { + appInstance: AppInstance +} + +export type GetAppRunnerLogResponse = { + appRunnerLog: AppRunnerLog + lastArchived?: string +} + +export type GetDeveloperApiSettingsResponse = { + accessChannels: AccessChannels + environments: Array + apiKeys: Array + developerApiUrl: DeveloperApiUrl +} + +export type GetEnvironmentResponse = { + environment?: Environment +} + +export type GetReleaseResponse = { + release: Release +} + +export type K8sEnvironmentConfig = { + namespace?: string + apiServer?: string + caBundle?: string + bearerToken?: string +} + +export type ListApiKeysResponse = { + apiKeys: Array + apiUrl: string +} + +export type ListAppInstanceSummariesResponse = { + appInstanceSummaries: Array + pagination: Pagination +} + +export type ListAppInstancesResponse = { + appInstances: Array + pagination: Pagination +} + +export type ListAppRunnerLogsResponse = { + appRunnerLogs: Array + pagination: CursorPagination + lastArchived?: string +} + +export type ListDeploymentsResponse = { + deployments: Array + pagination: Pagination +} + +export type ListEnvironmentAppInstancesResponse = { + appInstances?: Array + pagination?: Pagination +} + +export type ListEnvironmentDeploymentsResponse = { + environmentDeployments: Array +} + +export type ListEnvironmentsResponse = { + environments: Array + pagination: Pagination +} + +export type ListReleaseCredentialCandidatesResponse = { + slots: Array +} + +export type ListReleaseSummariesResponse = { + releaseSummaries: Array + pagination: Pagination +} + +export type ListReleasesResponse = { + releases: Array + pagination: Pagination +} + +export type ListRollbackTargetsResponse = { + rollbackTargets: Array + pagination: Pagination +} + +export type NamedRef = { + id: string + displayName: string +} + +export type NewAppInstance = { + displayName?: string + description?: string +} + +export type Operator = { + type: OperatorType + id: string + displayName: string +} + +export type PrecheckReleaseRequest = { + appInstanceId?: string + dsl?: string + sourceAppId?: string +} + +export type PrecheckReleaseResponse = { + gateCommitId: string + canCreate: boolean + matchedRelease?: ReleaseContentMatch + unsupportedNodes: Array +} + +export type PromoteRequest = { + appInstanceId?: string + releaseId: string + environmentId?: string + credentials?: Array + envVars?: Array + idempotencyKey: string +} + +export type PromoteResponse = { + deployment: Deployment +} + +export type Release = { + id: string + appInstanceId: string + displayName: string + description: string + source: ReleaseSource + sourceAppId?: string + gateCommitId: string + requiredSlots: Array + createdBy: Actor + createdAt: string +} + +export type ReleaseContentMatch = { + releaseId: string + displayName: string + createdAt: string +} + +export type ReleaseEnvironmentAction = { + environment: Environment + kind: ReleaseEnvironmentActionKind + disabledReason?: string + requiresRuntimeInputs: boolean + currentReleaseId: string +} + +export type ReleaseEnvironmentDeployment = { + environment: Environment + status: RuntimeInstanceStatus +} + +export type ReleaseSummary = { + release: Release + deployedEnvironments: Array + environmentActions: Array + activeEnvironmentCount: number +} + +export type ReportRuntimeAssignmentStatusRequest = { + deploymentId?: string + runtimeInstanceId?: string + releaseId?: string + status?: AckStatus + lastError?: Error + runnerId?: string + assignmentRevision?: string +} + +export type ReportRuntimeAssignmentStatusResponse = { + accepted?: boolean + stale?: boolean +} + +export type RequiredSlot = { + type: SlotType + providerId: string + category: PluginCategory + key: string +} + +export type ResolveApiTokenRouteRequest = { + token?: string +} + +export type ResolveApiTokenRouteResponse = { + environmentId?: string + namespace?: string + serviceName?: string + servicePort?: number + environmentStatus?: EnvironmentStatus + appId?: string + tenantId?: string + runtimeInstanceId?: string + observedReleaseId?: string + runtimeInstanceStatus?: RuntimeInstanceStatus + revoked?: boolean + unavailableReason?: string + targetKind?: RouteTargetKind + directUpstream?: string +} + +export type RollbackRequest = { + appInstanceId?: string + environmentId?: string + targetReleaseId: string + idempotencyKey: string +} + +export type RollbackResponse = { + deployment: Deployment +} + +export type RollbackTarget = { + release: Release + resolvedDeploymentId: string + deployedAt: string + isCurrent: boolean +} + +export type RunnerInfo = { + hostname?: string +} + +export type RuntimeArtifact = { + dslYaml?: string + bindingSnapshotVersion?: string + bindingSnapshot?: { + [key: string]: unknown + } +} + +export type RuntimeArtifactRequest = { + runtimeInstanceId?: string + releaseId?: string + deploymentId?: string + bindingSnapshotVersion?: string +} + +export type RuntimeArtifactResult = { + runtimeInstanceId?: string + releaseId?: string + artifact?: RuntimeArtifact + error?: Error + deploymentId?: string +} + +export type TestConnectionRequest = { + environmentId?: string +} + +export type TestConnectionResponse = { + reachable?: boolean + message?: string +} + +export type UndeployRequest = { + appInstanceId?: string + environmentId?: string + idempotencyKey: string +} + +export type UndeployResponse = { + deployment: Deployment +} + +export type UnsupportedDslNode = { + id: string + type: string +} + +export type UpdateAccessChannelsRequest = { + appInstanceId?: string + webAppEnabled?: boolean + developerApiEnabled?: boolean +} + +export type UpdateAccessChannelsResponse = { + accessChannels: AccessChannels +} + +export type UpdateAccessPolicyRequest = { + appInstanceId?: string + environmentId?: string + mode: AccessMode + subjects?: Array +} + +export type UpdateAccessPolicyResponse = { + policy: AccessPolicy +} + +export type UpdateAppInstanceRequest = { + appInstanceId?: string + displayName: string + description?: string +} + +export type UpdateAppInstanceResponse = { + appInstance: AppInstance +} + +export type UpdateEnvironmentRequest = { + environmentId?: string + displayName: string + description?: string +} + +export type UpdateEnvironmentResponse = { + environment?: Environment +} + +export type UpdateReleaseRequest = { + releaseId?: string + displayName: string + description?: string +} + +export type UpdateReleaseResponse = { + release: Release +} + export type Account = { id?: string email?: string @@ -68,7 +1113,7 @@ export type BrandingInfo = { export type CheckPasswordStatusReply = { requirePasswordChange?: boolean - changeReason?: number + changeReason?: PasswordChangeReason daysToExpire?: number message?: string } @@ -185,7 +1230,7 @@ export type DeleteWorkspaceReply = { } export type EndpointReply = { - mode?: number + mode?: OtelEndpointMode metricsEndpoint?: OtelExporterEndpoint tracesEndpoint?: OtelExporterEndpoint } @@ -196,6 +1241,14 @@ export type EnterpriseSystemUserSettingReply = { enableEmailPasswordLogin?: boolean } +export type ExternallyAccessibleApp = { + appId?: string + tenantId?: string + mode?: string + name?: string + updatedAt?: string +} + export type GetBearerTokenResponse = { maskedToken?: string } @@ -280,7 +1333,7 @@ export type GroupAppItem = { app_name?: string workspace_id?: string workspace_name?: string - app_status?: number + app_status?: AppStatus token_usage?: string rpm?: string concurrency?: string @@ -304,6 +1357,7 @@ export type InfoConfigReply = { Branding?: BrandingInfo WebAppAuth?: WebAppAuthInfo PluginInstallationPermission?: PluginInstallationPermissionInfo + EnableAppDeploy?: boolean } export type InnerAdmission = { @@ -355,6 +1409,19 @@ export type InnerIsUserAllowedToAccessWebAppRes = { result?: boolean } +export type InnerListExternallyAccessibleAppsReq = { + page?: number + limit?: number + mode?: string + name?: string +} + +export type InnerListExternallyAccessibleAppsRes = { + data?: Array + total?: number + hasMore?: boolean +} + export type InnerReleaseAdmissionRequest = { admission?: InnerAdmission } @@ -411,15 +1478,21 @@ export type LicenseStatus = { } export type LimitConfig = { - type?: number + type?: LimitType threshold?: string - action?: number + action?: LimitAction reached?: boolean } export type LimitFields = { workspaceMembers?: number workspaces?: ResourceQuota + appRunnerEnvCpus?: ResourceQuota +} + +export type ListAccessSubjectsReply = { + subjects?: Array + pagination?: Pagination } export type ListGroupAppsResponse = { @@ -442,6 +1515,11 @@ export type ListSecretKeysReply = { pagination?: Pagination } +export type ListTraceProvidersReply = { + providers?: Array + catalog?: Array +} + export type ListUsersReply = { data?: Array pagination?: Pagination @@ -531,7 +1609,7 @@ export type OidcReply = { export type OtelExporterEndpoint = { endpoint?: string compression?: string - protocol?: number + protocol?: 'HTTP_PROTOBUF' | 'HTTP_JSON' | 'GRPC' timeout?: string headers?: { [key: string]: string @@ -549,7 +1627,7 @@ export type OtelExporterStatusReply = { bytesPushed?: string itemsInQueue?: string logs?: string - status?: number + status?: 'RUNNING' | 'ERROR' | 'STOPPED' } export type PasswordPolicyConfig = { @@ -565,7 +1643,7 @@ export type PasswordPolicyConfig = { } export type PasswordStrengthReply = { - level?: number + level?: PasswordStrengthLevel } export type PasswordStrengthReq = { @@ -578,7 +1656,7 @@ export type PluginInstallationPermissionInfo = { } export type PluginInstallationSettingsReply = { - pluginInstallationScope?: number + pluginInstallationScope?: PluginInstallationScope restrictToMarketplaceOnly?: boolean } @@ -616,11 +1694,11 @@ export type ResourceGroupDetail = { description?: string enabled?: boolean rpm_limit?: number - rpm_action?: number + rpm_action?: LimitAction concurrency_limit?: number - concurrency_action?: number + concurrency_action?: LimitAction token_quota?: string - token_action?: number + token_action?: LimitAction created_at?: string updated_at?: string } @@ -635,8 +1713,8 @@ export type ResourceGroupItem = { token_quota?: string token_usage?: string app_count?: string - rpm_status?: number - conc_status?: number + rpm_status?: LimitStatus + conc_status?: LimitStatus created_at?: string updated_at?: string } @@ -684,7 +1762,7 @@ export type SearchAppItem = { app_name?: string workspace_id?: string workspace_name?: string - app_status?: number + app_status?: AppStatus icon?: string icon_type?: string icon_background?: string @@ -720,7 +1798,7 @@ export type SetDefaultWorkspaceReq = { export type Subject = { subjectId?: string - subjectType?: string + subjectType?: SubjectType accountData?: SubjectAccountData groupData?: SubjectGroupData } @@ -753,10 +1831,50 @@ export type TestConnectionReply = { error?: string } +export type TestTraceProviderRequest = { + provider?: TraceProvider +} + export type ToggleEndpointRequest = { enabled?: boolean } +export type ToggleTraceProviderRequest = { + id?: string + enabled?: boolean +} + +export type TraceProvider = { + id?: string + name?: string + provider?: string + endpoint?: string + protocol?: string + credentials?: { + [key: string]: string + } + settings?: { + [key: string]: string + } + enabled?: boolean +} + +export type TraceProviderDescriptor = { + provider?: string + displayName?: string + credentialFields?: Array + settingFields?: Array + defaultProtocol?: string + supportedProtocols?: Array +} + +export type TraceProviderField = { + key?: string + displayName?: string + required?: boolean + secret?: boolean +} + export type UpdateAccessModeReq = { appId?: string accessMode?: string @@ -850,7 +1968,7 @@ export type UpdateOfflineLicenseReq = { } export type UpdatePluginInstallationSettingsRequest = { - pluginInstallationScope?: number + pluginInstallationScope?: PluginInstallationScope restrictToMarketplaceOnly?: boolean } @@ -860,11 +1978,11 @@ export type UpdateResourceGroupRequest = { description?: string enabled?: boolean rpm_limit?: number - rpm_action?: number + rpm_action?: LimitAction concurrency_limit?: number - concurrency_action?: number + concurrency_action?: LimitAction token_quota?: string - token_action?: number + token_action?: LimitAction } export type UpdateUserReply = { @@ -919,6 +2037,14 @@ export type UpdateWorkspaceReq = { status?: string } +export type UpsertTraceProviderReply = { + provider?: TraceProvider +} + +export type UpsertTraceProviderRequest = { + provider?: TraceProvider +} + export type WebAppAuthInfo = { allowSso?: boolean allowEmailCodeLogin?: boolean @@ -956,6 +2082,15 @@ export type WorkspacePermission = { allowOwnerTransfer?: boolean } +export type CursorPagination = { + pageSize?: number + nextCursor?: string + prevCursor?: string + hasNextPage?: boolean + hasPrevPage?: boolean + totalCount?: string +} + export type Pagination = { totalCount?: number perPage?: number @@ -963,6 +2098,633 @@ export type Pagination = { totalPages?: number } +export type AccessSubjectServiceListAccessSubjectsData = { + body?: never + path?: never + query?: { + keyword?: string + groupId?: string + pageNumber?: number + resultsPerPage?: number + } + url: '/enterprise/access-subjects' +} + +export type AccessSubjectServiceListAccessSubjectsResponses = { + 200: ListAccessSubjectsReply +} + +export type AccessSubjectServiceListAccessSubjectsResponse + = AccessSubjectServiceListAccessSubjectsResponses[keyof AccessSubjectServiceListAccessSubjectsResponses] + +export type AppInstanceServiceListAppInstanceSummariesData = { + body?: never + path?: never + query?: { + pageNumber?: number + resultsPerPage?: number + displayName?: string + environmentId?: string + } + url: '/enterprise/app-deploy/appInstanceSummaries' +} + +export type AppInstanceServiceListAppInstanceSummariesResponses = { + 200: ListAppInstanceSummariesResponse +} + +export type AppInstanceServiceListAppInstanceSummariesResponse + = AppInstanceServiceListAppInstanceSummariesResponses[keyof AppInstanceServiceListAppInstanceSummariesResponses] + +export type AppInstanceServiceListAppInstancesData = { + body?: never + path?: never + query?: { + pageNumber?: number + resultsPerPage?: number + displayName?: string + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances' +} + +export type AppInstanceServiceListAppInstancesResponses = { + 200: ListAppInstancesResponse +} + +export type AppInstanceServiceListAppInstancesResponse + = AppInstanceServiceListAppInstancesResponses[keyof AppInstanceServiceListAppInstancesResponses] + +export type AppInstanceServiceCreateAppInstanceData = { + body: CreateAppInstanceRequest + path?: never + query?: never + url: '/enterprise/app-deploy/appInstances' +} + +export type AppInstanceServiceCreateAppInstanceResponses = { + 200: CreateAppInstanceResponse +} + +export type AppInstanceServiceCreateAppInstanceResponse + = AppInstanceServiceCreateAppInstanceResponses[keyof AppInstanceServiceCreateAppInstanceResponses] + +export type AppInstanceServiceDeleteAppInstanceData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}' +} + +export type AppInstanceServiceDeleteAppInstanceResponses = { + 200: DeleteAppInstanceResponse +} + +export type AppInstanceServiceDeleteAppInstanceResponse + = AppInstanceServiceDeleteAppInstanceResponses[keyof AppInstanceServiceDeleteAppInstanceResponses] + +export type AppInstanceServiceGetAppInstanceData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}' +} + +export type AppInstanceServiceGetAppInstanceResponses = { + 200: GetAppInstanceResponse +} + +export type AppInstanceServiceGetAppInstanceResponse + = AppInstanceServiceGetAppInstanceResponses[keyof AppInstanceServiceGetAppInstanceResponses] + +export type AppInstanceServiceUpdateAppInstanceData = { + body: UpdateAppInstanceRequest + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}' +} + +export type AppInstanceServiceUpdateAppInstanceResponses = { + 200: UpdateAppInstanceResponse +} + +export type AppInstanceServiceUpdateAppInstanceResponse + = AppInstanceServiceUpdateAppInstanceResponses[keyof AppInstanceServiceUpdateAppInstanceResponses] + +export type AccessServiceGetAccessChannelsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels' +} + +export type AccessServiceGetAccessChannelsResponses = { + 200: GetAccessChannelsResponse +} + +export type AccessServiceGetAccessChannelsResponse + = AccessServiceGetAccessChannelsResponses[keyof AccessServiceGetAccessChannelsResponses] + +export type AccessServiceUpdateAccessChannelsData = { + body: UpdateAccessChannelsRequest + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels' +} + +export type AccessServiceUpdateAccessChannelsResponses = { + 200: UpdateAccessChannelsResponse +} + +export type AccessServiceUpdateAccessChannelsResponse + = AccessServiceUpdateAccessChannelsResponses[keyof AccessServiceUpdateAccessChannelsResponses] + +export type AccessServiceGetAccessSettingsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessSettings' +} + +export type AccessServiceGetAccessSettingsResponses = { + 200: GetAccessSettingsResponse +} + +export type AccessServiceGetAccessSettingsResponse + = AccessServiceGetAccessSettingsResponses[keyof AccessServiceGetAccessSettingsResponses] + +export type DeploymentServiceListDeploymentsData = { + body?: never + path: { + appInstanceId: string + } + query?: { + pageNumber?: number + resultsPerPage?: number + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/deployments' +} + +export type DeploymentServiceListDeploymentsResponses = { + 200: ListDeploymentsResponse +} + +export type DeploymentServiceListDeploymentsResponse + = DeploymentServiceListDeploymentsResponses[keyof DeploymentServiceListDeploymentsResponses] + +export type AccessServiceGetDeveloperApiSettingsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/developerApiSettings' +} + +export type AccessServiceGetDeveloperApiSettingsResponses = { + 200: GetDeveloperApiSettingsResponse +} + +export type AccessServiceGetDeveloperApiSettingsResponse + = AccessServiceGetDeveloperApiSettingsResponses[keyof AccessServiceGetDeveloperApiSettingsResponses] + +export type DeploymentServiceListEnvironmentDeploymentsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environmentDeployments' +} + +export type DeploymentServiceListEnvironmentDeploymentsResponses = { + 200: ListEnvironmentDeploymentsResponse +} + +export type DeploymentServiceListEnvironmentDeploymentsResponse + = DeploymentServiceListEnvironmentDeploymentsResponses[keyof DeploymentServiceListEnvironmentDeploymentsResponses] + +export type AccessServiceGetAccessPolicyData = { + body?: never + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy' +} + +export type AccessServiceGetAccessPolicyResponses = { + 200: GetAccessPolicyResponse +} + +export type AccessServiceGetAccessPolicyResponse + = AccessServiceGetAccessPolicyResponses[keyof AccessServiceGetAccessPolicyResponses] + +export type AccessServiceUpdateAccessPolicyData = { + body: UpdateAccessPolicyRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy' +} + +export type AccessServiceUpdateAccessPolicyResponses = { + 200: UpdateAccessPolicyResponse +} + +export type AccessServiceUpdateAccessPolicyResponse + = AccessServiceUpdateAccessPolicyResponses[keyof AccessServiceUpdateAccessPolicyResponses] + +export type AccessServiceListApiKeysData = { + body?: never + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys' +} + +export type AccessServiceListApiKeysResponses = { + 200: ListApiKeysResponse +} + +export type AccessServiceListApiKeysResponse + = AccessServiceListApiKeysResponses[keyof AccessServiceListApiKeysResponses] + +export type AccessServiceCreateApiKeyData = { + body: CreateApiKeyRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys' +} + +export type AccessServiceCreateApiKeyResponses = { + 200: CreateApiKeyResponse +} + +export type AccessServiceCreateApiKeyResponse + = AccessServiceCreateApiKeyResponses[keyof AccessServiceCreateApiKeyResponses] + +export type AccessServiceDeleteApiKeyData = { + body?: never + path: { + appInstanceId: string + environmentId: string + apiKeyId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys/{apiKeyId}' +} + +export type AccessServiceDeleteApiKeyResponses = { + 200: DeleteApiKeyResponse +} + +export type AccessServiceDeleteApiKeyResponse + = AccessServiceDeleteApiKeyResponses[keyof AccessServiceDeleteApiKeyResponses] + +export type DeploymentServiceListRollbackTargetsData = { + body?: never + path: { + appInstanceId: string + environmentId: string + } + query?: { + pageNumber?: number + resultsPerPage?: number + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/rollbackTargets' +} + +export type DeploymentServiceListRollbackTargetsResponses = { + 200: ListRollbackTargetsResponse +} + +export type DeploymentServiceListRollbackTargetsResponse + = DeploymentServiceListRollbackTargetsResponses[keyof DeploymentServiceListRollbackTargetsResponses] + +export type DeploymentServiceCancelDeploymentData = { + body: CancelDeploymentRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:cancelDeployment' +} + +export type DeploymentServiceCancelDeploymentResponses = { + 200: CancelDeploymentResponse +} + +export type DeploymentServiceCancelDeploymentResponse + = DeploymentServiceCancelDeploymentResponses[keyof DeploymentServiceCancelDeploymentResponses] + +export type DeploymentServicePromoteData = { + body: PromoteRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:promote' +} + +export type DeploymentServicePromoteResponses = { + 200: PromoteResponse +} + +export type DeploymentServicePromoteResponse + = DeploymentServicePromoteResponses[keyof DeploymentServicePromoteResponses] + +export type DeploymentServiceRollbackData = { + body: RollbackRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:rollback' +} + +export type DeploymentServiceRollbackResponses = { + 200: RollbackResponse +} + +export type DeploymentServiceRollbackResponse + = DeploymentServiceRollbackResponses[keyof DeploymentServiceRollbackResponses] + +export type DeploymentServiceUndeployData = { + body: UndeployRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:undeploy' +} + +export type DeploymentServiceUndeployResponses = { + 200: UndeployResponse +} + +export type DeploymentServiceUndeployResponse + = DeploymentServiceUndeployResponses[keyof DeploymentServiceUndeployResponses] + +export type ReleaseServiceListReleaseSummariesData = { + body?: never + path: { + appInstanceId: string + } + query?: { + releaseId?: string + displayName?: string + pageNumber?: number + resultsPerPage?: number + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/releaseSummaries' +} + +export type ReleaseServiceListReleaseSummariesResponses = { + 200: ListReleaseSummariesResponse +} + +export type ReleaseServiceListReleaseSummariesResponse + = ReleaseServiceListReleaseSummariesResponses[keyof ReleaseServiceListReleaseSummariesResponses] + +export type ReleaseServiceListReleasesData = { + body?: never + path: { + appInstanceId: string + } + query?: { + releaseId?: string + displayName?: string + pageNumber?: number + resultsPerPage?: number + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/releases' +} + +export type ReleaseServiceListReleasesResponses = { + 200: ListReleasesResponse +} + +export type ReleaseServiceListReleasesResponse + = ReleaseServiceListReleasesResponses[keyof ReleaseServiceListReleasesResponses] + +export type ReleaseServiceComputeReleaseDeploymentViewData = { + body?: never + path: { + appInstanceId: string + } + query?: { + releaseId?: string + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}:computeReleaseDeploymentView' +} + +export type ReleaseServiceComputeReleaseDeploymentViewResponses = { + 200: ComputeReleaseDeploymentViewResponse +} + +export type ReleaseServiceComputeReleaseDeploymentViewResponse + = ReleaseServiceComputeReleaseDeploymentViewResponses[keyof ReleaseServiceComputeReleaseDeploymentViewResponses] + +export type AppInstanceServiceGetAppInstanceOverviewData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}:getOverview' +} + +export type AppInstanceServiceGetAppInstanceOverviewResponses = { + 200: GetAppInstanceOverviewResponse +} + +export type AppInstanceServiceGetAppInstanceOverviewResponse + = AppInstanceServiceGetAppInstanceOverviewResponses[keyof AppInstanceServiceGetAppInstanceOverviewResponses] + +export type DeploymentServiceDeployData = { + body: DeployRequest + path?: never + query?: never + url: '/enterprise/app-deploy/appInstances:deploy' +} + +export type DeploymentServiceDeployResponses = { + 200: DeployResponse +} + +export type DeploymentServiceDeployResponse + = DeploymentServiceDeployResponses[keyof DeploymentServiceDeployResponses] + +export type EnvironmentServiceListEnvironmentsData = { + body?: never + path?: never + query?: { + environmentId?: string + displayName?: string + pageNumber?: number + resultsPerPage?: number + } + url: '/enterprise/app-deploy/environments' +} + +export type EnvironmentServiceListEnvironmentsResponses = { + 200: ListEnvironmentsResponse +} + +export type EnvironmentServiceListEnvironmentsResponse + = EnvironmentServiceListEnvironmentsResponses[keyof EnvironmentServiceListEnvironmentsResponses] + +export type ReleaseServiceCreateReleaseData = { + body: CreateReleaseRequest + path?: never + query?: never + url: '/enterprise/app-deploy/releases' +} + +export type ReleaseServiceCreateReleaseResponses = { + 200: CreateReleaseResponse +} + +export type ReleaseServiceCreateReleaseResponse + = ReleaseServiceCreateReleaseResponses[keyof ReleaseServiceCreateReleaseResponses] + +export type ReleaseServiceDeleteReleaseData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}' +} + +export type ReleaseServiceDeleteReleaseResponses = { + 200: DeleteReleaseResponse +} + +export type ReleaseServiceDeleteReleaseResponse + = ReleaseServiceDeleteReleaseResponses[keyof ReleaseServiceDeleteReleaseResponses] + +export type ReleaseServiceGetReleaseData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}' +} + +export type ReleaseServiceGetReleaseResponses = { + 200: GetReleaseResponse +} + +export type ReleaseServiceGetReleaseResponse + = ReleaseServiceGetReleaseResponses[keyof ReleaseServiceGetReleaseResponses] + +export type ReleaseServiceUpdateReleaseData = { + body: UpdateReleaseRequest + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}' +} + +export type ReleaseServiceUpdateReleaseResponses = { + 200: UpdateReleaseResponse +} + +export type ReleaseServiceUpdateReleaseResponse + = ReleaseServiceUpdateReleaseResponses[keyof ReleaseServiceUpdateReleaseResponses] + +export type ReleaseServiceExportReleaseDslData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}:exportDsl' +} + +export type ReleaseServiceExportReleaseDslResponses = { + 200: ExportReleaseDslResponse +} + +export type ReleaseServiceExportReleaseDslResponse + = ReleaseServiceExportReleaseDslResponses[keyof ReleaseServiceExportReleaseDslResponses] + +export type ReleaseServiceListReleaseCredentialCandidatesData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}:listCredentialCandidates' +} + +export type ReleaseServiceListReleaseCredentialCandidatesResponses = { + 200: ListReleaseCredentialCandidatesResponse +} + +export type ReleaseServiceListReleaseCredentialCandidatesResponse + = ReleaseServiceListReleaseCredentialCandidatesResponses[keyof ReleaseServiceListReleaseCredentialCandidatesResponses] + +export type ReleaseServiceComputeDeploymentOptionsData = { + body: ComputeDeploymentOptionsRequest + path?: never + query?: never + url: '/enterprise/app-deploy/releases:computeDeploymentOptions' +} + +export type ReleaseServiceComputeDeploymentOptionsResponses = { + 200: ComputeDeploymentOptionsResponse +} + +export type ReleaseServiceComputeDeploymentOptionsResponse + = ReleaseServiceComputeDeploymentOptionsResponses[keyof ReleaseServiceComputeDeploymentOptionsResponses] + +export type ReleaseServicePrecheckReleaseData = { + body: PrecheckReleaseRequest + path?: never + query?: never + url: '/enterprise/app-deploy/releases:precheck' +} + +export type ReleaseServicePrecheckReleaseResponses = { + 200: PrecheckReleaseResponse +} + +export type ReleaseServicePrecheckReleaseResponse + = ReleaseServicePrecheckReleaseResponses[keyof ReleaseServicePrecheckReleaseResponses] + export type ConsoleSsoOAuth2LoginData = { body?: never path?: never diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index cef500a9064..8e4251b2095 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -2,6 +2,975 @@ import * as z from 'zod' +export const zAccessMode = z.enum([ + 'ACCESS_MODE_UNSPECIFIED', + 'ACCESS_MODE_PUBLIC', + 'ACCESS_MODE_PRIVATE', + 'ACCESS_MODE_PRIVATE_ALL', +]) + +export const zSubjectType = z.enum([ + 'SUBJECT_TYPE_UNSPECIFIED', + 'SUBJECT_TYPE_ACCOUNT', + 'SUBJECT_TYPE_GROUP', +]) + +export const zAppRunnerLogStatus = z.enum([ + 'APP_RUNNER_LOG_STATUS_UNSPECIFIED', + 'APP_RUNNER_LOG_STATUS_RUNNING', + 'APP_RUNNER_LOG_STATUS_SUCCEEDED', + 'APP_RUNNER_LOG_STATUS_FAILED', + 'APP_RUNNER_LOG_STATUS_PARTIAL_SUCCEEDED', +]) + +export const zAssignmentOperation = z.enum([ + 'ASSIGNMENT_OPERATION_UNSPECIFIED', + 'ASSIGNMENT_OPERATION_LOAD', + 'ASSIGNMENT_OPERATION_UNLOAD', +]) + +export const zEnvironmentMode = z.enum([ + 'ENVIRONMENT_MODE_UNSPECIFIED', + 'ENVIRONMENT_MODE_SHARED', + 'ENVIRONMENT_MODE_ISOLATED', +]) + +export const zRuntimeBackend = z.enum([ + 'RUNTIME_BACKEND_UNSPECIFIED', + 'RUNTIME_BACKEND_K8S', + 'RUNTIME_BACKEND_EXTERNAL', +]) + +export const zPluginCategory = z.enum([ + 'PLUGIN_CATEGORY_UNSPECIFIED', + 'PLUGIN_CATEGORY_MODEL', + 'PLUGIN_CATEGORY_TOOL', +]) + +export const zDeploymentStatus = z.enum([ + 'DEPLOYMENT_STATUS_UNSPECIFIED', + 'DEPLOYMENT_STATUS_DEPLOYING', + 'DEPLOYMENT_STATUS_READY', + 'DEPLOYMENT_STATUS_FAILED', + 'DEPLOYMENT_STATUS_CANCELLED', +]) + +export const zDeploymentAction = z.enum([ + 'DEPLOYMENT_ACTION_UNSPECIFIED', + 'DEPLOYMENT_ACTION_DEPLOY', + 'DEPLOYMENT_ACTION_PROMOTE', + 'DEPLOYMENT_ACTION_ROLLBACK', + 'DEPLOYMENT_ACTION_UNDEPLOY', +]) + +export const zDeveloperApiUrlStatus = z.enum([ + 'DEVELOPER_API_URL_STATUS_UNSPECIFIED', + 'DEVELOPER_API_URL_STATUS_CONFIGURED', + 'DEVELOPER_API_URL_STATUS_NOT_CONFIGURED', +]) + +export const zEnvVarValueSource = z.enum([ + 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', + 'ENV_VAR_VALUE_SOURCE_LITERAL', + 'ENV_VAR_VALUE_SOURCE_DSL_DEFAULT', + 'ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT', +]) + +export const zEnvVarValueType = z.enum([ + 'ENV_VAR_VALUE_TYPE_UNSPECIFIED', + 'ENV_VAR_VALUE_TYPE_STRING', + 'ENV_VAR_VALUE_TYPE_NUMBER', + 'ENV_VAR_VALUE_TYPE_SECRET', +]) + +export const zEnvironmentStatus = z.enum([ + 'ENVIRONMENT_STATUS_UNSPECIFIED', + 'ENVIRONMENT_STATUS_ADMISSION', + 'ENVIRONMENT_STATUS_BOOTSTRAPPING', + 'ENVIRONMENT_STATUS_READY', + 'ENVIRONMENT_STATUS_FAILED', + 'ENVIRONMENT_STATUS_DELETING', +]) + +export const zRuntimeInstanceStatus = z.enum([ + 'RUNTIME_INSTANCE_STATUS_UNSPECIFIED', + 'RUNTIME_INSTANCE_STATUS_UNDEPLOYED', + 'RUNTIME_INSTANCE_STATUS_DEPLOYING', + 'RUNTIME_INSTANCE_STATUS_READY', + 'RUNTIME_INSTANCE_STATUS_FAILED', + 'RUNTIME_INSTANCE_STATUS_DRIFTED', + 'RUNTIME_INSTANCE_STATUS_INVALID', + 'RUNTIME_INSTANCE_STATUS_UNDEPLOYING', +]) + +export const zAppRunnerLaunchProfileMode = z.enum([ + 'APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED', + 'APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG', +]) + +export const zOperatorType = z.enum([ + 'OPERATOR_TYPE_UNSPECIFIED', + 'OPERATOR_TYPE_END_USER', + 'OPERATOR_TYPE_ACCOUNT', + 'OPERATOR_TYPE_SERVICE_ACCOUNT', + 'OPERATOR_TYPE_SYSTEM', +]) + +export const zReleaseSource = z.enum([ + 'RELEASE_SOURCE_UNSPECIFIED', + 'RELEASE_SOURCE_SOURCE_APP', + 'RELEASE_SOURCE_UPLOAD', +]) + +export const zReleaseEnvironmentActionKind = z.enum([ + 'RELEASE_ENVIRONMENT_ACTION_KIND_UNSPECIFIED', + 'RELEASE_ENVIRONMENT_ACTION_KIND_PROMOTE', + 'RELEASE_ENVIRONMENT_ACTION_KIND_ROLLBACK', + 'RELEASE_ENVIRONMENT_ACTION_KIND_CURRENT', + 'RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING', + 'RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED', +]) + +export const zAckStatus = z.enum([ + 'ACK_STATUS_UNSPECIFIED', + 'ACK_STATUS_READY', + 'ACK_STATUS_FAILED', +]) + +export const zSlotType = z.enum([ + 'SLOT_TYPE_UNSPECIFIED', + 'SLOT_TYPE_PLUGIN_CREDENTIAL', + 'SLOT_TYPE_ENV_VAR', +]) + +export const zRouteTargetKind = z.enum([ + 'ROUTE_TARGET_KIND_UNSPECIFIED', + 'ROUTE_TARGET_KIND_K8S_SERVICE', + 'ROUTE_TARGET_KIND_DIRECT_UPSTREAM', +]) + +export const zPasswordChangeReason = z.enum([ + 'PASSWORD_CHANGE_REASON_UNSPECIFIED', + 'PASSWORD_CHANGE_REASON_TEMP', + 'PASSWORD_CHANGE_REASON_EXPIRED', + 'PASSWORD_CHANGE_REASON_POLICY', +]) + +export const zOtelEndpointMode = z.enum([ + 'OTEL_ENDPOINT_MODE_UNIFIED', + 'OTEL_ENDPOINT_MODE_DEDICATED', +]) + +export const zAppStatus = z.enum([ + 'APP_STATUS_UNSPECIFIED', + 'APP_STATUS_PUBLISHED', + 'APP_STATUS_UNPUBLISHED', + 'APP_STATUS_DELETED', +]) + +export const zLimitType = z.enum([ + 'LIMIT_TYPE_UNSPECIFIED', + 'LIMIT_TYPE_RPM', + 'LIMIT_TYPE_CONCURRENCY', + 'LIMIT_TYPE_TOKEN', +]) + +export const zLimitAction = z.enum([ + 'LIMIT_ACTION_UNSPECIFIED', + 'LIMIT_ACTION_BLOCK', + 'LIMIT_ACTION_TRACK', +]) + +export const zPasswordStrengthLevel = z.enum([ + 'PASSWORD_STRENGTH_LEVEL_UNSPECIFIED', + 'PASSWORD_STRENGTH_LEVEL_WEAK', + 'PASSWORD_STRENGTH_LEVEL_MEDIUM', + 'PASSWORD_STRENGTH_LEVEL_STRONG', +]) + +export const zPluginInstallationScope = z.enum([ + 'PLUGIN_INSTALLATION_SCOPE_ALL', + 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_ONLY', + 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_AND_SPECIFIC_PARTNERS', + 'PLUGIN_INSTALLATION_SCOPE_NONE', +]) + +export const zLimitStatus = z.enum([ + 'LIMIT_STATUS_UNSPECIFIED', + 'LIMIT_STATUS_NA', + 'LIMIT_STATUS_NORMAL', + 'LIMIT_STATUS_THROTTLED', +]) + +export const zAccessSubject = z.object({ + subjectType: zSubjectType, + subjectId: z.string(), +}) + +export const zAccessPolicy = z.object({ + id: z.string(), + appInstanceId: z.string(), + environmentId: z.string(), + mode: zAccessMode, + subjects: z.array(zAccessSubject), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +export const zActor = z.object({ + id: z.string(), + displayName: z.string(), +}) + +export const zAccessChannels = z.object({ + id: z.string(), + appInstanceId: z.string(), + webAppEnabled: z.boolean(), + developerApiEnabled: z.boolean(), + updatedBy: zActor, + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +export const zApiKey = z.object({ + id: z.string(), + appInstanceId: z.string(), + environmentId: z.string(), + displayName: z.string(), + maskedToken: z.string(), + createdBy: zActor, + createdAt: z.iso.datetime(), + lastUsedAt: z.iso.datetime().optional(), +}) + +export const zAppInstance = z.object({ + id: z.string(), + tenantId: z.string(), + displayName: z.string(), + description: z.string(), + createdBy: zActor, + updatedBy: zActor, + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +/** + * BootstrapAssignment is one runtime_instance assignment in a runner's startup + * baseline. + */ +export const zBootstrapAssignment = z.object({ + appId: z.string().optional(), + environmentId: z.string().optional(), + workflowId: z.string().optional(), + runtimeInstanceId: z.string().optional(), + workspaceId: z.string().optional(), + runtimeInstanceVersion: z.string().optional(), + bindingSnapshotVersion: z.string().optional(), + executionTokenVersion: z.string().optional(), + executionToken: z.string().optional(), + releaseId: z.string().optional(), + operation: zAssignmentOperation.optional(), + deploymentId: z.string().optional(), + requiresStatusReport: z.boolean().optional(), +}) + +export const zBootstrapRunnerResponse = z.object({ + runnerId: z.string().optional(), + assignmentRevision: z.string().optional(), + assignments: z.array(zBootstrapAssignment).optional(), +}) + +export const zCancelDeploymentRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), +}) + +export const zComputeDeploymentOptionsRequest = z.object({ + environmentId: z.string().optional(), + appInstanceId: z.string().optional(), + dsl: z.string().optional(), + sourceAppId: z.string().optional(), + releaseId: z.string().optional(), +}) + +export const zCreateApiKeyRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + displayName: z.string(), +}) + +export const zCreateApiKeyResponse = z.object({ + apiKey: zApiKey, + token: z.string(), +}) + +export const zCreateAppInstanceRequest = z.object({ + displayName: z.string(), + description: z.string().optional(), +}) + +export const zCreateAppInstanceResponse = z.object({ + appInstance: zAppInstance, +}) + +export const zCreateReleaseRequest = z.object({ + createAppInstance: z.boolean().optional(), + appInstanceId: z.string().optional(), + displayName: z.string().optional(), + description: z.string().optional(), + dsl: z.string().optional(), + sourceAppId: z.string().optional(), +}) + +/** + * CredentialCandidate is one tenant-visible credential a frontend may + * pick for a credential slot. It carries no secret. + */ +export const zCredentialCandidate = z.object({ + credentialId: z.string(), + providerId: z.string(), + category: zPluginCategory, + displayName: z.string(), + fromEnterprise: z.boolean(), +}) + +/** + * CredentialSelectionInput is one deploy-time plugin-credential + * selection: a shared credential id chosen for a required DSL slot. + */ +export const zCredentialSelectionInput = z.object({ + providerId: z.string(), + category: zPluginCategory.optional(), + credentialId: z.string(), +}) + +/** + * CredentialSlot is one model/tool plugin-credential requirement a + * Release's DSL declares, paired with the candidates selectable for it. + */ +export const zCredentialSlot = z.object({ + providerId: z.string(), + category: zPluginCategory, + candidates: z.array(zCredentialCandidate), + lastCredentialId: z.string(), +}) + +export const zDeleteApiKeyResponse = z.record(z.string(), z.unknown()) + +export const zDeleteAppInstanceResponse = z.record(z.string(), z.unknown()) + +export const zDeleteEnvironmentResponse = z.record(z.string(), z.unknown()) + +export const zDeleteReleaseResponse = z.record(z.string(), z.unknown()) + +export const zDeploymentOptionsAppInstanceDefaults = z.object({ + displayName: z.string(), + description: z.string(), +}) + +export const zDeploymentOptionsReleaseDefaults = z.object({ + displayName: z.string(), + description: z.string(), +}) + +export const zEnvVarInput = z.object({ + key: z.string(), + value: z.string().optional(), + valueSource: zEnvVarValueSource.optional(), +}) + +export const zEnvVarSlot = z.object({ + key: z.string(), + valueType: zEnvVarValueType, + description: z.string(), + defaultValue: z.string().optional(), + lastValue: z.string().optional(), +}) + +export const zDeploymentOptions = z.object({ + dslDigest: z.string(), + appInstanceDefaults: zDeploymentOptionsAppInstanceDefaults, + releaseDefaults: zDeploymentOptionsReleaseDefaults, + credentialSlots: z.array(zCredentialSlot), + envVarSlots: z.array(zEnvVarSlot), +}) + +export const zComputeDeploymentOptionsResponse = z.object({ + options: zDeploymentOptions, +}) + +export const zEnvironmentDeploymentRecord = z.object({ + id: z.string(), + status: zDeploymentStatus, + createdAt: z.iso.datetime(), + finalizedAt: z.iso.datetime().optional(), +}) + +/** + * Error is the package-wide failure shape, carried wherever an operation or + * resource reports an error. + */ +export const zError = z.object({ + code: z.string().optional(), + message: z.string().optional(), + phase: z.string().optional(), + occurredAt: z.iso.datetime().optional(), +}) + +export const zDeveloperApiUrl = z.object({ + apiUrl: z.string(), + status: zDeveloperApiUrlStatus, + error: zError.optional(), +}) + +export const zApiKeySummary = z.object({ + apiKeyCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + environmentCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + developerApiEnabled: z.boolean(), + developerApiUrl: zDeveloperApiUrl, +}) + +export const zEnvironment = z.object({ + id: z.string(), + displayName: z.string(), + description: z.string(), + mode: zEnvironmentMode, + backend: zRuntimeBackend, + status: zEnvironmentStatus, + statusMessage: z.string(), + lastError: zError.optional(), + apiServer: z.string().optional(), + namespace: z.string().optional(), + managedBy: z.string().optional(), + runtimeEndpoint: z.string().optional(), + cpuCount: z.number(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +export const zAccessEndpoint = z.object({ + environment: zEnvironment.optional(), + endpointUrl: z.string(), +}) + +export const zCreateEnvironmentResponse = z.object({ + environment: zEnvironment.optional(), +}) + +export const zExchangeControlTokenRequest = z.object({ + joinToken: z.string().optional(), +}) + +export const zExchangeControlTokenResponse = z.object({ + accessToken: z.string().optional(), + expiresAt: z.iso.datetime().optional(), +}) + +export const zExportReleaseDslResponse = z.object({ + dsl: z.string(), +}) + +export const zExternalAppRunnerConfig = z.object({ + runtimeEndpoint: z.string().optional(), +}) + +export const zGenerateAppRunnerLaunchProfileRequest = z.object({ + environmentId: z.string().optional(), + mode: zAppRunnerLaunchProfileMode.optional(), + controlEndpoint: z.string(), + pluginDaemonBaseUrl: z.string(), + runtimeListenAddr: z.string(), + debugListenAddr: z.string().optional(), +}) + +export const zGenerateAppRunnerLaunchProfileResponse = z.object({ + environmentId: z.string().optional(), + joinToken: z.string().optional(), + configYaml: z.string().optional(), + runtimeEndpoint: z.string().optional(), + sourceCommands: z.array(z.string()).optional(), + dockerCommands: z.array(z.string()).optional(), +}) + +export const zGetAccessChannelsResponse = z.object({ + accessChannels: zAccessChannels, +}) + +export const zGetAccessPolicyResponse = z.object({ + policy: zAccessPolicy, +}) + +export const zGetAppInstanceResponse = z.object({ + appInstance: zAppInstance, +}) + +export const zGetDeveloperApiSettingsResponse = z.object({ + accessChannels: zAccessChannels, + environments: z.array(zEnvironment), + apiKeys: z.array(zApiKey), + developerApiUrl: zDeveloperApiUrl, +}) + +export const zGetEnvironmentResponse = z.object({ + environment: zEnvironment.optional(), +}) + +export const zK8sEnvironmentConfig = z.object({ + namespace: z.string().optional(), + apiServer: z.string().optional(), + caBundle: z.string().optional(), + bearerToken: z.string().optional(), +}) + +export const zCreateEnvironmentRequest = z.object({ + displayName: z.string(), + description: z.string().optional(), + mode: zEnvironmentMode.optional(), + backend: zRuntimeBackend.optional(), + k8s: zK8sEnvironmentConfig.optional(), + external: zExternalAppRunnerConfig.optional(), + cpuCount: z.number().optional(), + idempotencyKey: z.string(), +}) + +export const zListApiKeysResponse = z.object({ + apiKeys: z.array(zApiKey), + apiUrl: z.string(), +}) + +export const zListReleaseCredentialCandidatesResponse = z.object({ + slots: z.array(zCredentialSlot), +}) + +export const zNamedRef = z.object({ + id: z.string(), + displayName: z.string(), +}) + +export const zNewAppInstance = z.object({ + displayName: z.string().optional(), + description: z.string().optional(), +}) + +export const zDeployRequest = z.object({ + dsl: z.string().optional(), + sourceAppId: z.string().optional(), + newAppInstance: zNewAppInstance.optional(), + environmentId: z.string(), + releaseName: z.string().optional(), + releaseDescription: z.string().optional(), + credentials: z.array(zCredentialSelectionInput).optional(), + envVars: z.array(zEnvVarInput).optional(), + idempotencyKey: z.string(), + expectedDslDigest: z.string().optional(), +}) + +/** + * Operator is who triggered the run (the "END USER OR ACCOUNT" column). + */ +export const zOperator = z.object({ + type: zOperatorType, + id: z.string(), + displayName: z.string(), +}) + +export const zAppRunnerLog = z.object({ + id: z.string(), + timestamp: z.iso.datetime(), + workflowRunId: z.string(), + status: zAppRunnerLogStatus, + durationSeconds: z.number(), + totalTokens: z.string(), + workspace: zNamedRef, + environment: zNamedRef, + appInstance: zNamedRef, + operator: zOperator, + invokeFrom: z.string(), + traceId: z.string(), + difyTraceId: z.string(), + gateCommitId: z.string(), + body: z.string().optional(), + attributesJson: z.string().optional(), + resourceAttributesJson: z.string().optional(), +}) + +export const zGetAppRunnerLogResponse = z.object({ + appRunnerLog: zAppRunnerLog, + lastArchived: z.iso.datetime().optional(), +}) + +export const zPrecheckReleaseRequest = z.object({ + appInstanceId: z.string().optional(), + dsl: z.string().optional(), + sourceAppId: z.string().optional(), +}) + +export const zPromoteRequest = z.object({ + appInstanceId: z.string().optional(), + releaseId: z.string(), + environmentId: z.string().optional(), + credentials: z.array(zCredentialSelectionInput).optional(), + envVars: z.array(zEnvVarInput).optional(), + idempotencyKey: z.string(), +}) + +/** + * ReleaseContentMatch identifies an existing release whose DSL content is + * identical to the checked content. + */ +export const zReleaseContentMatch = z.object({ + releaseId: z.string(), + displayName: z.string(), + createdAt: z.iso.datetime(), +}) + +export const zReleaseEnvironmentAction = z.object({ + environment: zEnvironment, + kind: zReleaseEnvironmentActionKind, + disabledReason: z.string().optional(), + requiresRuntimeInputs: z.boolean(), + currentReleaseId: z.string(), +}) + +/** + * ReleaseEnvironmentDeployment is an environment where the release is the + * active deployment, paired with that environment's runtime status so the + * version history can show running vs failed vs deploying. + */ +export const zReleaseEnvironmentDeployment = z.object({ + environment: zEnvironment, + status: zRuntimeInstanceStatus, +}) + +export const zReportRuntimeAssignmentStatusRequest = z.object({ + deploymentId: z.string().optional(), + runtimeInstanceId: z.string().optional(), + releaseId: z.string().optional(), + status: zAckStatus.optional(), + lastError: zError.optional(), + runnerId: z.string().optional(), + assignmentRevision: z.string().optional(), +}) + +export const zReportRuntimeAssignmentStatusResponse = z.object({ + accepted: z.boolean().optional(), + stale: z.boolean().optional(), +}) + +/** + * RequiredSlot is an input requirement extracted from a Release's + * DSL. + */ +export const zRequiredSlot = z.object({ + type: zSlotType, + providerId: z.string(), + category: zPluginCategory, + key: z.string(), +}) + +export const zRelease = z.object({ + id: z.string(), + appInstanceId: z.string(), + displayName: z.string(), + description: z.string(), + source: zReleaseSource, + sourceAppId: z.string().optional(), + gateCommitId: z.string(), + requiredSlots: z.array(zRequiredSlot), + createdBy: zActor, + createdAt: z.iso.datetime(), +}) + +export const zCreateReleaseResponse = z.object({ + release: zRelease, + appInstance: zAppInstance, +}) + +export const zDeployment = z.object({ + id: z.string(), + appInstanceId: z.string(), + status: zDeploymentStatus, + action: zDeploymentAction, + environment: zEnvironment, + release: zRelease, + error: zError.optional(), + createdBy: zActor, + createdAt: z.iso.datetime(), + finalizedAt: z.iso.datetime().optional(), +}) + +export const zCancelDeploymentResponse = z.object({ + deployment: zDeployment, +}) + +export const zDeployResponse = z.object({ + appInstance: zAppInstance, + release: zRelease, + deployment: zDeployment, +}) + +/** + * EnvironmentAppInstance is one app instance as seen from a single environment: + * its current release, runtime status, and derived last error in THIS env. + */ +export const zEnvironmentAppInstance = z.object({ + appInstance: zAppInstance.optional(), + currentRelease: zRelease.optional(), + status: zRuntimeInstanceStatus.optional(), + lastError: zError.optional(), + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), +}) + +export const zEnvironmentDeployment = z.object({ + appInstanceId: z.string(), + environment: zEnvironment, + status: zRuntimeInstanceStatus, + currentRelease: zRelease.optional(), + desiredRelease: zRelease.optional(), + currentDeployment: zEnvironmentDeploymentRecord.optional(), + error: zError.optional(), + updatedAt: z.iso.datetime(), + releasesBehind: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +export const zAppInstanceSummary = z.object({ + appInstance: zAppInstance, + environmentDeployments: z.array(zEnvironmentDeployment), + latestRelease: zRelease.optional(), + accessChannels: zAccessChannels, + apiKeySummary: zApiKeySummary, +}) + +export const zComputeReleaseDeploymentViewResponse = z.object({ + releases: z.array(zRelease), + environmentDeployments: z.array(zEnvironmentDeployment), + environmentActions: z.array(zReleaseEnvironmentAction), + options: zDeploymentOptions.optional(), +}) + +/** + * EnvironmentDeploymentHistoryItem is one deployment row in an environment's + * history, with a thin reference to the owning app instance. + */ +export const zEnvironmentDeploymentHistoryItem = z.object({ + deployment: zDeployment.optional(), + appInstanceId: z.string().optional(), + appInstanceName: z.string().optional(), + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), +}) + +export const zGetAppInstanceOverviewResponse = z.object({ + appInstance: zAppInstance, + environmentDeployments: z.array(zEnvironmentDeployment), + recentReleases: z.array(zRelease), + accessChannels: zAccessChannels, + apiKeySummary: zApiKeySummary, + totalReleaseCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), +}) + +export const zGetReleaseResponse = z.object({ + release: zRelease, +}) + +export const zListEnvironmentDeploymentsResponse = z.object({ + environmentDeployments: z.array(zEnvironmentDeployment), +}) + +export const zPromoteResponse = z.object({ + deployment: zDeployment, +}) + +export const zReleaseSummary = z.object({ + release: zRelease, + deployedEnvironments: z.array(zReleaseEnvironmentDeployment), + environmentActions: z.array(zReleaseEnvironmentAction), + activeEnvironmentCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), +}) + +export const zResolveApiTokenRouteRequest = z.object({ + token: z.string().optional(), +}) + +export const zResolveApiTokenRouteResponse = z.object({ + environmentId: z.string().optional(), + namespace: z.string().optional(), + serviceName: z.string().optional(), + servicePort: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentStatus: zEnvironmentStatus.optional(), + appId: z.string().optional(), + tenantId: z.string().optional(), + runtimeInstanceId: z.string().optional(), + observedReleaseId: z.string().optional(), + runtimeInstanceStatus: zRuntimeInstanceStatus.optional(), + revoked: z.boolean().optional(), + unavailableReason: z.string().optional(), + targetKind: zRouteTargetKind.optional(), + directUpstream: z.string().optional(), +}) + +export const zRollbackRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + targetReleaseId: z.string(), + idempotencyKey: z.string(), +}) + +export const zRollbackResponse = z.object({ + deployment: zDeployment, +}) + +export const zRollbackTarget = z.object({ + release: zRelease, + resolvedDeploymentId: z.string(), + deployedAt: z.iso.datetime(), + isCurrent: z.boolean(), +}) + +export const zRunnerInfo = z.object({ + hostname: z.string().optional(), +}) + +export const zBootstrapRunnerRequest = z.object({ + runner: zRunnerInfo.optional(), +}) + +export const zRuntimeArtifact = z.object({ + dslYaml: z.string().optional(), + bindingSnapshotVersion: z.string().optional(), + bindingSnapshot: z.record(z.string(), z.unknown()).optional(), +}) + +export const zRuntimeArtifactRequest = z.object({ + runtimeInstanceId: z.string().optional(), + releaseId: z.string().optional(), + deploymentId: z.string().optional(), + bindingSnapshotVersion: z.string().optional(), +}) + +export const zBatchResolveRuntimeArtifactsRequest = z.object({ + requests: z.array(zRuntimeArtifactRequest).optional(), +}) + +export const zRuntimeArtifactResult = z.object({ + runtimeInstanceId: z.string().optional(), + releaseId: z.string().optional(), + artifact: zRuntimeArtifact.optional(), + error: zError.optional(), + deploymentId: z.string().optional(), +}) + +export const zBatchResolveRuntimeArtifactsResponse = z.object({ + results: z.array(zRuntimeArtifactResult).optional(), +}) + +export const zTestConnectionRequest = z.object({ + environmentId: z.string().optional(), +}) + +export const zTestConnectionResponse = z.object({ + reachable: z.boolean().optional(), + message: z.string().optional(), +}) + +export const zUndeployRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + idempotencyKey: z.string(), +}) + +export const zUndeployResponse = z.object({ + deployment: zDeployment, +}) + +/** + * UnsupportedDslNode identifies a workflow node whose type the app runner + * cannot execute. + */ +export const zUnsupportedDslNode = z.object({ + id: z.string(), + type: z.string(), +}) + +export const zPrecheckReleaseResponse = z.object({ + gateCommitId: z.string(), + canCreate: z.boolean(), + matchedRelease: zReleaseContentMatch.optional(), + unsupportedNodes: z.array(zUnsupportedDslNode), +}) + +export const zUpdateAccessChannelsRequest = z.object({ + appInstanceId: z.string().optional(), + webAppEnabled: z.boolean().optional(), + developerApiEnabled: z.boolean().optional(), +}) + +export const zUpdateAccessChannelsResponse = z.object({ + accessChannels: zAccessChannels, +}) + +export const zUpdateAccessPolicyRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + mode: zAccessMode, + subjects: z.array(zAccessSubject).optional(), +}) + +export const zUpdateAccessPolicyResponse = z.object({ + policy: zAccessPolicy, +}) + +export const zUpdateAppInstanceRequest = z.object({ + appInstanceId: z.string().optional(), + displayName: z.string(), + description: z.string().optional(), +}) + +export const zUpdateAppInstanceResponse = z.object({ + appInstance: zAppInstance, +}) + +export const zUpdateEnvironmentRequest = z.object({ + environmentId: z.string().optional(), + displayName: z.string(), + description: z.string().optional(), +}) + +export const zUpdateEnvironmentResponse = z.object({ + environment: zEnvironment.optional(), +}) + +export const zUpdateReleaseRequest = z.object({ + releaseId: z.string().optional(), + displayName: z.string(), + description: z.string().optional(), +}) + +export const zUpdateReleaseResponse = z.object({ + release: zRelease, +}) + /** * Account represents a basic user account */ @@ -52,7 +1021,7 @@ export const zBrandingInfo = z.object({ export const zCheckPasswordStatusReply = z.object({ requirePasswordChange: z.boolean().optional(), - changeReason: z.int().optional(), + changeReason: zPasswordChangeReason.optional(), daysToExpire: z .int() .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) @@ -178,6 +1147,14 @@ export const zEnterpriseSystemUserSettingReply = z.object({ enableEmailPasswordLogin: z.boolean().optional(), }) +export const zExternallyAccessibleApp = z.object({ + appId: z.string().optional(), + tenantId: z.string().optional(), + mode: z.string().optional(), + name: z.string().optional(), + updatedAt: z.string().optional(), +}) + export const zGetBearerTokenResponse = z.object({ maskedToken: z.string().optional(), }) @@ -228,7 +1205,7 @@ export const zGroupAppItem = z.object({ app_name: z.string().optional(), workspace_id: z.string().optional(), workspace_name: z.string().optional(), - app_status: z.int().optional(), + app_status: zAppStatus.optional(), token_usage: z.string().optional(), rpm: z.string().optional(), concurrency: z.string().optional(), @@ -277,6 +1254,31 @@ export const zInnerIsUserAllowedToAccessWebAppRes = z.object({ result: z.boolean().optional(), }) +export const zInnerListExternallyAccessibleAppsReq = z.object({ + page: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + mode: z.string().optional(), + name: z.string().optional(), +}) + +export const zInnerListExternallyAccessibleAppsRes = z.object({ + data: z.array(zExternallyAccessibleApp).optional(), + total: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + hasMore: z.boolean().optional(), +}) + export const zInnerReleaseAdmissionRequest = z.object({ admission: zInnerAdmission.optional(), }) @@ -314,9 +1316,9 @@ export const zJoinWorkspaceReq = z.object({ }) export const zLimitConfig = z.object({ - type: z.int().optional(), + type: zLimitType.optional(), threshold: z.string().optional(), - action: z.int().optional(), + action: zLimitAction.optional(), reached: z.boolean().optional(), }) @@ -424,7 +1426,7 @@ export const zOidcReply = z.object({ export const zOtelExporterEndpoint = z.object({ endpoint: z.string().optional(), compression: z.string().optional(), - protocol: z.int().optional(), + protocol: z.enum(['HTTP_PROTOBUF', 'HTTP_JSON', 'GRPC']).optional(), timeout: z .string() .regex(/^-?(?:0|[1-9]\d{0,11})(?:\.\d{1,9})?s$/) @@ -439,7 +1441,7 @@ export const zOtelExporterEndpoint = z.object({ }) export const zEndpointReply = z.object({ - mode: z.int().optional(), + mode: zOtelEndpointMode.optional(), metricsEndpoint: zOtelExporterEndpoint.optional(), tracesEndpoint: zOtelExporterEndpoint.optional(), }) @@ -449,7 +1451,7 @@ export const zOtelExporterStatusReply = z.object({ bytesPushed: z.string().optional(), itemsInQueue: z.string().optional(), logs: z.string().optional(), - status: z.int().optional(), + status: z.enum(['RUNNING', 'ERROR', 'STOPPED']).optional(), }) export const zPasswordPolicyConfig = z.object({ @@ -473,7 +1475,7 @@ export const zPasswordPolicyConfig = z.object({ }) export const zPasswordStrengthReply = z.object({ - level: z.int().optional(), + level: zPasswordStrengthLevel.optional(), }) export const zPasswordStrengthReq = z.object({ @@ -486,7 +1488,7 @@ export const zPluginInstallationPermissionInfo = z.object({ }) export const zPluginInstallationSettingsReply = z.object({ - pluginInstallationScope: z.int().optional(), + pluginInstallationScope: zPluginInstallationScope.optional(), restrictToMarketplaceOnly: z.boolean().optional(), }) @@ -534,15 +1536,15 @@ export const zResourceGroupDetail = z.object({ .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - rpm_action: z.int().optional(), + rpm_action: zLimitAction.optional(), concurrency_limit: z .int() .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - concurrency_action: z.int().optional(), + concurrency_action: zLimitAction.optional(), token_quota: z.string().optional(), - token_action: z.int().optional(), + token_action: zLimitAction.optional(), created_at: z.string().optional(), updated_at: z.string().optional(), }) @@ -565,8 +1567,8 @@ export const zResourceGroupItem = z.object({ token_quota: z.string().optional(), token_usage: z.string().optional(), app_count: z.string().optional(), - rpm_status: z.int().optional(), - conc_status: z.int().optional(), + rpm_status: zLimitStatus.optional(), + conc_status: zLimitStatus.optional(), created_at: z.string().optional(), updated_at: z.string().optional(), }) @@ -606,6 +1608,7 @@ export const zLimitFields = z.object({ .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), workspaces: zResourceQuota.optional(), + appRunnerEnvCpus: zResourceQuota.optional(), }) /** @@ -693,7 +1696,7 @@ export const zSearchAppItem = z.object({ app_name: z.string().optional(), workspace_id: z.string().optional(), workspace_name: z.string().optional(), - app_status: z.int().optional(), + app_status: zAppStatus.optional(), icon: z.string().optional(), icon_type: z.string().optional(), icon_background: z.string().optional(), @@ -760,11 +1763,24 @@ export const zGetWebAppWhitelistSubjectsRes = z.object({ */ export const zSubject = z.object({ subjectId: z.string().optional(), - subjectType: z.string().optional(), + subjectType: zSubjectType.optional(), accountData: zSubjectAccountData.optional(), groupData: zSubjectGroupData.optional(), }) +export const zEnvironmentAccessPolicy = z.object({ + environment: zEnvironment, + policy: zAccessPolicy.optional(), + resolvedSubjects: z.array(zSubject), +}) + +export const zGetAccessSettingsResponse = z.object({ + accessChannels: zAccessChannels, + environmentPolicies: z.array(zEnvironmentAccessPolicy), + webAppEndpoints: z.array(zAccessEndpoint).optional(), + cliEndpoint: zAccessEndpoint.optional(), +}) + export const zGetGroupSubjectsRes = z.object({ subjects: z.array(zSubject).optional(), }) @@ -798,6 +1814,72 @@ export const zToggleEndpointRequest = z.object({ enabled: z.boolean().optional(), }) +export const zToggleTraceProviderRequest = z.object({ + id: z.string().optional(), + enabled: z.boolean().optional(), +}) + +/** + * TraceProvider is one configured trace-export destination. Trace data + * collected by the enterprise collector is fanned out to every enabled + * destination. Credentials carries per-provider secret values (e.g. Langfuse + * public/secret keys); Settings carries non-secret options (e.g. host, + * project). Secret credential values are redacted on read and preserved on + * write when the client echoes the redaction sentinel back (same round-trip + * contract as endpoint headers / TLS keys). + */ +export const zTraceProvider = z.object({ + id: z.string().optional(), + name: z.string().optional(), + provider: z.string().optional(), + endpoint: z.string().optional(), + protocol: z.string().optional(), + credentials: z.record(z.string(), z.string()).optional(), + settings: z.record(z.string(), z.string()).optional(), + enabled: z.boolean().optional(), +}) + +/** + * TestTraceProviderRequest tests connectivity/auth for a destination config + * before (or without) persisting it. When credentials carry the redaction + * sentinel, the stored secret for the destination with the same id is used. + */ +export const zTestTraceProviderRequest = z.object({ + provider: zTraceProvider.optional(), +}) + +/** + * TraceProviderField describes one credential or setting field of a provider in + * the static catalog: the key the dashboard sends back, its display label, + * whether it is required, and (for credentials) whether it is a secret that + * gets redacted. + */ +export const zTraceProviderField = z.object({ + key: z.string().optional(), + displayName: z.string().optional(), + required: z.boolean().optional(), + secret: z.boolean().optional(), +}) + +/** + * TraceProviderDescriptor is one entry in the static provider catalog: the + * field definitions and transport defaults for a provider type. The dashboard + * uses it to render the add/edit form and to know which fields are secret. + */ +export const zTraceProviderDescriptor = z.object({ + provider: z.string().optional(), + displayName: z.string().optional(), + credentialFields: z.array(zTraceProviderField).optional(), + settingFields: z.array(zTraceProviderField).optional(), + defaultProtocol: z.string().optional(), + supportedProtocols: z.array(z.string()).optional(), +}) + +export const zListTraceProvidersReply = z.object({ + providers: z.array(zTraceProvider).optional(), + catalog: z.array(zTraceProviderDescriptor).optional(), +}) + export const zUpdateAccessModeReq = z.object({ appId: z.string().optional(), accessMode: z.string().optional(), @@ -894,7 +1976,7 @@ export const zUpdateOfflineLicenseReq = z.object({ }) export const zUpdatePluginInstallationSettingsRequest = z.object({ - pluginInstallationScope: z.int().optional(), + pluginInstallationScope: zPluginInstallationScope.optional(), restrictToMarketplaceOnly: z.boolean().optional(), }) @@ -908,15 +1990,15 @@ export const zUpdateResourceGroupRequest = z.object({ .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - rpm_action: z.int().optional(), + rpm_action: zLimitAction.optional(), concurrency_limit: z .int() .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - concurrency_action: z.int().optional(), + concurrency_action: zLimitAction.optional(), token_quota: z.string().optional(), - token_action: z.int().optional(), + token_action: zLimitAction.optional(), }) export const zUpdateUserReply = z.object({ @@ -963,6 +2045,18 @@ export const zUpdateWorkspaceReq = z.object({ status: z.string().optional(), }) +export const zUpsertTraceProviderReply = z.object({ + provider: zTraceProvider.optional(), +}) + +/** + * UpsertTraceProviderRequest creates a destination when id is empty (a new id + * is allocated) or updates the destination with the given id otherwise. + */ +export const zUpsertTraceProviderRequest = z.object({ + provider: zTraceProvider.optional(), +}) + export const zWebAppAuthInfo = z.object({ allowSso: z.boolean().optional(), allowEmailCodeLogin: z.boolean().optional(), @@ -985,6 +2079,7 @@ export const zInfoConfigReply = z.object({ Branding: zBrandingInfo.optional(), WebAppAuth: zWebAppAuthInfo.optional(), PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(), + EnableAppDeploy: z.boolean().optional(), }) export const zWebOAuth2LoginReply = z.object({ @@ -1058,6 +2153,28 @@ export const zUpdateWorkspacePermissionReq = z.object({ permission: zWorkspacePermission.optional(), }) +/** + * CursorPagination: pagination by cursor token + */ +export const zCursorPagination = z.object({ + pageSize: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + nextCursor: z.string().optional(), + prevCursor: z.string().optional(), + hasNextPage: z.boolean().optional(), + hasPrevPage: z.boolean().optional(), + totalCount: z.string().optional(), +}) + +export const zListAppRunnerLogsResponse = z.object({ + appRunnerLogs: z.array(zAppRunnerLog), + pagination: zCursorPagination, + lastArchived: z.iso.datetime().optional(), +}) + /** * Pagination : Just for pagination by page */ @@ -1084,6 +2201,61 @@ export const zPagination = z.object({ .optional(), }) +export const zDashboardListAppInstancesResponse = z.object({ + appInstances: z.array(zAppInstance), + pagination: zPagination, +}) + +export const zDashboardListEnvironmentDeploymentsResponse = z.object({ + deployments: z.array(zEnvironmentDeploymentHistoryItem).optional(), + pagination: zPagination.optional(), +}) + +export const zListAppInstanceSummariesResponse = z.object({ + appInstanceSummaries: z.array(zAppInstanceSummary), + pagination: zPagination, +}) + +export const zListAppInstancesResponse = z.object({ + appInstances: z.array(zAppInstance), + pagination: zPagination, +}) + +export const zListDeploymentsResponse = z.object({ + deployments: z.array(zDeployment), + pagination: zPagination, +}) + +export const zListEnvironmentAppInstancesResponse = z.object({ + appInstances: z.array(zEnvironmentAppInstance).optional(), + pagination: zPagination.optional(), +}) + +export const zListEnvironmentsResponse = z.object({ + environments: z.array(zEnvironment), + pagination: zPagination, +}) + +export const zListReleaseSummariesResponse = z.object({ + releaseSummaries: z.array(zReleaseSummary), + pagination: zPagination, +}) + +export const zListReleasesResponse = z.object({ + releases: z.array(zRelease), + pagination: zPagination, +}) + +export const zListRollbackTargetsResponse = z.object({ + rollbackTargets: z.array(zRollbackTarget), + pagination: zPagination, +}) + +export const zListAccessSubjectsReply = z.object({ + subjects: z.array(zSubject).optional(), + pagination: zPagination.optional(), +}) + export const zListMembersReply = z.object({ data: z.array(zAccountDetail).optional(), pagination: zPagination.optional(), @@ -1104,6 +2276,469 @@ export const zListWorkspacesReply = z.object({ pagination: zPagination.optional(), }) +export const zAccessSubjectServiceListAccessSubjectsQuery = z.object({ + keyword: z.string().optional(), + groupId: z.string().optional(), + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +/** + * OK + */ +export const zAccessSubjectServiceListAccessSubjectsResponse = zListAccessSubjectsReply + +export const zAppInstanceServiceListAppInstanceSummariesQuery = z.object({ + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + displayName: z.string().optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zAppInstanceServiceListAppInstanceSummariesResponse = zListAppInstanceSummariesResponse + +export const zAppInstanceServiceListAppInstancesQuery = z.object({ + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + displayName: z.string().optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zAppInstanceServiceListAppInstancesResponse = zListAppInstancesResponse + +export const zAppInstanceServiceCreateAppInstanceBody = zCreateAppInstanceRequest + +/** + * OK + */ +export const zAppInstanceServiceCreateAppInstanceResponse = zCreateAppInstanceResponse + +export const zAppInstanceServiceDeleteAppInstancePath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceDeleteAppInstanceResponse = zDeleteAppInstanceResponse + +export const zAppInstanceServiceGetAppInstancePath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceGetAppInstanceResponse = zGetAppInstanceResponse + +export const zAppInstanceServiceUpdateAppInstanceBody = zUpdateAppInstanceRequest + +export const zAppInstanceServiceUpdateAppInstancePath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceUpdateAppInstanceResponse = zUpdateAppInstanceResponse + +export const zAccessServiceGetAccessChannelsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetAccessChannelsResponse = zGetAccessChannelsResponse + +export const zAccessServiceUpdateAccessChannelsBody = zUpdateAccessChannelsRequest + +export const zAccessServiceUpdateAccessChannelsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceUpdateAccessChannelsResponse = zUpdateAccessChannelsResponse + +export const zAccessServiceGetAccessSettingsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetAccessSettingsResponse = zGetAccessSettingsResponse + +export const zDeploymentServiceListDeploymentsPath = z.object({ + appInstanceId: z.string(), +}) + +export const zDeploymentServiceListDeploymentsQuery = z.object({ + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zDeploymentServiceListDeploymentsResponse = zListDeploymentsResponse + +export const zAccessServiceGetDeveloperApiSettingsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetDeveloperApiSettingsResponse = zGetDeveloperApiSettingsResponse + +export const zDeploymentServiceListEnvironmentDeploymentsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceListEnvironmentDeploymentsResponse + = zListEnvironmentDeploymentsResponse + +export const zAccessServiceGetAccessPolicyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetAccessPolicyResponse = zGetAccessPolicyResponse + +export const zAccessServiceUpdateAccessPolicyBody = zUpdateAccessPolicyRequest + +export const zAccessServiceUpdateAccessPolicyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceUpdateAccessPolicyResponse = zUpdateAccessPolicyResponse + +export const zAccessServiceListApiKeysPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceListApiKeysResponse = zListApiKeysResponse + +export const zAccessServiceCreateApiKeyBody = zCreateApiKeyRequest + +export const zAccessServiceCreateApiKeyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceCreateApiKeyResponse = zCreateApiKeyResponse + +export const zAccessServiceDeleteApiKeyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), + apiKeyId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceDeleteApiKeyResponse = zDeleteApiKeyResponse + +export const zDeploymentServiceListRollbackTargetsPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +export const zDeploymentServiceListRollbackTargetsQuery = z.object({ + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +/** + * OK + */ +export const zDeploymentServiceListRollbackTargetsResponse = zListRollbackTargetsResponse + +export const zDeploymentServiceCancelDeploymentBody = zCancelDeploymentRequest + +export const zDeploymentServiceCancelDeploymentPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceCancelDeploymentResponse = zCancelDeploymentResponse + +export const zDeploymentServicePromoteBody = zPromoteRequest + +export const zDeploymentServicePromotePath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServicePromoteResponse = zPromoteResponse + +export const zDeploymentServiceRollbackBody = zRollbackRequest + +export const zDeploymentServiceRollbackPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceRollbackResponse = zRollbackResponse + +export const zDeploymentServiceUndeployBody = zUndeployRequest + +export const zDeploymentServiceUndeployPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceUndeployResponse = zUndeployResponse + +export const zReleaseServiceListReleaseSummariesPath = z.object({ + appInstanceId: z.string(), +}) + +export const zReleaseServiceListReleaseSummariesQuery = z.object({ + releaseId: z.string().optional(), + displayName: z.string().optional(), + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zReleaseServiceListReleaseSummariesResponse = zListReleaseSummariesResponse + +export const zReleaseServiceListReleasesPath = z.object({ + appInstanceId: z.string(), +}) + +export const zReleaseServiceListReleasesQuery = z.object({ + releaseId: z.string().optional(), + displayName: z.string().optional(), + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zReleaseServiceListReleasesResponse = zListReleasesResponse + +export const zReleaseServiceComputeReleaseDeploymentViewPath = z.object({ + appInstanceId: z.string(), +}) + +export const zReleaseServiceComputeReleaseDeploymentViewQuery = z.object({ + releaseId: z.string().optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zReleaseServiceComputeReleaseDeploymentViewResponse + = zComputeReleaseDeploymentViewResponse + +export const zAppInstanceServiceGetAppInstanceOverviewPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceGetAppInstanceOverviewResponse = zGetAppInstanceOverviewResponse + +export const zDeploymentServiceDeployBody = zDeployRequest + +/** + * OK + */ +export const zDeploymentServiceDeployResponse = zDeployResponse + +export const zEnvironmentServiceListEnvironmentsQuery = z.object({ + environmentId: z.string().optional(), + displayName: z.string().optional(), + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +/** + * OK + */ +export const zEnvironmentServiceListEnvironmentsResponse = zListEnvironmentsResponse + +export const zReleaseServiceCreateReleaseBody = zCreateReleaseRequest + +/** + * OK + */ +export const zReleaseServiceCreateReleaseResponse = zCreateReleaseResponse + +export const zReleaseServiceDeleteReleasePath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceDeleteReleaseResponse = zDeleteReleaseResponse + +export const zReleaseServiceGetReleasePath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceGetReleaseResponse = zGetReleaseResponse + +export const zReleaseServiceUpdateReleaseBody = zUpdateReleaseRequest + +export const zReleaseServiceUpdateReleasePath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceUpdateReleaseResponse = zUpdateReleaseResponse + +export const zReleaseServiceExportReleaseDslPath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceExportReleaseDslResponse = zExportReleaseDslResponse + +export const zReleaseServiceListReleaseCredentialCandidatesPath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceListReleaseCredentialCandidatesResponse + = zListReleaseCredentialCandidatesResponse + +export const zReleaseServiceComputeDeploymentOptionsBody = zComputeDeploymentOptionsRequest + +/** + * OK + */ +export const zReleaseServiceComputeDeploymentOptionsResponse = zComputeDeploymentOptionsResponse + +export const zReleaseServicePrecheckReleaseBody = zPrecheckReleaseRequest + +/** + * OK + */ +export const zReleaseServicePrecheckReleaseResponse = zPrecheckReleaseResponse + /** * OK */ diff --git a/packages/contracts/openapi-ts.enterprise.config.ts b/packages/contracts/openapi-ts.enterprise.config.ts index 3c9bc903ab4..aae8dcb215b 100644 --- a/packages/contracts/openapi-ts.enterprise.config.ts +++ b/packages/contracts/openapi-ts.enterprise.config.ts @@ -7,9 +7,36 @@ import yaml from 'js-yaml' type JsonObject = Record type OpenApiDocument = JsonObject & { + components?: OpenApiComponents paths?: Record } +type OpenApiComponents = JsonObject & { + schemas?: Record +} + +type OpenApiMediaType = JsonObject & { + schema?: unknown +} + +type OpenApiOperation = JsonObject & { + operationId?: string + responses?: Record +} + +type OpenApiPathItem = Record + +type OpenApiResponse = JsonObject & { + content?: Record +} + +type OpenApiSchema = JsonObject & { + enum?: unknown[] + format?: string + properties?: Record + type?: string | string[] +} + type ContractOperation = { id: string operationId?: string @@ -21,9 +48,30 @@ const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER ? path.resolve(process.env.DIFY_ENTERPRISE_SERVER) : path.resolve(currentDir, '../../../dify-enterprise/server') const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml') +const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/') +const isObject = (value: unknown): value is JsonObject => { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +const isOpenApiSchema = (value: unknown): value is OpenApiSchema => { + return isObject(value) +} + +const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => { + return isObject(value) ? value as OpenApiOperation : undefined +} + +const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => { + return isObject(value) ? value as OpenApiResponse : undefined +} + +const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => { + return isObject(value) ? value as OpenApiMediaType : undefined +} + const stripConsoleApiPrefix = (routePath: string) => { if (isConsoleApiPath(routePath)) return routePath.replace('/console/api', '') @@ -34,9 +82,18 @@ const stripConsoleApiPrefix = (routePath: string) => { const stripSchemaNamePrefix = (schemaName: string) => { return schemaName .replace(/^dify\.enterprise\.api\.enterprise\./, '') + .replace(/^dify\.enterprise\.api\.appdeploy\.v1\./, '') + .replace(/^dify\.enterprise\.api\.appdeploy\./, '') .replace(/^pagination\./, '') } +const contractTagSegment = (tag?: string) => { + if (tag === 'EnterpriseAppDeployConsole') + return 'AppDeploy' + + return tag || 'default' +} + const contractNameSegments = (operation: ContractOperation) => { const operationId = operation.operationId || operation.id const tag = operation.tags?.[0] @@ -48,7 +105,165 @@ const contractNameSegments = (operation: ContractOperation) => { } const contractPathSegments = (operation: ContractOperation) => { - return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)] + return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)] +} + +const hasSchemaLessResponseContent = (operation: OpenApiOperation) => { + if (!isObject(operation.responses)) + return false + + return Object.values(operation.responses).some((response) => { + const openApiResponse = asOpenApiResponse(response) + if (!openApiResponse || !isObject(openApiResponse.content)) + return false + + return Object.values(openApiResponse.content).some((mediaType) => { + const openApiMediaType = asOpenApiMediaType(mediaType) + return !!openApiMediaType && !('schema' in openApiMediaType) + }) + }) +} + +// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these +// raw download operations until the source OpenAPI exposes an explicit schema. +const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => { + return Object.fromEntries( + Object.entries(pathItem).filter(([method, operation]) => { + if (!operationMethods.has(method.toLowerCase())) + return true + + const openApiOperation = asOpenApiOperation(operation) + return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation) + }), + ) +} + +const toWords = (value: string) => { + return value + .replace(/[{}]/g, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[^a-z0-9]+/i) + .filter(Boolean) +} + +const toPascalCase = (words: string[]) => { + return words.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('') +} + +const commonWordPrefix = (values: string[]) => { + const wordLists = values.map(value => value.split('_')) + const firstWords = wordLists[0] ?? [] + const prefix: string[] = [] + + for (const [index, word] of firstWords.entries()) { + if (!wordLists.every(words => words[index] === word)) + break + + prefix.push(word) + } + + return prefix +} + +const enumSchemaNameFromValues = (values: unknown[]) => { + if (values.length === 0 || !values.every(value => typeof value === 'string')) + return undefined + + const prefix = commonWordPrefix(values) + if (prefix.length < 2) + return undefined + + return toPascalCase(prefix.map(word => word.toLowerCase())) +} + +const findSchemaEntry = ( + schemas: Record, + schemaName: string, +): [string, OpenApiSchema] | undefined => { + return Object.entries(schemas) + .find(([name]) => stripSchemaNamePrefix(name) === schemaName) +} + +const enumValuesKey = (values: unknown[]) => JSON.stringify(values) + +const reusableEnumSchema = (propertySchema: OpenApiSchema): OpenApiSchema => ({ + ...(propertySchema.format ? { format: propertySchema.format } : {}), + enum: propertySchema.enum, + type: propertySchema.type ?? 'string', +}) + +const enumSchemaKey = ( + schemas: Record, + preferredName: string, + valuesKey: string, + valuesToSchemaKey: Map, + schemaName: string, + propertyName: string, +) => { + const existingKey = valuesToSchemaKey.get(valuesKey) + if (existingKey) + return existingKey + + const existingEnumEntry = findSchemaEntry(schemas, preferredName) + if (!existingEnumEntry) + return preferredName + + const existingEnumValues = existingEnumEntry[1].enum + if (Array.isArray(existingEnumValues) && enumValuesKey(existingEnumValues) === valuesKey) + return existingEnumEntry[0] + + return `${stripSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}` +} + +const promoteInlineEnumSchema = ( + schemas: Record, + schemaName: string, + properties: Record, + propertyName: string, + propertySchema: OpenApiSchema, + valuesToSchemaKey: Map, +) => { + if (!Array.isArray(propertySchema.enum)) + return + + const preferredName = enumSchemaNameFromValues(propertySchema.enum) + if (!preferredName) + return + + const valuesKey = enumValuesKey(propertySchema.enum) + const key = enumSchemaKey(schemas, preferredName, valuesKey, valuesToSchemaKey, schemaName, propertyName) + + if (!schemas[key]) + schemas[key] = reusableEnumSchema(propertySchema) + + valuesToSchemaKey.set(valuesKey, key) + properties[propertyName] = { + $ref: `#/components/schemas/${key}`, + } +} + +// gnostic's protoc-gen-openapi inlines proto enum schemas into every field. +// Promote prefixable inline enums to reusable schemas so Hey API can emit +// runtime enum objects from the generated contract. +const promoteReusableEnumSchemasForHeyApi = (document: OpenApiDocument) => { + const schemas = document.components?.schemas + if (!schemas) + return + + const valuesToSchemaKey = new Map() + + Object.entries(schemas).forEach(([schemaName, schema]) => { + const properties = schema.properties + if (!properties) + return + + Object.entries(properties).forEach(([propertyName, propertySchema]) => { + if (!isOpenApiSchema(propertySchema)) + return + + promoteInlineEnumSchema(schemas, schemaName, properties, propertyName, propertySchema, valuesToSchemaKey) + }) + }) } const normalizeEnterpriseOpenApi = () => { @@ -63,9 +278,17 @@ const normalizeEnterpriseOpenApi = () => { document.paths = Object.fromEntries( Object.entries(paths) .filter(([routePath]) => isConsoleApiPath(routePath)) - .map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]), + .map(([routePath, pathItem]) => { + if (!isObject(pathItem)) + return [stripConsoleApiPrefix(routePath), pathItem] + + return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)] + }) + .filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0), ) + promoteReusableEnumSchemasForHeyApi(document) + return document } @@ -97,6 +320,9 @@ export default defineConfig({ { name: '@hey-api/typescript', comments: false, + enums: { + mode: 'javascript', + }, }, 'zod', { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d189883bd0a..3932cb6d87a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ catalogs: '@tanstack/eslint-plugin-query': specifier: 5.101.0 version: 5.101.0 + '@tanstack/query-core': + specifier: 5.101.0 + version: 5.101.0 '@tanstack/react-form': specifier: 1.33.0 version: 1.33.0 @@ -393,6 +396,12 @@ catalogs: jotai: specifier: 2.20.1 version: 2.20.1 + jotai-scope: + specifier: 0.11.0 + version: 0.11.0 + jotai-tanstack-query: + specifier: 0.11.0 + version: 0.11.0 js-audio-recorder: specifier: 1.0.7 version: 1.0.7 @@ -1096,6 +1105,9 @@ importers: '@tailwindcss/typography': specifier: 'catalog:' version: 0.5.20(tailwindcss@4.3.1) + '@tanstack/query-core': + specifier: 'catalog:' + version: 5.101.0 '@tanstack/react-form': specifier: 'catalog:' version: 1.33.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1189,6 +1201,12 @@ importers: jotai: specifier: 'catalog:' version: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) + jotai-scope: + specifier: 'catalog:' + version: 0.11.0(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + jotai-tanstack-query: + specifier: 'catalog:' + version: 0.11.0(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) js-audio-recorder: specifier: 'catalog:' version: 1.0.7 @@ -7198,6 +7216,26 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jotai-scope@0.11.0: + resolution: {integrity: sha512-ofiW0Z0i3lTw509Gx0+T6fqsDPMDxMn+AHmNs9iF9OA8CmK1/0xRprPxuZ89UZdBzt6jcrTYdunNZSF2255zJQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + jotai: '>=2.20.0' + react: '>=16.0.0' + + jotai-tanstack-query@0.11.0: + resolution: {integrity: sha512-Ys0u0IuuS6/okUJOulFTdCVfVaeKbm1+lKVSN9zHhIxtrAXl9FM4yu7fNvxM6fSz/NCE9tZOKR0MQ3hvplaH8A==} + peerDependencies: + '@tanstack/query-core': '*' + '@tanstack/react-query': '*' + jotai: '>=2.0.0' + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-query': + optional: true + react: + optional: true + jotai@2.20.1: resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==} engines: {node: '>=12.20.0'} @@ -16182,6 +16220,19 @@ snapshots: jiti@2.7.0: {} + jotai-scope@0.11.0(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + jotai: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + + jotai-tanstack-query@0.11.0(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + '@tanstack/query-core': 5.101.0 + jotai: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@tanstack/react-query': 5.101.0(react@19.2.7) + react: 19.2.7 + jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7): optionalDependencies: '@babel/core': 7.29.7 @@ -19321,6 +19372,7 @@ time: '@tailwindcss/typography@0.5.20': '2026-06-08T10:34:41.124Z' '@tailwindcss/vite@4.3.1': '2026-06-12T17:59:45.667Z' '@tanstack/eslint-plugin-query@5.101.0': '2026-06-02T19:24:31.866Z' + '@tanstack/query-core@5.101.0': '2026-06-02T19:24:32.202Z' '@tanstack/react-form@1.33.0': '2026-05-28T17:05:42.660Z' '@tanstack/react-hotkeys@0.10.0': '2026-04-25T12:28:06.989Z' '@tanstack/react-query@5.101.0': '2026-06-02T19:24:39.383Z' @@ -19397,6 +19449,8 @@ time: i18next@26.3.1: '2026-06-03T14:14:17.016Z' iconify-import-svg@0.2.0: '2026-04-20T06:18:25.132Z' immer@11.1.8: '2026-05-08T15:09:33.021Z' + jotai-scope@0.11.0: '2026-05-13T18:43:15.331Z' + jotai-tanstack-query@0.11.0: '2025-08-01T02:55:49.826Z' jotai@2.20.1: '2026-06-11T06:30:45.782Z' js-audio-recorder@1.0.7: '2021-01-09T10:20:49.923Z' js-cookie@3.0.8: '2026-05-29T10:51:39.065Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 313f2102ebf..8aaafed7267 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -106,6 +106,7 @@ catalog: '@tailwindcss/typography': 0.5.20 '@tailwindcss/vite': 4.3.1 '@tanstack/eslint-plugin-query': 5.101.0 + '@tanstack/query-core': 5.101.0 '@tanstack/react-form': 1.33.0 '@tanstack/react-hotkeys': 0.10.0 '@tanstack/react-query': 5.101.0 @@ -181,6 +182,8 @@ catalog: iconify-import-svg: 0.2.0 immer: 11.1.8 jotai: 2.20.1 + jotai-scope: 0.11.0 + jotai-tanstack-query: 0.11.0 js-audio-recorder: 1.0.7 js-cookie: 3.0.8 js-yaml: 4.2.0 diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx index e1284bfc5b5..415b48c5cad 100644 --- a/web/__tests__/app/app-access-control-flow.test.tsx +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import AppPublisher from '@/app/components/app/app-publisher' +import { AppPublisher } from '@/app/components/app/app-publisher' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' @@ -89,7 +89,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () })) vi.mock('@/app/components/app/app-access-control', () => ({ - default: ({ + AccessControl: ({ onConfirm, onClose, }: { diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index d598051f72c..593211ce9dd 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -1,12 +1,11 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import AppPublisher from '@/app/components/app/app-publisher' +import { AppPublisher } from '@/app/components/app/app-publisher' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' const mockTrackEvent = vi.fn() -const mockRefetch = vi.fn() const mockFetchInstalledAppList = vi.fn() const mockFetchAppDetailDirect = vi.fn() const mockToastError = vi.fn() @@ -64,7 +63,6 @@ vi.mock('@/service/access-control', () => ({ useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false, - refetch: mockRefetch, }), useAppWhiteListSubjects: () => ({ data: { groups: [], members: [] }, @@ -115,7 +113,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () })) vi.mock('@/app/components/app/app-access-control', () => ({ - default: () =>
, + AccessControl: () =>
, })) vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) @@ -182,8 +180,6 @@ describe('App Publisher Flow', () => { app_name: 'Demo App', })) }) - - expect(mockRefetch).toHaveBeenCalled() }) it('opens embedded modal and resolves the installed explore target', async () => { diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 5702755a5c8..67bd892b4ec 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -13,7 +13,7 @@ import type { App } from '@/types/app' import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import AppCard from '@/app/components/apps/app-card' +import { AppCard } from '@/app/components/apps/app-card' import { AccessMode } from '@/models/access-control' import { exportAppConfig, updateAppInfo } from '@/service/apps' import { AppModeEnum } from '@/types/app' @@ -64,10 +64,10 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) vi.mock('@/next/dynamic', () => ({ - default: (loader: () => Promise<{ default: React.ComponentType }>) => { + default: (loader: () => Promise) => { let Component: React.ComponentType> | null = null loader().then((mod) => { - Component = mod.default as React.ComponentType> + Component = (typeof mod === 'function' ? mod : mod.default) as React.ComponentType> }).catch(() => {}) const Wrapper = (props: Record) => { if (Component) @@ -201,7 +201,7 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ })) vi.mock('@/app/components/app/app-access-control', () => ({ - default: ({ onConfirm, onClose }: Record) => ( + AccessControl: ({ onConfirm, onClose }: Record) => (
diff --git a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx index 67b50f94096..f26723d0199 100644 --- a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx +++ b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx @@ -1,13 +1,16 @@ +import type { ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import RoleRouteGuard from '../role-route-guard' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { RoleRouteGuard } from '../role-route-guard' const mocks = vi.hoisted(() => ({ redirect: vi.fn((url: string) => { throw new Error(`NEXT_REDIRECT:${url}`) }), currentWorkspaceQueryOptions: vi.fn(() => ({ queryKey: ['console', 'workspaces', 'current', 'post'] })), + systemFeaturesQueryKey: vi.fn(() => ['console', 'systemFeatures', 'get']), })) let mockPathname = '/apps' @@ -27,6 +30,11 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { vi.mock('@/service/client', () => ({ consoleQuery: { + systemFeatures: { + get: { + queryKey: mocks.systemFeaturesQueryKey, + }, + }, workspaces: { current: { post: { @@ -39,6 +47,19 @@ vi.mock('@/service/client', () => ({ const mockUseQuery = vi.mocked(useQuery) +function renderGuard(children: ReactNode) { + return renderWithSystemFeatures( + + {children} + , + { + systemFeatures: { + enable_app_deploy: true, + }, + }, + ) +} + const setCurrentWorkspaceQuery = (overrides: { role?: string, isPending?: boolean } = {}) => { mockUseQuery.mockReturnValue({ data: overrides.role, @@ -56,11 +77,7 @@ describe('RoleRouteGuard', () => { it('should render loading while workspace is loading', () => { setCurrentWorkspaceQuery({ isPending: true }) - render(( - -
content
-
- )) + renderGuard(
content
) expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.queryByText('content')).not.toBeInTheDocument() @@ -73,11 +90,16 @@ describe('RoleRouteGuard', () => { it('should redirect dataset operator on guarded routes', () => { setCurrentWorkspaceQuery({ role: 'dataset_operator' }) - expect(() => render(( - -
content
-
- ))).toThrow('NEXT_REDIRECT:/datasets') + expect(() => renderGuard(
content
)).toThrow('NEXT_REDIRECT:/datasets') + + expect(mocks.redirect).toHaveBeenCalledWith('/datasets') + }) + + it('should redirect dataset operator on deployments routes', () => { + mockPathname = '/deployments/create' + setCurrentWorkspaceQuery({ role: 'dataset_operator' }) + + expect(() => renderGuard(
content
)).toThrow('NEXT_REDIRECT:/datasets') expect(mocks.redirect).toHaveBeenCalledWith('/datasets') }) @@ -86,11 +108,7 @@ describe('RoleRouteGuard', () => { mockPathname = '/plugins' setCurrentWorkspaceQuery({ role: 'dataset_operator' }) - render(( - -
content
-
- )) + renderGuard(
content
) expect(screen.getByText('content')).toBeInTheDocument() expect(mocks.redirect).not.toHaveBeenCalled() @@ -100,11 +118,7 @@ describe('RoleRouteGuard', () => { mockPathname = '/plugins' setCurrentWorkspaceQuery({ isPending: true }) - render(( - -
content
-
- )) + renderGuard(
content
) expect(screen.getByText('content')).toBeInTheDocument() expect(screen.queryByRole('status')).not.toBeInTheDocument() diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx new file mode 100644 index 00000000000..5fa2ff42250 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx @@ -0,0 +1,8 @@ +import { AccessTab } from '@/features/deployments/detail/access-tab' + +export default async function InstanceDetailAccessPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx new file mode 100644 index 00000000000..6fb643008ad --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx @@ -0,0 +1,8 @@ +import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab' + +export default async function InstanceDetailApiTokensPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx new file mode 100644 index 00000000000..d51bcfd5898 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx @@ -0,0 +1,8 @@ +import { DeployTab } from '@/features/deployments/detail/deploy-tab' + +export default async function InstanceDetailInstancesPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx new file mode 100644 index 00000000000..4e39b066201 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' +import { InstanceDetail } from '@/features/deployments/detail' + +export default async function InstanceDetailLayout({ children, params }: { + children: ReactNode + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + + return ( + + {children} + + ) +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx new file mode 100644 index 00000000000..7362d075a29 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx @@ -0,0 +1,8 @@ +import { OverviewTab } from '@/features/deployments/detail/overview-tab' + +export default async function InstanceDetailOverviewPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx new file mode 100644 index 00000000000..b0ae57a3a87 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from '@/next/navigation' + +export default async function InstanceDetailPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + redirect(`/deployments/${appInstanceId}/overview`) +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx new file mode 100644 index 00000000000..ee5e689cc65 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx @@ -0,0 +1,8 @@ +import { VersionsTab } from '@/features/deployments/detail/versions-tab' + +export default async function InstanceDetailReleasesPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/create/page.tsx b/web/app/(commonLayout)/deployments/create/page.tsx new file mode 100644 index 00000000000..1e85fbdffa7 --- /dev/null +++ b/web/app/(commonLayout)/deployments/create/page.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useTranslation } from 'react-i18next' +import { CreateDeploymentGuide } from '@/features/deployments/create-guide' +import useDocumentTitle from '@/hooks/use-document-title' + +export default function CreateDeploymentPage() { + const { t } = useTranslation('deployments') + useDocumentTitle(t('documentTitle.create')) + + return +} diff --git a/web/app/(commonLayout)/deployments/layout.tsx b/web/app/(commonLayout)/deployments/layout.tsx new file mode 100644 index 00000000000..eb522444778 --- /dev/null +++ b/web/app/(commonLayout)/deployments/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' +import { DeployDrawer } from '@/features/deployments/deploy-drawer' + +export default function DeploymentsLayout({ children }: { + children: ReactNode +}) { + return ( + <> + {children} + + + ) +} diff --git a/web/app/(commonLayout)/deployments/page.tsx b/web/app/(commonLayout)/deployments/page.tsx new file mode 100644 index 00000000000..753f0a1837f --- /dev/null +++ b/web/app/(commonLayout)/deployments/page.tsx @@ -0,0 +1,10 @@ +'use client' +import { useTranslation } from 'react-i18next' +import { DeploymentsList } from '@/features/deployments/list' +import useDocumentTitle from '@/hooks/use-document-title' + +export default function DeploymentsPage() { + const { t } = useTranslation('deployments') + useDocumentTitle(t('documentTitle.list')) + return +} diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index fa0ce21bbb8..4d6bdf7984c 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -16,9 +16,9 @@ import { ModalContextProvider } from '@/context/modal-context-provider' import { ProviderContextProvider } from '@/context/provider-context-provider' import PartnerStack from '../components/billing/partner-stack' import { CommonLayoutHydrationBoundary } from './hydration-boundary' -import RoleRouteGuard from './role-route-guard' +import { RoleRouteGuard } from './role-route-guard' -const Layout = async ({ children }: { children: ReactNode }) => { +export default async function Layout({ children }: { children: ReactNode }) { return ( <> @@ -49,4 +49,3 @@ const Layout = async ({ children }: { children: ReactNode }) => { ) } -export default Layout diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx index 482a13c5d26..05cf59aee42 100644 --- a/web/app/(commonLayout)/role-route-guard.tsx +++ b/web/app/(commonLayout)/role-route-guard.tsx @@ -1,28 +1,38 @@ 'use client' import type { ReactNode } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import Loading from '@/app/components/base/loading' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { redirect, usePathname } from '@/next/navigation' import { consoleQuery } from '@/service/client' -const datasetOperatorRedirectRoutes = ['/', '/apps', '/app', '/snippets', '/explore', '/tools', '/integrations'] as const +const datasetOperatorRedirectRoutes = ['/', '/apps', '/app', '/deployments', '/snippets', '/explore', '/tools', '/integrations'] as const -const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`) +function isPathUnderRoute(pathname: string, route: string) { + return pathname === route || pathname.startsWith(`${route}/`) +} -export default function RoleRouteGuard({ children }: { children: ReactNode }) { +export function RoleRouteGuard({ children }: { children: ReactNode }) { const currentWorkspaceRoleQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({ select: workspace => workspace.role, })) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const pathname = usePathname() const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route)) - const shouldRedirect = shouldGuardRoute && !currentWorkspaceRoleQuery.isPending && currentWorkspaceRoleQuery.data === 'dataset_operator' + const shouldRedirectDatasetOperator = shouldGuardRoute + && !currentWorkspaceRoleQuery.isPending + && currentWorkspaceRoleQuery.data === 'dataset_operator' + const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy // Block rendering only for guarded routes to avoid permission flicker. if (shouldGuardRoute && currentWorkspaceRoleQuery.isPending) return - if (shouldRedirect) + if (shouldRedirectAppDeploy) + redirect('/apps') + + if (shouldRedirectDatasetOperator) redirect('/datasets') return <>{children} diff --git a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx index 03a35bd52ab..acbdf1281e5 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import AccessControlDialog from '../access-control-dialog' +import { AccessControlDialog } from '../access-control-dialog' describe('AccessControlDialog', () => { it('should render dialog content when visible', () => { diff --git a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx index b1a862a13c9..0972a654751 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx @@ -1,45 +1,43 @@ import { fireEvent, render, screen } from '@testing-library/react' -import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' -import AccessControlItem from '../access-control-item' +import { AccessControlItem } from '../access-control-item' +import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness' +import { createAccessControlDraftHarness } from './access-control-test-utils' describe('AccessControlItem', () => { beforeEach(() => { vi.clearAllMocks() - useAccessControlStore.setState({ - appId: '', - specificGroups: [], - specificMembers: [], - currentMenu: AccessMode.PUBLIC, - selectedGroupsForBreadcrumb: [], - }) }) it('should update current menu when selecting a different access type', () => { - render( - - Organization Only - , + const harness = createAccessControlDraftHarness( + + + Organization Only + + , + { currentMenu: AccessMode.PUBLIC }, ) + render(harness.element) - const option = screen.getByText('Organization Only').parentElement as HTMLElement + const option = screen.getByRole('radio', { name: 'Organization Only' }) fireEvent.click(option) - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) + expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION) }) it('should keep the selected state for the active access type', () => { - useAccessControlStore.setState({ - currentMenu: AccessMode.ORGANIZATION, - }) - - render( - - Organization Only - , + const harness = createAccessControlDraftHarness( + + + Organization Only + + , + { currentMenu: AccessMode.ORGANIZATION }, ) + render(harness.element) - const option = screen.getByText('Organization Only').parentElement as HTMLElement - expect(option).toHaveClass('border-components-option-card-option-selected-border') + const option = screen.getByRole('radio', { name: 'Organization Only' }) + expect(option).toHaveAttribute('data-checked') }) }) diff --git a/web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx b/web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx new file mode 100644 index 00000000000..8dc6cbd75cb --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' +import type { AccessMode } from '@/models/access-control' +import { RadioGroup } from '@langgenius/dify-ui/radio-group' +import { useAccessControlStore } from '../store' + +export function AccessControlRadioGroupHarness({ children }: { + children: ReactNode +}) { + const currentMenu = useAccessControlStore(state => state.currentMenu) + const setCurrentMenu = useAccessControlStore(state => state.setCurrentMenu) + + return ( + value={currentMenu} onValueChange={setCurrentMenu}> + {children} + + ) +} diff --git a/web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts b/web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts new file mode 100644 index 00000000000..2a6f27b8331 --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react' +import type { AccessControlDraft, AccessControlStore } from '../store' +import { createElement } from 'react' +import { AccessMode } from '@/models/access-control' +import { useAccessControlStore } from '../store' +import { AccessControlDraftProvider } from '../store-provider' + +const emptyDraft = { + appId: '', + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + specificGroups: [], + specificMembers: [], + selectedGroupsForBreadcrumb: [], +} satisfies Required + +function draftKey(draft: AccessControlDraft) { + return [ + draft.appId ?? '', + draft.currentMenu, + draft.specificGroups?.map(group => group.id).join(',') ?? '', + draft.specificMembers?.map(member => member.id).join(',') ?? '', + draft.selectedGroupsForBreadcrumb?.map(group => group.id).join(',') ?? '', + ].join(':') +} + +function completeDraft(initialDraft: Partial = {}): Required { + return { + ...emptyDraft, + ...initialDraft, + } +} + +function SnapshotProbe({ onSnapshot }: { + onSnapshot: (snapshot: AccessControlStore) => void +}) { + onSnapshot(useAccessControlStore(state => state)) + return null +} + +export function createAccessControlDraftHarness( + children: ReactNode, + initialDraft?: Partial, +) { + const draft = completeDraft(initialDraft) + let snapshot: AccessControlStore = { + appId: draft.appId, + specificGroups: draft.specificGroups, + setSpecificGroups: () => undefined, + specificMembers: draft.specificMembers, + setSpecificMembers: () => undefined, + currentMenu: draft.currentMenu, + setCurrentMenu: () => undefined, + selectedGroupsForBreadcrumb: draft.selectedGroupsForBreadcrumb, + setSelectedGroupsForBreadcrumb: () => undefined, + } + + return { + element: createElement( + AccessControlDraftProvider, + { + draftKey: draftKey(draft), + initialDraft: draft, + }, + createElement(SnapshotProbe, { + onSnapshot: nextSnapshot => snapshot = nextSnapshot, + }), + children, + ), + getSnapshot: () => snapshot, + } +} diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 52c2a0dd543..1a8a181f68c 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -1,24 +1,23 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import type { App } from '@/types/app' +import { SubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' -import useAccessControlStore from '@/context/access-control-store' import { AccessMode, SubjectType } from '@/models/access-control' -import AccessControlDialog from '../access-control-dialog' -import AccessControlItem from '../access-control-item' -import AddMemberOrGroupDialog from '../add-member-or-group-pop' -import AccessControl from '../index' -import SpecificGroupsOrMembers from '../specific-groups-or-members' +import { AccessControlDialog } from '../access-control-dialog' +import { AccessControlItem } from '../access-control-item' +import { AddMemberOrGroupDialog } from '../add-member-or-group-pop' +import { AccessControl } from '../index' +import { SpecificGroupsOrMembers } from '../specific-groups-or-members' +import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness' +import { createAccessControlDraftHarness } from './access-control-test-utils' const mockUseAppWhiteListSubjects = vi.fn() const mockUseSearchForWhiteListCandidates = vi.fn() -const mockMutateAsync = vi.fn() -const mockUseUpdateAccessMode = vi.fn(() => ({ - isPending: false, - mutateAsync: mockMutateAsync, -})) +const mockMutate = vi.fn() +const mockUseMutation = vi.hoisted(() => vi.fn()) const intersectionObserverMocks = vi.hoisted(() => ({ callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void), })) @@ -39,9 +38,16 @@ vi.mock('@/context/app-context', () => ({ vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), - useUpdateAccessMode: () => mockUseUpdateAccessMode(), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useMutation: (...args: unknown[]) => mockUseMutation(...args), + } +}) + vi.mock('ahooks', async (importOriginal) => { const actual = await importOriginal() return { @@ -94,10 +100,12 @@ beforeAll(() => { }) beforeEach(() => { - mockMutateAsync.mockResolvedValue(undefined) - mockUseUpdateAccessMode.mockReturnValue({ + mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + mockUseMutation.mockReturnValue({ isPending: false, - mutateAsync: mockMutateAsync, + mutate: mockMutate, }) mockUseAppWhiteListSubjects.mockReturnValue({ isPending: false, @@ -117,33 +125,39 @@ beforeEach(() => { // AccessControlItem handles selected vs. unselected styling and click state updates describe('AccessControlItem', () => { it('should update current menu when selecting a different access type', () => { - useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC }) - render( - - Organization Only - , + const harness = createAccessControlDraftHarness( + + + Organization Only + + , + { currentMenu: AccessMode.PUBLIC }, ) + render(harness.element) - const option = screen.getByText('Organization Only').parentElement as HTMLElement + const option = screen.getByRole('radio', { name: 'Organization Only' }) expect(option).toHaveClass('cursor-pointer') fireEvent.click(option) - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) + expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION) }) it('should keep current menu when clicking the selected access type', () => { - useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION }) - render( - - Organization Only - , + const harness = createAccessControlDraftHarness( + + + Organization Only + + , + { currentMenu: AccessMode.ORGANIZATION }, ) + render(harness.element) - const option = screen.getByText('Organization Only').parentElement as HTMLElement + const option = screen.getByRole('radio', { name: 'Organization Only' }) fireEvent.click(option) - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) + expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION) }) }) @@ -180,32 +194,40 @@ describe('AccessControlDialog', () => { // SpecificGroupsOrMembers syncs store state with fetched data and supports removals describe('SpecificGroupsOrMembers', () => { it('should render collapsed view when not in specific selection mode', () => { - useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION }) + const harness = createAccessControlDraftHarness( + , + { currentMenu: AccessMode.ORGANIZATION }, + ) - render() + render(harness.element) expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument() }) it('should show loading state while pending', async () => { - useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: true, - data: undefined, - }) + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) - const { container } = render() + render(harness.element) - await waitFor(() => { - expect(container.querySelector('.spin-animation')).toBeInTheDocument() - }) + expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument() }) it('should render fetched groups and members and support removal', async () => { - useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) + const harness = createAccessControlDraftHarness( + , + { + appId: 'app-1', + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + specificGroups: [baseGroup], + specificMembers: [baseMember], + }, + ) - render() + render(harness.element) await waitFor(() => { expect(screen.getByText(baseGroup.name)).toBeInTheDocument() @@ -234,8 +256,12 @@ describe('SpecificGroupsOrMembers', () => { describe('AddMemberOrGroupDialog', () => { it('should open search popover and display candidates', async () => { const user = userEvent.setup() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) - render() + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -246,17 +272,21 @@ describe('AddMemberOrGroupDialog', () => { it('should allow selecting members and expanding groups', async () => { const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand') await user.click(expandButton) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup]) await user.click(screen.getByRole('option', { name: /Member One/ })) - expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) + expect(harness.getSnapshot().specificMembers).toEqual([baseMember]) }) it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => { @@ -269,7 +299,11 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group') @@ -286,9 +320,9 @@ describe('AddMemberOrGroupDialog', () => { fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers')) - expect(useAccessControlStore.getState().specificGroups).toEqual([]) - expect(useAccessControlStore.getState().specificMembers).toEqual([]) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) + expect(harness.getSnapshot().specificGroups).toEqual([]) + expect(harness.getSnapshot().specificMembers).toEqual([]) + expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([]) expect(fetchNextPage).not.toHaveBeenCalled() }) @@ -301,7 +335,11 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -315,10 +353,6 @@ describe('AccessControl', () => { const onClose = vi.fn() const onConfirm = vi.fn() const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') - useAccessControlStore.setState({ - specificGroups: [baseGroup], - specificMembers: [baseMember], - }) const app = { id: 'app-id-1', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, @@ -332,21 +366,22 @@ describe('AccessControl', () => { />, ) - await waitFor(() => { - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS) - }) - fireEvent.click(screen.getByText('common.operation.confirm')) await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith({ - appId: app.id, - accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - subjects: [ - { subjectId: baseGroup.id, subjectType: SubjectType.GROUP }, - { subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT }, - ], - }) + expect(mockMutate).toHaveBeenCalledWith( + { + body: { + appId: app.id, + accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + subjects: [ + { subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_GROUP }, + { subjectId: baseMember.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_ACCOUNT }, + ], + }, + }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess') expect(onConfirm).toHaveBeenCalled() }) diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx index d34756e85e4..b447e9c381b 100644 --- a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -1,9 +1,9 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import useAccessControlStore from '@/context/access-control-store' -import { SubjectType } from '@/models/access-control' -import AddMemberOrGroupDialog from '../add-member-or-group-pop' +import { AccessMode, SubjectType } from '@/models/access-control' +import { AddMemberOrGroupDialog } from '../add-member-or-group-pop' +import { createAccessControlDraftHarness } from './access-control-test-utils' const mockUseSearchForWhiteListCandidates = vi.fn() const intersectionObserverMocks = vi.hoisted(() => ({ @@ -69,13 +69,6 @@ describe('AddMemberOrGroupDialog', () => { beforeEach(() => { vi.clearAllMocks() - useAccessControlStore.setState({ - appId: 'app-1', - specificGroups: [], - specificMembers: [], - currentMenu: SubjectType.GROUP as never, - selectedGroupsForBreadcrumb: [], - }) mockUseSearchForWhiteListCandidates.mockReturnValue({ isLoading: false, isFetchingNextPage: false, @@ -88,7 +81,11 @@ describe('AddMemberOrGroupDialog', () => { it('should open the search popover and display candidates', async () => { const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -99,16 +96,20 @@ describe('AddMemberOrGroupDialog', () => { it('should allow expanding groups and selecting members', async () => { const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup]) await user.click(screen.getByRole('option', { name: /Member One/ })) - expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) + expect(harness.getSnapshot().specificMembers).toEqual([baseMember]) }) it('should show the empty state when no candidates are returned', async () => { @@ -120,7 +121,11 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -128,9 +133,6 @@ describe('AddMemberOrGroupDialog', () => { }) it('should keep breadcrumbs visible when the current group has no candidates', async () => { - useAccessControlStore.setState({ - selectedGroupsForBreadcrumb: [baseGroup], - }) mockUseSearchForWhiteListCandidates.mockReturnValue({ isLoading: false, isFetchingNextPage: false, @@ -139,7 +141,15 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { + appId: 'app-1', + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + selectedGroupsForBreadcrumb: [baseGroup], + }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -149,6 +159,6 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) + expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([]) }) }) diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index 74e7d7046c0..f3cb47f16b8 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -3,9 +3,8 @@ import type { App } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' -import AccessControl from '../index' +import { AccessControl } from '../index' let mockWebappAuth = { enabled: true, @@ -18,20 +17,24 @@ const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { systemFeatures: { webapp_auth: mockWebappAuth }, }) -const mockMutateAsync = vi.fn() -const mockUseUpdateAccessMode = vi.fn(() => ({ - isPending: false, - mutateAsync: mockMutateAsync, -})) +const mockMutate = vi.fn() +const mockUseMutation = vi.hoisted(() => vi.fn()) const mockUseAppWhiteListSubjects = vi.fn() const mockUseSearchForWhiteListCandidates = vi.fn() vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), - useUpdateAccessMode: () => mockUseUpdateAccessMode(), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useMutation: (...args: unknown[]) => mockUseMutation(...args), + } +}) + describe('AccessControl', () => { beforeEach(() => { vi.clearAllMocks() @@ -41,14 +44,13 @@ describe('AccessControl', () => { allow_email_password_login: false, allow_email_code_login: false, } - useAccessControlStore.setState({ - appId: '', - specificGroups: [], - specificMembers: [], - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - selectedGroupsForBreadcrumb: [], + mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + mockUseMutation.mockReturnValue({ + isPending: false, + mutate: mockMutate, }) - mockMutateAsync.mockResolvedValue(undefined) mockUseAppWhiteListSubjects.mockReturnValue({ isPending: false, data: { @@ -81,18 +83,18 @@ describe('AccessControl', () => { />, ) - await waitFor(() => { - expect(useAccessControlStore.getState().appId).toBe(app.id) - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC) - }) - fireEvent.click(screen.getByText('common.operation.confirm')) await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith({ - appId: app.id, - accessMode: AccessMode.PUBLIC, - }) + expect(mockMutate).toHaveBeenCalledWith( + { + body: { + appId: app.id, + accessMode: AccessMode.PUBLIC, + }, + }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess') expect(onConfirm).toHaveBeenCalledTimes(1) }) @@ -116,4 +118,30 @@ describe('AccessControl', () => { expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument() expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument() }) + + it('should prevent confirming specific access before subjects are loaded', () => { + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: true, + data: undefined, + }) + + render( + , + ) + + const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) + const organizationOption = screen.getByRole('radio', { + name: 'app.accessControlDialog.accessItems.organization', + }) + + expect(confirmButton).toBeDisabled() + expect(organizationOption).toHaveAttribute('aria-disabled', 'true') + + fireEvent.click(confirmButton) + + expect(mockMutate).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx index e7635219405..044e5aa3d60 100644 --- a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -1,17 +1,13 @@ import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' -import SpecificGroupsOrMembers from '../specific-groups-or-members' +import { SpecificGroupsOrMembers } from '../specific-groups-or-members' +import { createAccessControlDraftHarness } from './access-control-test-utils' -const mockUseAppWhiteListSubjects = vi.fn() +const mockUseSearchForWhiteListCandidates = vi.fn() vi.mock('@/service/access-control', () => ({ - useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), -})) - -vi.mock('../add-member-or-group-pop', () => ({ - default: () =>
, + useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), })) const createGroup = (overrides: Partial = {}): AccessControlGroup => ({ @@ -36,50 +32,48 @@ describe('SpecificGroupsOrMembers', () => { beforeEach(() => { vi.clearAllMocks() - useAccessControlStore.setState({ - appId: '', - specificGroups: [], - specificMembers: [], - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - selectedGroupsForBreadcrumb: [], - }) - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: false, - data: { - groups: [baseGroup], - members: [baseMember], - }, + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [] }, }) }) it('should render the collapsed row when not in specific mode', () => { - useAccessControlStore.setState({ - currentMenu: AccessMode.ORGANIZATION, - }) + const harness = createAccessControlDraftHarness( + , + { currentMenu: AccessMode.ORGANIZATION }, + ) - render() + render(harness.element) expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() - expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.add' })).not.toBeInTheDocument() }) - it('should show loading while whitelist subjects are pending', async () => { - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: true, - data: undefined, - }) + it('should show loading when the selected subjects are pending', async () => { + const harness = createAccessControlDraftHarness() + render(harness.element) - const { container } = render() + expect(screen.getByRole('combobox', { name: 'common.operation.add' })).toBeDisabled() await waitFor(() => { - expect(container.querySelector('.spin-animation')).toBeInTheDocument() + expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument() }) }) it('should render fetched groups and members and support removal', async () => { - useAccessControlStore.setState({ appId: 'app-1' }) + const harness = createAccessControlDraftHarness( + , + { + appId: 'app-1', + specificGroups: [baseGroup], + specificMembers: [baseMember], + }, + ) - render() + render(harness.element) await waitFor(() => { expect(screen.getByText(baseGroup.name)).toBeInTheDocument() @@ -91,9 +85,9 @@ describe('SpecificGroupsOrMembers', () => { const memberRemove = removeButtons[1]! fireEvent.click(groupRemove) - expect(useAccessControlStore.getState().specificGroups).toEqual([]) + expect(harness.getSnapshot().specificGroups).toEqual([]) fireEvent.click(memberRemove) - expect(useAccessControlStore.getState().specificMembers).toEqual([]) + expect(harness.getSnapshot().specificMembers).toEqual([]) }) }) diff --git a/web/app/components/app/app-access-control/access-control-dialog-content.tsx b/web/app/components/app/app-access-control/access-control-dialog-content.tsx new file mode 100644 index 00000000000..fb1f324a0ea --- /dev/null +++ b/web/app/components/app/app-access-control/access-control-dialog-content.tsx @@ -0,0 +1,110 @@ +'use client' + +import type { ReactNode } from 'react' +import type { SpecificGroupsOrMembersProps } from './specific-groups-or-members' +import { Button } from '@langgenius/dify-ui/button' +import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { RadioGroup } from '@langgenius/dify-ui/radio-group' +import { useTranslation } from 'react-i18next' +import { AccessMode } from '@/models/access-control' +import { AccessControlItem } from './access-control-item' +import { SpecificGroupsOrMembers, WebAppSSONotEnabledTip } from './specific-groups-or-members' +import { useAccessControlStore } from './store' + +type AccessControlDialogContentProps = { + title?: ReactNode + description?: ReactNode + accessLabel?: ReactNode + hideExternal?: boolean + hideExternalTip?: boolean + saving?: boolean + controlsDisabled?: boolean + confirmDisabled?: boolean + specificGroupsOrMembersProps?: SpecificGroupsOrMembersProps + onClose: () => void + onConfirm: () => void +} + +export function AccessControlDialogContent({ + title, + description, + accessLabel, + hideExternal = false, + hideExternalTip = false, + saving = false, + controlsDisabled = false, + confirmDisabled = false, + specificGroupsOrMembersProps, + onClose, + onConfirm, +}: AccessControlDialogContentProps) { + const { t } = useTranslation() + const currentMenu = useAccessControlStore(s => s.currentMenu) + const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu) + + return ( +
+
+ + {title ?? t('accessControlDialog.title', { ns: 'app' })} + + + {description ?? t('accessControlDialog.description', { ns: 'app' })} + +
+ + value={currentMenu} + onValueChange={setCurrentMenu} + className="flex flex-col items-stretch gap-y-1 px-6 pb-3" + aria-labelledby="access-control-options-label" + disabled={controlsDisabled} + > +
+

+ {accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })} +

+
+ +
+
+
+
+
+ + + + {!hideExternal && ( + +
+
+
+ {!hideExternalTip && } +
+
+ )} + +
+
+
+ +
+ + +
+
+ ) +} diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx index a863935f90d..c29e73aa9a4 100644 --- a/web/app/components/app/app-access-control/access-control-dialog.tsx +++ b/web/app/components/app/app-access-control/access-control-dialog.tsx @@ -5,7 +5,6 @@ import { DialogCloseButton, DialogContent, } from '@langgenius/dify-ui/dialog' -import { useCallback } from 'react' type DialogProps = { className?: string @@ -14,21 +13,17 @@ type DialogProps = { onClose?: () => void } -const AccessControlDialog = ({ +export function AccessControlDialog({ className, children, show, onClose, -}: DialogProps) => { - const close = useCallback(() => { - onClose?.() - }, [onClose]) - +}: DialogProps) { return ( - !open && close()}> + !open && onClose?.()}> @@ -38,5 +33,3 @@ const AccessControlDialog = ({ ) } - -export default AccessControlDialog diff --git a/web/app/components/app/app-access-control/access-control-item.tsx b/web/app/components/app/app-access-control/access-control-item.tsx index cc2cf94f0c4..5913a7e47f8 100644 --- a/web/app/components/app/app-access-control/access-control-item.tsx +++ b/web/app/components/app/app-access-control/access-control-item.tsx @@ -1,37 +1,26 @@ 'use client' -import type { FC, PropsWithChildren } from 'react' +import type { PropsWithChildren } from 'react' import type { AccessMode } from '@/models/access-control' -import useAccessControlStore from '@/context/access-control-store' +import { cn } from '@langgenius/dify-ui/cn' +import { RadioRoot } from '@langgenius/dify-ui/radio' -type AccessControlItemProps = PropsWithChildren<{ +export function AccessControlItem({ type, children }: PropsWithChildren<{ type: AccessMode -}> - -const AccessControlItem: FC = ({ type, children }) => { - const currentMenu = useAccessControlStore(s => s.currentMenu) - const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu) - if (currentMenu !== type) { - return ( -
setCurrentMenu(type)} - > - {children} -
- ) - } - +}>) { return ( -
+ value={type} + variant="unstyled" + render={
} + className={cn( + 'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors', + 'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover', + 'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', + 'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset', + 'data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg', + )} > {children} -
+ ) } - -AccessControlItem.displayName = 'AccessControlItem' - -export default AccessControlItem diff --git a/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx b/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx new file mode 100644 index 00000000000..43da1de6a70 --- /dev/null +++ b/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx @@ -0,0 +1,208 @@ +'use client' + +import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox' +import type { AccessSubjectSelectionProps } from './types' +import type { + AccessControlGroup, + Subject, +} from '@/models/access-control' +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxList, + ComboboxStatus, + ComboboxTrigger, +} from '@langgenius/dify-ui/combobox' +import { useDebounce } from 'ahooks' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { SkeletonRectangle } from '@/app/components/base/skeleton' +import { useSearchForWhiteListCandidates } from '@/service/access-control' +import { SelectedGroupsBreadCrumb, SubjectItem } from './subject-options' +import { + getSubjectLabel, + getSubjectValue, + isSameSubject, + selectionValueToSubjects, + subjectsToSelectionValue, +} from './utils' + +type AccessSubjectAddButtonProps = AccessSubjectSelectionProps & { + disabled?: boolean + breadcrumbGroups?: AccessControlGroup[] + onBreadcrumbGroupsChange?: (groups: AccessControlGroup[]) => void +} + +export function AccessSubjectAddButton({ + selectedGroups, + selectedMembers, + onChange, + disabled, + breadcrumbGroups, + onBreadcrumbGroupsChange, +}: AccessSubjectAddButtonProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [keyword, setKeyword] = useState('') + const [internalBreadcrumbGroups, setInternalBreadcrumbGroups] = useState([]) + const scrollRootRef = useRef(null) + const anchorRef = useRef(null) + const selectedGroupsForBreadcrumb = breadcrumbGroups ?? internalBreadcrumbGroups + const setSelectedGroupsForBreadcrumb = onBreadcrumbGroupsChange ?? setInternalBreadcrumbGroups + const debouncedKeyword = useDebounce(keyword, { wait: 500 }) + + const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1] + const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ + keyword: debouncedKeyword, + groupId: lastAvailableGroup?.id, + resultsPerPage: 10, + }, open && !disabled) + const pages = data?.pages ?? [] + const subjects = pages.flatMap(page => page.subjects ?? []) + const selectedSubjects = selectionValueToSubjects({ + groups: selectedGroups, + members: selectedMembers, + }) + const hasResults = pages.length > 0 && subjects.length > 0 + const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0 + const hasMore = pages[pages.length - 1]?.hasMore ?? false + + useEffect(() => { + let observer: IntersectionObserver | undefined + if (anchorRef.current) { + observer = new IntersectionObserver((entries) => { + if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore) + fetchNextPage() + }, { root: scrollRootRef.current, rootMargin: '20px' }) + observer.observe(anchorRef.current) + } + return () => observer?.disconnect() + }, [fetchNextPage, hasMore, isFetchingNextPage, isLoading]) + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen && disabled) + return + if (!nextOpen) + setKeyword('') + + setOpen(nextOpen) + } + + const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => { + if (!disabled && details.reason !== 'item-press') + setKeyword(inputValue) + } + + const handleValueChange = (nextSubjects: Subject[]) => { + onChange(subjectsToSelectionValue(nextSubjects)) + } + + return ( + + multiple + open={open} + value={selectedSubjects} + inputValue={keyword} + items={subjects} + disabled={disabled} + itemToStringLabel={getSubjectLabel} + itemToStringValue={getSubjectValue} + isItemEqualToValue={isSameSubject} + filter={null} + onOpenChange={handleOpenChange} + onInputValueChange={handleInputValueChange} + onValueChange={handleValueChange} + > + + + + + +
+
+ + +
+ {isLoading + ? ( + + + + ) + : ( + <> + {shouldShowBreadcrumb && ( +
+ +
+ )} + {hasResults + ? ( + <> + + {(subject: Subject) => ( + setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])} + /> + )} + + {isFetchingNextPage && } +
+ + ) + : ( + + {t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })} + + )} + + )} +
+ + + ) +} + +function SubjectOptionsSkeleton() { + return ( +
+ {[0, 1, 2, 3, 4].map(index => ( +
+ + + + +
+ ))} +
+ ) +} diff --git a/web/app/components/app/app-access-control/access-subject-selector/selection-list.tsx b/web/app/components/app/app-access-control/access-subject-selector/selection-list.tsx new file mode 100644 index 00000000000..4a7a545d62f --- /dev/null +++ b/web/app/components/app/app-access-control/access-subject-selector/selection-list.tsx @@ -0,0 +1,207 @@ +'use client' + +import type { ReactNode } from 'react' +import type { AccessSubjectSelectionProps } from './types' +import type { + AccessControlAccount, + AccessControlGroup, +} from '@/models/access-control' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import { SkeletonRectangle } from '@/app/components/base/skeleton' + +type AccessSubjectSelectionListProps = AccessSubjectSelectionProps & { + loading?: boolean + className?: string +} + +export function AccessSubjectSelectionList({ + selectedGroups, + selectedMembers, + onChange, + loading, + className, +}: AccessSubjectSelectionListProps) { + return ( +
+ {loading + ? + : ( + + )} +
+ ) +} + +function AccessSubjectSelectionListSkeleton() { + const { t } = useTranslation() + + return ( +
+ +
+ {[0, 1].map(index => ( + + ))} +
+ +
+ {[0, 1, 2].map(index => ( + + ))} +
+
+ ) +} + +function SelectedItemSkeleton({ withMeta = false }: { + withMeta?: boolean +}) { + return ( +
+ + + {withMeta && } + +
+ ) +} + +function RenderGroupsAndMembers({ + selectedGroups, + selectedMembers, + onChange, +}: AccessSubjectSelectionProps) { + const { t } = useTranslation() + if (selectedGroups.length <= 0 && selectedMembers.length <= 0) { + return ( +
+

+ {t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })} +

+
+ ) + } + + return ( + <> +

+ {t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })} +

+
+ {selectedGroups.map(group => ( + + ))} +
+

+ {t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })} +

+
+ {selectedMembers.map(member => ( + + ))} +
+ + ) +} + +type SelectedGroupItemProps = AccessSubjectSelectionProps & { + group: AccessControlGroup +} + +function SelectedGroupItem({ + group, + selectedGroups, + selectedMembers, + onChange, +}: SelectedGroupItemProps) { + const handleRemoveGroup = () => { + onChange({ + groups: selectedGroups.filter(selectedGroup => selectedGroup.id !== group.id), + members: selectedMembers, + }) + } + + return ( + + ) +} + +type SelectedMemberItemProps = AccessSubjectSelectionProps & { + member: AccessControlAccount +} + +function SelectedMemberItem({ + member, + selectedGroups, + selectedMembers, + onChange, +}: SelectedMemberItemProps) { + const handleRemoveMember = () => { + onChange({ + groups: selectedGroups, + members: selectedMembers.filter(selectedMember => selectedMember.id !== member.id), + }) + } + + return ( + } + onRemove={handleRemoveMember} + > +

{member.name}

+
+ ) +} + +type SelectedBaseItemProps = { + icon: ReactNode + children: ReactNode + onRemove?: () => void +} + +function SelectedBaseItem({ icon, onRemove, children }: SelectedBaseItemProps) { + const { t } = useTranslation() + + return ( +
+
+
+ {icon} +
+
+ {children} + +
+ ) +} diff --git a/web/app/components/app/app-access-control/access-subject-selector/subject-options.tsx b/web/app/components/app/app-access-control/access-subject-selector/subject-options.tsx new file mode 100644 index 00000000000..1a09df73636 --- /dev/null +++ b/web/app/components/app/app-access-control/access-subject-selector/subject-options.tsx @@ -0,0 +1,217 @@ +'use client' + +import type { ReactNode } from 'react' +import type { + AccessControlAccount, + AccessControlGroup, + Subject, + SubjectAccount, + SubjectGroup, +} from '@/models/access-control' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + ComboboxItem, + ComboboxItemText, +} from '@langgenius/dify-ui/combobox' +import { useTranslation } from 'react-i18next' +import { useSelector } from '@/context/app-context' +import { SubjectType } from '@/models/access-control' + +export function SubjectItem({ + subject, + selectedGroups, + selectedMembers, + onExpandGroup, +}: { + subject: Subject + selectedGroups: AccessControlGroup[] + selectedMembers: AccessControlAccount[] + onExpandGroup: (group: AccessControlGroup) => void +}) { + if (subject.subjectType === SubjectType.GROUP) { + return ( + + ) + } + + return ( + + ) +} + +export function SelectedGroupsBreadCrumb({ + selectedGroupsForBreadcrumb, + onChange, +}: { + selectedGroupsForBreadcrumb: AccessControlGroup[] + onChange: (groups: AccessControlGroup[]) => void +}) { + const { t } = useTranslation() + + const handleBreadCrumbClick = (index: number) => { + const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1) + onChange(newGroups) + } + const handleReset = () => { + onChange([]) + } + const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0 + + return ( +
+ {hasBreadcrumb + ? ( + + ) + : ( + {t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })} + )} + {selectedGroupsForBreadcrumb.map((group, index) => { + const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1 + + return ( +
+ / + {isLastGroup + ? {group.name} + : ( + + )} +
+ ) + })} +
+ ) +} + +type GroupItemProps = { + group: AccessControlGroup + subject: Subject + selectedGroups: AccessControlGroup[] + onExpandGroup: (group: AccessControlGroup) => void +} + +function GroupItem({ group, subject, selectedGroups, onExpandGroup }: GroupItemProps) { + const { t } = useTranslation() + const isChecked = selectedGroups.some(selectedGroup => selectedGroup.id === group.id) + + return ( +
+ + + +
+
+
+
+ {group.name} + {group.groupSize} +
+
+ +
+ ) +} + +type MemberItemProps = { + member: AccessControlAccount + subject: Subject + selectedMembers: AccessControlAccount[] +} + +function MemberItem({ member, subject, selectedMembers }: MemberItemProps) { + const currentUser = useSelector(s => s.userProfile) + const { t } = useTranslation() + const isChecked = selectedMembers.some(selectedMember => selectedMember.id === member.id) + return ( + + + +
+
+ +
+
+ {member.name} + {currentUser.email === member.email && ( + + ( + {t('you', { ns: 'common' })} + ) + + )} +
+ {member.email} +
+ ) +} + +type ComboboxBaseItemProps = { + className?: string + subject: Subject + children: ReactNode +} + +function ComboboxBaseItem({ children, className, subject }: ComboboxBaseItemProps) { + return ( + + {children} + + ) +} + +function SelectionBox({ checked }: { checked: boolean }) { + return ( +