diff --git a/.agents/skills/e2e-cucumber-playwright/SKILL.md b/.agents/skills/e2e-cucumber-playwright/SKILL.md index 75e79ea2e7f..5762bf2076d 100644 --- a/.agents/skills/e2e-cucumber-playwright/SKILL.md +++ b/.agents/skills/e2e-cucumber-playwright/SKILL.md @@ -32,12 +32,11 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - `e2e/` uses Cucumber for scenarios and Playwright as the browser layer. - `DifyWorld` is the per-scenario context object. Type `this` as `DifyWorld` and use `async function`, not arrow functions. - Keep glue organized by capability under `e2e/features/step-definitions/`; use `common/` only for broadly reusable steps. -- Browser session behavior comes from `features/support/hooks.ts`: - - default: authenticated session with shared storage state - - `@unauthenticated`: clean browser context - - `@authenticated`: readability/selective-run tag only unless implementation changes - - `@fresh`: only for `e2e:full*` flows +- Treat `e2e/AGENTS.md`, `features/support/hooks.ts`, and the Cucumber configuration as the owners of current session and tag semantics. Verify them when behavior depends on session state instead of copying a tag inventory into this skill. - Do not import Playwright Test runner patterns that bypass the current Cucumber + `DifyWorld` architecture unless the task is explicitly about changing that architecture. +- Perform the behavior under test through Playwright. APIs are allowed for setup, seed preparation, persistence polling, and cleanup, but ordinary Console JSON and representable multipart operations must use the scenario- or process-owned generated oRPC client with request and response validation enabled. Keep the setup/cleanup API identity independent from an unauthenticated or logged-out behavior browser. +- Consume generated operations directly. Do not add one-to-one API wrappers, handwritten endpoint URLs, response DTO casts, duplicate schemas, global mutable clients, or TanStack Query caching in Cucumber. Keep helpers only for real fixture construction, multi-operation orchestration, invariants, polling, derived test views, or protocol adapters. +- Keep SSE, binary, redirect-only, external-service, and readiness exceptions centralized under their protocol owner. A contract mismatch must fail and be fixed at the backend schema owner followed by regeneration; never weaken validation to make E2E pass. ## Workflow @@ -66,7 +65,7 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - If a product element has real user-facing semantics but no accessible name, prefer fixing that accessible contract over adding a test id. 5. Validate narrowly. - Run the narrowest tagged scenario or flow that exercises the change. - - Run `vpr lint --fix --quiet` from the repository root and `pnpm -C e2e type-check`. + - Run the package-required static checks documented in `e2e/AGENTS.md`. - Broaden verification only when the change affects hooks, tags, setup, or shared step semantics. ## Review Checklist @@ -77,6 +76,8 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - Are locators user-facing and assertions web-first? - Does the change introduce hidden coupling across scenarios, tags, or instance state? - Does it document or implement behavior that differs from the real hooks or configuration? +- Does setup/cleanup use the generated client directly, with any remaining helper owning more than a one-to-one endpoint forward? +- Is every raw HTTP call a documented protocol or infrastructure exception rather than an ordinary Console operation? Lead findings with correctness, flake risk, and architecture drift. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 50931da0a41..206d45374b6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,6 @@ # Lint bulk suppression baselines. /oxlint-suppressions.json -/eslint-suppressions.json # CODEOWNERS file /.github/CODEOWNERS @laipz8200 @crazywoola @@ -33,31 +32,9 @@ # Backend (default owner, more specific rules below will override) /api/ @QuantumGhost -# Backend - MCP -/api/core/mcp/ @Nov1c444 -/api/core/entities/mcp_provider.py @Nov1c444 -/api/services/tools/mcp_tools_manage_service.py @Nov1c444 -/api/controllers/mcp/ @Nov1c444 -/api/controllers/console/app/mcp_server.py @Nov1c444 - # Backend - Tests /api/tests/ @laipz8200 @QuantumGhost -/api/tests/**/*mcp* @Nov1c444 - -# Backend - Workflow - Engine (Core graph execution engine) -/api/core/workflow/graph_engine/ @laipz8200 @QuantumGhost -/api/core/workflow/runtime/ @laipz8200 @QuantumGhost -/api/core/workflow/graph/ @laipz8200 @QuantumGhost -/api/core/workflow/graph_events/ @laipz8200 @QuantumGhost -/api/core/workflow/node_events/ @laipz8200 @QuantumGhost - -# Backend - Workflow - Nodes (Agent, Iteration, Loop, LLM) -/api/core/workflow/nodes/agent/ @Nov1c444 -/api/core/workflow/nodes/iteration/ @Nov1c444 -/api/core/workflow/nodes/loop/ @Nov1c444 -/api/core/workflow/nodes/llm/ @Nov1c444 - # Backend - RAG (Retrieval Augmented Generation) /api/core/rag/ @JohnJyong /api/services/rag_pipeline/ @JohnJyong @@ -111,7 +88,6 @@ /api/core/app/layers/trigger_post_layer.py @CourTeous33 /api/services/trigger/ @CourTeous33 /api/models/trigger.py @CourTeous33 -/api/fields/workflow_trigger_fields.py @CourTeous33 /api/repositories/workflow_trigger_log_repository.py @CourTeous33 /api/repositories/sqlalchemy_workflow_trigger_log_repository.py @CourTeous33 /api/libs/schedule_utils.py @CourTeous33 @@ -136,11 +112,11 @@ /api/controllers/console/billing/ @hj24 @zyssyz123 # Backend - Enterprise -/api/configs/enterprise/ @GarfieldDai @GareArc -/api/services/enterprise/ @GarfieldDai @GareArc -/api/services/feature_service.py @GarfieldDai @GareArc -/api/controllers/console/feature.py @GarfieldDai @GareArc -/api/controllers/web/feature.py @GarfieldDai @GareArc +/api/configs/enterprise/ @GareArc +/api/services/enterprise/ @GareArc +/api/services/feature_service.py @GareArc +/api/controllers/console/feature.py @GareArc +/api/controllers/web/feature.py @GareArc # Backend - Database Migrations /api/migrations/ @snakevash @laipz8200 @MRZHUH @@ -153,7 +129,6 @@ # Frontend - Platform and Features /web/config/ @lyzno1 -/web/contract/ @lyzno1 /web/env.ts @lyzno1 /web/features/ @lyzno1 /web/hooks/ @lyzno1 @@ -212,7 +187,6 @@ /web/app/components/rag-pipeline/store/ @iamjoel @zxhlyh # Frontend - RAG - Documents List -/web/app/components/datasets/documents/list.tsx @iamjoel @WTW0313 /web/app/components/datasets/documents/create-from-pipeline/ @iamjoel @WTW0313 # Frontend - RAG - Segments List @@ -231,22 +205,22 @@ /web/app/components/plugins/marketplace/ @iamjoel @Yessenia-d # Frontend - Login and Registration -/web/app/signin/ @douxc @iamjoel -/web/app/signup/ @douxc @iamjoel -/web/app/reset-password/ @douxc @iamjoel -/web/app/install/ @douxc @iamjoel -/web/app/init/ @douxc @iamjoel -/web/app/forgot-password/ @douxc @iamjoel -/web/app/account/ @douxc @iamjoel +/web/app/signin/ @iamjoel +/web/app/signup/ @iamjoel +/web/app/reset-password/ @iamjoel +/web/app/install/ @iamjoel +/web/app/init/ @iamjoel +/web/app/forgot-password/ @iamjoel +/web/app/account/ @iamjoel # Frontend - Service Authentication -/web/service/base.ts @douxc @iamjoel +/web/service/base.ts @iamjoel # Frontend - WebApp Authentication and Access Control -/web/app/(shareLayout)/components/ @douxc @iamjoel -/web/app/(shareLayout)/webapp-signin/ @douxc @iamjoel -/web/app/(shareLayout)/webapp-reset-password/ @douxc @iamjoel -/web/app/components/app/app-access-control/ @douxc @iamjoel +/web/app/(shareLayout)/components/ @iamjoel +/web/app/(shareLayout)/webapp-signin/ @iamjoel +/web/app/(shareLayout)/webapp-reset-password/ @iamjoel +/web/app/components/app/app-access-control/ @iamjoel # Frontend - Explore Page /web/app/components/explore/ @CodingOnStar @iamjoel @@ -265,7 +239,6 @@ /web/app/components/base/**/*.spec.tsx @hyoban @CodingOnStar # Frontend - Utils and Hooks -/web/utils/classnames.ts @iamjoel @zxhlyh /web/utils/time.ts @iamjoel @zxhlyh /web/utils/format.ts @iamjoel @zxhlyh /web/utils/clipboard.ts @iamjoel @zxhlyh diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 18542a1f18d..aec8514a69d 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -335,6 +335,8 @@ jobs: - check-changes if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true' uses: ./.github/workflows/web-e2e.yml + with: + run-external-runtime: false secrets: inherit web-e2e-skip: diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml index 60e15c25e13..c7ee9850b08 100644 --- a/.github/workflows/post-merge.yml +++ b/.github/workflows/post-merge.yml @@ -26,19 +26,30 @@ jobs: external_e2e: - 'e2e/features/agent-v2/**' - 'e2e/features/step-definitions/agent-v2/**' + - 'e2e/features/step-definitions/common/**' - 'e2e/features/support/**' + - 'e2e/fixtures/auth.ts' - 'e2e/fixtures/test-materials/**' - 'e2e/scripts/**' - 'e2e/support/**' - 'e2e/cucumber.config.ts' - 'e2e/package.json' - 'e2e/test-env.ts' + - 'e2e/tsconfig.json' - 'e2e/tsx-register.js' + - 'package.json' + - 'pnpm-lock.yaml' + - '.nvmrc' - '.github/workflows/post-merge.yml' - '.github/workflows/web-e2e.yml' - '.github/actions/setup-web/**' + - 'docker/docker-compose.middleware.yaml' + - 'docker/envs/middleware.env.example' - 'dify-agent/**' - 'dify-agent-runtime/**' + - 'api/pyproject.toml' + - 'api/uv.lock' + - 'api/tests/integration_tests/.env.example' - 'api/clients/agent_backend/**' - 'api/core/app/apps/agent_app/**' - 'api/core/workflow/nodes/agent_v2/**' @@ -48,8 +59,13 @@ jobs: - 'api/services/plugin/**' - 'api/core/tools/**' - 'api/services/tools/**' + - 'packages/contracts/package.json' - 'packages/contracts/generated/api/console/agent/**' + - 'packages/contracts/generated/api/console/apps/**' + - 'packages/contracts/generated/api/console/datasets/**' - 'packages/contracts/generated/api/console/orpc.gen.ts' + - 'packages/contracts/generated/api/console/workspaces/**' + - 'packages/contracts/generated/api/service/**' - 'web/features/agent-v2/**' - 'web/app/(commonLayout)/agents/**' - 'web/app/(commonLayout)/@detailSidebar/agents/**' diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index d7cd2657d58..df7ed8d7c92 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -4,9 +4,9 @@ on: workflow_call: inputs: run-external-runtime: - required: false + description: Run only the prepared and external runtime suite instead of the core suites. + required: true type: boolean - default: false permissions: contents: read @@ -46,6 +46,7 @@ jobs: run: uv sync --project api --dev - name: Run E2E support unit tests + if: ${{ !inputs.run-external-runtime }} working-directory: ./e2e run: vp run test:unit @@ -54,6 +55,7 @@ jobs: run: vp run e2e:install - name: Run isolated source-api and built-web Cucumber E2E tests + if: ${{ !inputs.run-external-runtime }} working-directory: ./e2e env: E2E_ADMIN_EMAIL: e2e-admin@example.com @@ -64,7 +66,7 @@ jobs: run: vp run e2e:full - name: Preserve Chromium E2E report and logs - if: ${{ !cancelled() }} + if: ${{ !cancelled() && !inputs.run-external-runtime }} run: | if [[ -d e2e/cucumber-report ]]; then mv e2e/cucumber-report e2e/cucumber-report-non-external @@ -74,6 +76,7 @@ jobs: fi - name: Run WebKit keyboard and browser smoke tests + if: ${{ !inputs.run-external-runtime }} working-directory: ./e2e env: E2E_ADMIN_EMAIL: e2e-admin@example.com @@ -99,7 +102,7 @@ jobs: vp run e2e -- --tags '@browser-smoke' - name: Preserve WebKit E2E report and logs - if: ${{ !cancelled() }} + if: ${{ !cancelled() && !inputs.run-external-runtime }} run: | if [[ -d e2e/cucumber-report ]]; then mv e2e/cucumber-report e2e/cucumber-report-webkit diff --git a/README.md b/README.md index b6c430b6b32..7688ee889bd 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Dify is an open-source LLM app development platform. Its intuitive interface com
-The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine: +The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and Docker Compose v2.24.0 or later are installed on your machine: ```bash cd dify diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 744378e2383..6b0db7a0b48 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -62,7 +62,7 @@ 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.agent import Agent, AgentStatus +from models.agent import Agent, AgentConfigDraftType, AgentStatus from models.agent_config_entities import AgentSoulConfig from models.enums import ApiTokenType from models.model import ApiToken, App, IconType @@ -266,6 +266,13 @@ class AgentDebugConversationRefreshResponse(BaseModel): debug_conversation_message_count: int = 0 +class AgentDebugConversationRefreshPayload(BaseModel): + draft_type: AgentConfigDraftType = Field( + default=AgentConfigDraftType.DEBUG_BUILD, + description="Agent draft surface whose conversation should be refreshed", + ) + + class AgentPublishPayload(BaseModel): version_note: str | None = Field(default=None, description="Optional note for this published Agent version") @@ -309,6 +316,7 @@ register_schema_models( AgentAppCopyPayload, AgentPublishPayload, AgentBuildDraftCheckoutPayload, + AgentDebugConversationRefreshPayload, ComposerSavePayload, AgentApiStatusPayload, AgentInviteOptionsQuery, @@ -392,6 +400,7 @@ def _serialize_agent_app_detail( tenant_id=app_model.tenant_id, agent_id=agent.id, account_id=current_user.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, commit=False, ) message_count = roster_service.count_agent_app_debug_conversation_messages( @@ -439,6 +448,7 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_ tenant_id=tenant_id, agents=list(agents_by_app_id.values()), account_id=current_user.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) payload = AgentAppPagination.model_validate( app_pagination, @@ -655,6 +665,16 @@ class AgentAppApi(Resource): @console_ns.route("/agent//debug-conversation/refresh") class AgentDebugConversationRefreshApi(Resource): + @console_ns.expect(console_ns.models[AgentDebugConversationRefreshPayload.__name__]) + @console_ns.doc( + params={ + "payload": { + "in": "body", + "required": False, + "schema": {"$ref": f"#/components/schemas/{AgentDebugConversationRefreshPayload.__name__}"}, + } + } + ) @console_ns.response( 200, "Agent debug conversation refreshed", @@ -669,10 +689,12 @@ class AgentDebugConversationRefreshApi(Resource): @with_current_tenant_id @with_session def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): + args = AgentDebugConversationRefreshPayload.model_validate(request.get_json(silent=True) or {}) debug_conversation_id = _agent_roster_service(session).refresh_agent_app_debug_conversation_id( tenant_id=tenant_id, agent_id=str(agent_id), account_id=current_user.id, + draft_type=args.draft_type, ) return AgentDebugConversationRefreshResponse( debug_conversation_id=debug_conversation_id, @@ -729,6 +751,7 @@ class AgentBuildDraftCheckoutApi(Resource): @console_ns.route("/agent//build-draft") class AgentBuildDraftApi(Resource): @console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__]) + @console_ns.response(404, "Agent build draft not found") @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 335aefdaf2d..a286768d380 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -246,7 +246,7 @@ class ModelConfigPartial(ResponseModel): return to_timestamp(value) -class ModelConfig(ResponseModel): +class AppModelConfigResponse(ResponseModel): opening_statement: str | None = None suggested_questions: Any | None = Field( default=None, validation_alias=AliasChoices("suggested_questions_list", "suggested_questions") @@ -419,7 +419,7 @@ class AppDetail(AppResponseModel): icon_background: str | None = None enable_site: bool enable_api: bool - model_config_: ModelConfig | None = Field( + model_config_: AppModelConfigResponse | None = Field( default=None, validation_alias=AliasChoices("app_model_config", "model_config"), alias="model_config", @@ -525,7 +525,13 @@ def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum) register_response_schema_models( - console_ns, RedirectUrlResponse, SimpleResultResponse, AppImportResponse, AppTraceResponse + console_ns, + RedirectUrlResponse, + SimpleResultResponse, + AppImportResponse, + AppTraceResponse, + AppModelConfigResponse, + AppDetail, ) register_schema_models( @@ -544,10 +550,8 @@ register_schema_models( Tag, WorkflowPartial, ModelConfigPartial, - ModelConfig, AppDetailSiteResponse, DeletedTool, - AppDetail, AppExportResponse, Segmentation, PreProcessingRule, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index ae76fee38d9..3fe721def62 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -49,6 +49,7 @@ from libs import helper from libs.helper import uuid_value from libs.login import login_required from models import Account +from models.agent import AgentConfigDraftType from models.model import App, AppMode from services.agent.errors import AgentNotFoundError from services.agent.roster_service import AgentRosterService @@ -343,14 +344,23 @@ class AgentChatMessageStopApi(Resource): def _resolve_current_user_agent_debug_conversation_id( - *, session: Session, current_tenant_id: str, current_user: Account, app_model: App, agent_id: str | None + *, + session: Session, + current_tenant_id: str, + current_user: Account, + app_model: App, + agent_id: str | None, + draft_type: AgentConfigDraftType, ) -> str: + """Resolve the current editor's conversation without crossing draft surfaces.""" + roster_service = AgentRosterService(session) if agent_id: return roster_service.get_or_create_agent_app_debug_conversation_id( tenant_id=current_tenant_id, agent_id=agent_id, account_id=current_user.id, + draft_type=draft_type, ) agent = roster_service.get_app_backing_agent(tenant_id=current_tenant_id, app_id=str(app_model.id)) @@ -360,6 +370,7 @@ def _resolve_current_user_agent_debug_conversation_id( tenant_id=current_tenant_id, agent_id=agent.id, account_id=current_user.id, + draft_type=draft_type, ) @@ -382,6 +393,7 @@ def _create_chat_message( current_user=current_user, app_model=app_model, agent_id=agent_id, + draft_type=AgentConfigDraftType(args_model.draft_type), ) if args_model.conversation_id and args_model.conversation_id != debug_conversation_id: raise NotFound("Conversation Not Exists.") @@ -418,6 +430,7 @@ def _create_build_chat_finalization_message( current_user=current_user, app_model=app_model, agent_id=agent_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) args: dict[str, Any] = { "query": _BUILD_CHAT_FINALIZATION_QUERY, diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 4be0ab2211d..6025d02fe39 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -359,6 +359,12 @@ class WorkflowPublishResponse(ResponseModel): created_at: int +class SyncDraftWorkflowResponse(ResponseModel): + result: str + hash: str + updated_at: int + + class WorkflowRestoreResponse(ResponseModel): result: str hash: str @@ -441,6 +447,7 @@ register_response_schema_models( WorkflowOnlineUsersByApp, WorkflowOnlineUsersResponse, WorkflowPublishResponse, + SyncDraftWorkflowResponse, WorkflowRestoreResponse, DefaultBlockConfigsResponse, DefaultBlockConfigResponse, @@ -556,14 +563,7 @@ class DraftWorkflowApi(Resource): @console_ns.response( 200, "Draft workflow synced successfully", - console_ns.model( - "SyncDraftWorkflowResponse", - { - "result": fields.String, - "hash": fields.String, - "updated_at": fields.String, - }, - ), + console_ns.models[SyncDraftWorkflowResponse.__name__], ) @console_ns.response(400, "Invalid workflow configuration") @console_ns.response(403, "Permission denied") @@ -618,11 +618,14 @@ class DraftWorkflowApi(Resource): except VariableError as e: raise InvalidArgumentError(description=str(e)) - return { - "result": "success", - "hash": workflow.unique_hash, - "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), - } + return dump_response( + SyncDraftWorkflowResponse, + { + "result": "success", + "hash": workflow.unique_hash, + "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), + }, + ) @console_ns.route("/apps//advanced-chat/workflows/draft/run") diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py index 1f58dbe910f..3e9160f2bb0 100644 --- a/api/controllers/console/auth/activate.py +++ b/api/controllers/console/auth/activate.py @@ -7,10 +7,13 @@ from configs import dify_config from constants.languages import supported_language from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns +from controllers.console.auth.error import InvitationAccountMismatchError from controllers.console.error import AccountInFreezeError, AlreadyActivateError from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from libs.helper import EmailStr, timezone +from libs.login import current_account_with_tenant +from libs.token import extract_access_token from models import AccountStatus from models.account import TenantAccountJoin, TenantAccountRole from services.account_service import RegisterService, TenantService @@ -136,6 +139,12 @@ class ActivateApi(Resource): ) @console_ns.response(400, "Already activated or invalid token") def post(self): + """Accept an invitation without letting an existing session act for another account. + + Token-only activation remains available for legacy clients. When the request already + carries a console session, that session must belong to the account encoded in the + invitation before the token is consumed or tenant membership is changed. + """ args = ActivatePayload.model_validate(console_ns.payload) normalized_request_email = args.email.lower() if args.email else None @@ -146,6 +155,11 @@ class ActivateApi(Resource): raise AlreadyActivateError() account = invitation["account"] + if extract_access_token(request): + current_account, _ = current_account_with_tenant() + if current_account.id != account.id: + raise InvitationAccountMismatchError() + if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email): raise AccountInFreezeError() diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index 81f1c6e70fa..562de31270f 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -13,6 +13,12 @@ class InvalidEmailError(BaseHTTPException): code = 400 +class InvitationAccountMismatchError(BaseHTTPException): + error_code = "invitation_account_mismatch" + description = "This invitation was sent to another account. Please sign in with the invited account." + code = 403 + + class PasswordMismatchError(BaseHTTPException): error_code = "password_mismatch" description = "The passwords do not match." diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index 2160f3e38ec..a49cf47eaf6 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -6,6 +6,7 @@ from flask import current_app, redirect, request from flask_restx import Resource from pydantic import BaseModel, Field from werkzeug.exceptions import Unauthorized +from werkzeug.wrappers import Response from configs import dify_config from constants.languages import languages @@ -127,6 +128,20 @@ def _preferred_interface_language(language: str | None = None) -> str: return languages[0] +def _redirect_with_console_session(account: Account, target_url: str) -> Response: + """Create a console session and attach its cookies to a redirect response.""" + token_pair = AccountService.login( + account=account, + session=db.session(), + ip_address=extract_remote_ip(request), + ) + response = redirect(target_url) + set_access_token_to_cookie(request, response, token_pair.access_token) + set_refresh_token_to_cookie(request, response, token_pair.refresh_token) + set_csrf_token_to_cookie(request, response, token_pair.csrf_token) + return response + + @console_ns.route("/oauth/login/") class OAuthLogin(Resource): @console_ns.doc("oauth_login") @@ -195,16 +210,26 @@ class OAuthCallback(Resource): return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={urllib.parse.quote(str(e))}") if invite_token and RegisterService.is_valid_invite_token(invite_token): - invitation = RegisterService.get_invitation_by_token(token=invite_token) - if invitation: - invitation_email = invitation.get("email", None) - invitation_email_normalized = ( - invitation_email.lower() if isinstance(invitation_email, str) else invitation_email - ) - if invitation_email_normalized != user_info.email.lower(): - return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Invalid invitation token.") + invitation = RegisterService.get_invitation_if_token_valid( + None, + None, + invite_token, + session=db.session(), + ) + if not invitation: + return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Invalid invitation token.") + if invitation["data"]["email"].lower() != user_info.email.lower(): + message = "This invitation was sent to another account. Please sign in with the invited account." + query = urllib.parse.urlencode({"message": message, "invite_token": invite_token}) + return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?{query}") - return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}") + account = invitation["account"] + if account.status == AccountStatus.BANNED: + return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account is banned.") + + AccountService.link_account_integrate(provider, user_info.id, account, session=db.session()) + target_url = f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}" + return _redirect_with_console_session(account, target_url) try: account, oauth_new_user = _generate_account(provider, user_info, timezone=timezone, language=language) @@ -239,21 +264,10 @@ class OAuthCallback(Resource): "?message=Workspace not found, please contact system admin to invite you to join in a workspace." ) - token_pair = AccountService.login( - account=account, - session=db.session(), - ip_address=extract_remote_ip(request), - ) - target_url = _get_redirect_target(redirect_url) query_char = "&" if "?" in target_url else "?" target_url = f"{target_url}{query_char}oauth_new_user={str(oauth_new_user).lower()}" - response = redirect(target_url) - - set_access_token_to_cookie(request, response, token_pair.access_token) - set_refresh_token_to_cookie(request, response, token_pair.refresh_token) - set_csrf_token_to_cookie(request, response, token_pair.csrf_token) - return response + return _redirect_with_console_session(account, target_url) def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> Account | None: diff --git a/api/controllers/console/knowledge_fs_proxy.py b/api/controllers/console/knowledge_fs_proxy.py index f930c92a971..bf9ba1587a0 100644 --- a/api/controllers/console/knowledge_fs_proxy.py +++ b/api/controllers/console/knowledge_fs_proxy.py @@ -8,8 +8,9 @@ can be validated explicitly against the pinned KnowledgeFS contract during devel Console auth and contract-specific dataset RBAC run before forwarding. Request bodies are capped at 64 MiB, JSON and binary responses have separate bounds, SSE responses remain streaming with a bounded idle read timeout, and only safe -response headers are exposed. Upstream 401 responses become 502 so they cannot -trigger Dify browser-session recovery; resource-level 403 responses remain 403. +response headers are exposed. Operation-specific upstream error mappings are +applied before Console JSON error handling; the default maps 401 to 502 so it +cannot trigger browser-session recovery and preserves resource-level 403. """ from __future__ import annotations @@ -27,9 +28,11 @@ from werkzeug.exceptions import ( BadGateway, Forbidden, GatewayTimeout, + HTTPException, NotFound, RequestEntityTooLarge, ServiceUnavailable, + default_exceptions, ) from configs import dify_config @@ -41,21 +44,28 @@ from controllers.console.wraps import ( ) from core.helper import ssrf_proxy from libs.login import current_account_with_tenant, login_required +from services.knowledge_fs_operations import KnowledgeFSMethod from services.knowledge_fs_proxy import ( KnowledgeFSAccessDeniedError, + KnowledgeFSAuthorization, KnowledgeFSConfigurationError, - KnowledgeFSMethod, KnowledgeFSRouteNotAllowedError, KnowledgeFSTimeoutError, KnowledgeFSTransportError, KnowledgeFSUpstreamResponse, authorize_knowledge_fs_request, get_knowledge_fs_operation, + proxy_authorized_knowledge_fs_request, proxy_knowledge_fs_request, ) logger = logging.getLogger(__name__) +type _KnowledgeFSRequestForwarder = Callable[ + [str | None, str | None, bytes | None, bytes | None], + KnowledgeFSUpstreamResponse, +] + _MAX_PROXY_BODY_BYTES = 64 * 1024 * 1024 _RESPONSE_HEADER_ALLOWLIST = ( "Cache-Control", @@ -128,27 +138,25 @@ def _translate_proxy_error(exc: Exception, *, tenant_id: str) -> NoReturn: def _knowledge_fs_operation_access_required( - view: Callable[[KnowledgeFSMethod, str], ResponseReturnValue], + view: Callable[[KnowledgeFSAuthorization], ResponseReturnValue], ) -> Callable[[KnowledgeFSMethod, str], ResponseReturnValue]: """Authorize one declared operation before billing and request-body work.""" @wraps(view) def decorated(method: KnowledgeFSMethod, upstream_path: str) -> ResponseReturnValue: - try: - operation = get_knowledge_fs_operation(method, upstream_path) - except KnowledgeFSRouteNotAllowedError as exc: - raise NotFound() from exc - current_user, tenant_id = current_account_with_tenant() try: - authorize_knowledge_fs_request( + authorization = authorize_knowledge_fs_request( account=current_user, tenant_id=tenant_id, - operation=operation, + method=method, + path=upstream_path, ) + except KnowledgeFSRouteNotAllowedError as exc: + raise NotFound() from exc except KnowledgeFSAccessDeniedError as exc: _translate_proxy_error(exc, tenant_id=tenant_id) - return view(method, upstream_path) + return view(authorization) return decorated @@ -190,21 +198,26 @@ def _proxy_response( """Expose raw content, status, and allowlisted headers from KnowledgeFS. Raises: - BadGateway: KnowledgeFS rejects the configured server credential. - Forbidden: KnowledgeFS denies the account access to the requested resource. + HTTPException: KnowledgeFS returns a status normalized by the operation contract. """ upstream = upstream_result.response - if upstream.status_code == HTTPStatus.UNAUTHORIZED: + mapped_status = dict(upstream_result.operation.error_status_map).get(upstream.status_code) + if mapped_status is not None: upstream.close() - logger.error( - "KnowledgeFS rejected the Dify server credential with HTTP %s for tenant_id=%s", - upstream.status_code, - tenant_id, - ) - raise BadGateway("KnowledgeFS authentication failed") - if upstream.status_code == HTTPStatus.FORBIDDEN: - upstream.close() - raise Forbidden() + description = "KnowledgeFS upstream request failed" + if upstream.status_code == HTTPStatus.UNAUTHORIZED: + description = "KnowledgeFS authentication failed" + logger.error( + "KnowledgeFS rejected the Dify server credential with HTTP %s for tenant_id=%s", + upstream.status_code, + tenant_id, + ) + exception_type = default_exceptions.get(mapped_status) + if exception_type is None: + exception = HTTPException(description) + exception.code = mapped_status + raise exception + raise exception_type(description) allowed_header_names = dict.fromkeys( name.lower() for name in (*_RESPONSE_HEADER_ALLOWLIST, *contract_response_headers) @@ -237,26 +250,21 @@ def _proxy_response( return Response(content, status=upstream.status_code, headers=headers) -def _proxy_request(method: KnowledgeFSMethod, upstream_path: str) -> Response: - """Forward the current raw request and return its filtered upstream response. - - The call performs one outbound KnowledgeFS request. Integration failures are - converted to Console HTTP exceptions for the outer JSON error adapter. - """ +def _proxy_current_request( + *, + method: KnowledgeFSMethod, + tenant_id: str, + forward: _KnowledgeFSRequestForwarder, +) -> Response: + """Forward the current raw request through one preconfigured service entry.""" if not dify_config.KNOWLEDGE_FS_ENABLED: raise NotFound() - current_user, tenant_id = current_account_with_tenant() try: - proxy_result = proxy_knowledge_fs_request( - account=current_user, - method=method, - path=upstream_path, - tenant_id=tenant_id, - accept=request.headers.get("Accept"), - content_type=request.content_type, - query=request.query_string or None, - body=_request_body() if method != "GET" else None, - request_headers=request.headers, + proxy_result = forward( + request.headers.get("Accept"), + request.content_type, + request.query_string or None, + _request_body() if method != "GET" else None, ) except ( KnowledgeFSConfigurationError, @@ -274,20 +282,99 @@ def _proxy_request(method: KnowledgeFSMethod, upstream_path: str) -> Response: ) +def _proxy_request( + method: KnowledgeFSMethod, + upstream_path: str, +) -> Response: + """Authorize and forward the current request through the combined service use case.""" + if not dify_config.KNOWLEDGE_FS_ENABLED: + raise NotFound() + current_user, tenant_id = current_account_with_tenant() + + def forward( + accept: str | None, + content_type: str | None, + query: bytes | None, + body: bytes | None, + ) -> KnowledgeFSUpstreamResponse: + return proxy_knowledge_fs_request( + account=current_user, + method=method, + path=upstream_path, + tenant_id=tenant_id, + accept=accept, + content_type=content_type, + query=query, + body=body, + request_headers=request.headers, + ) + + return _proxy_current_request(method=method, tenant_id=tenant_id, forward=forward) + + +def _proxy_authorized_request(authorization: KnowledgeFSAuthorization) -> Response: + """Forward the current request using one previously authorized operation capability. + + Args: + authorization: Request-scoped capability produced before billing and body parsing. + + Returns: + The filtered response returned by KnowledgeFS. + + Raises: + HTTPException: The integration is disabled or forwarding fails. + """ + operation = authorization.operation + tenant_id = authorization.tenant_id + + def forward( + accept: str | None, + content_type: str | None, + query: bytes | None, + body: bytes | None, + ) -> KnowledgeFSUpstreamResponse: + return proxy_authorized_knowledge_fs_request( + authorization=authorization, + accept=accept, + content_type=content_type, + query=query, + body=body, + request_headers=request.headers, + ) + + return _proxy_current_request(method=operation.method, tenant_id=tenant_id, forward=forward) + + @_knowledge_fs_enabled @_knowledge_fs_operation_access_required @cloud_edition_billing_rate_limit_check("knowledge") def _proxy_knowledge_fs_non_get( - method: KnowledgeFSMethod, - upstream_path: str, + authorization: KnowledgeFSAuthorization, ) -> ResponseReturnValue: """Apply knowledge billing checks to one allowlisted non-GET operation.""" - return _proxy_request(method, upstream_path) + return _proxy_authorized_request(authorization) @bp.route( "/knowledge-fs/", - methods=["GET", "OPTIONS"], + methods=["OPTIONS"], + provide_automatic_options=False, +) +@_console_api_errors +@_knowledge_fs_enabled +def proxy_knowledge_fs_options(upstream_path: str) -> ResponseReturnValue: + """Complete a CORS preflight only for an enabled Console operation.""" + requested_method = cast(KnowledgeFSMethod, request.headers.get("Access-Control-Request-Method", "").upper()) + try: + get_knowledge_fs_operation(requested_method, upstream_path) + except KnowledgeFSRouteNotAllowedError as exc: + raise NotFound() from exc + return Response(status=HTTPStatus.NO_CONTENT) + + +@bp.route( + "/knowledge-fs/", + methods=["GET"], provide_automatic_options=False, ) @_console_api_errors diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index d87c1a99b0a..33a4f54e69b 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -67,6 +67,15 @@ from services.plugin.plugin_parameter_service import PluginParameterService from services.plugin.plugin_permission_service import PluginPermissionService from services.tools.tools_transform_service import ToolTransformService +_PLUGIN_PACKAGE_UPLOAD_PARAMS = { + "pkg": { + "description": "Plugin package to upload", + "in": "formData", + "type": "file", + "required": True, + } +} + class AutoUpgradeSettingsResponse(TypedDict): strategy_setting: TenantPluginAutoUpgradeStrategySetting @@ -645,6 +654,7 @@ class PluginAssetApi(Resource): @console_ns.route("/workspaces/current/plugin/upload/pkg") class PluginUploadFromPkgApi(Resource): + @console_ns.doc(consumes=["multipart/form-data"], params=_PLUGIN_PACKAGE_UPLOAD_PARAMS) @console_ns.response(200, "Success", console_ns.models[PluginDecodeResponse.__name__]) @setup_required @login_required diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index c2c56f9e6de..b5999bc093d 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -1,6 +1,6 @@ from typing import Any, Self -from pydantic import AliasChoices, Field, computed_field +from pydantic import AliasChoices, Field from sqlalchemy import select from werkzeug.exceptions import Forbidden @@ -9,11 +9,13 @@ from controllers.common.schema import register_response_schema_models from controllers.web import web_ns from controllers.web.wraps import WebApiResource from extensions.ext_database import db +from extensions.storage.storage_type import StorageType from fields.base import ResponseModel from libs.helper import build_icon_url from models.account import Tenant, TenantStatus -from models.model import App, EndUser, Site +from models.model import App, EndUser, IconType, Site from services.feature_service import FeatureModel, FeatureService +from services.file_service import FileService class WebSiteResponse(ResponseModel): @@ -32,11 +34,7 @@ class WebSiteResponse(ResponseModel): prompt_public: bool | None = None show_workflow_steps: bool | None = None use_icon_as_answer_icon: bool | None = None - - @computed_field(return_type=str | None) # type: ignore[prop-decorator] - @property - def icon_url(self) -> str | None: - return build_icon_url(self.icon_type, self.icon) + icon_url: str | None = None class WebModelConfigResponse(ResponseModel): @@ -88,6 +86,7 @@ class WebAppSiteResponse(ResponseModel): end_user_id: str | None, features: FeatureModel, can_replace_logo: bool, + icon_url: str | None = None, ) -> Self: custom_config = None if can_replace_logo: @@ -102,6 +101,7 @@ class WebAppSiteResponse(ResponseModel): ) site_response = WebSiteResponse.model_validate(site, from_attributes=True) + site_response.icon_url = icon_url if icon_url is not None else build_icon_url(site.icon_type, site.icon) if features.billing.enabled and not features.webapp_copyright_enabled: site_response.copyright = None site_response.input_placeholder = None @@ -123,6 +123,15 @@ register_response_schema_models( ) +def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None: + """Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere.""" + if site.icon_type != IconType.IMAGE or not site.icon: + return None + if dify_config.EDITION == "CLOUD" and StorageType(dify_config.STORAGE_TYPE) == StorageType.S3: + return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id) + return build_icon_url(site.icon_type, site.icon) + + @web_ns.route("/site") class AppSiteApi(WebApiResource): @web_ns.doc("Get App Site Info") @@ -159,4 +168,5 @@ class AppSiteApi(WebApiResource): end_user_id=end_user.id, features=features, can_replace_logo=features.can_replace_logo, + icon_url=_build_site_icon_url(site=site, tenant_id=tenant.id), ).model_dump(mode="json") diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 73a663f92b2..26e5d0cdcb6 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -682,42 +682,27 @@ class AgentAppGenerator(MessageBasedAppGenerator): if draft_type == AgentConfigDraftType.DEBUG_BUILD.value else AgentConfigDraftType.DRAFT ) + if effective_draft_type == AgentConfigDraftType.DRAFT: + from services.agent.composer_service import AgentComposerService + + return AgentComposerService.get_or_create_normal_agent_draft( + session=session, + tenant_id=tenant_id, + agent=agent, + created_by=agent.updated_by or agent.created_by, + ) + if not account_id: + raise AgentAppGeneratorError("Build draft requires an account user") stmt = select(AgentConfigDraft).where( AgentConfigDraft.tenant_id == tenant_id, AgentConfigDraft.agent_id == agent.id, - AgentConfigDraft.draft_type == effective_draft_type, + AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD, + AgentConfigDraft.account_id == account_id, ) - if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: - if not account_id: - raise AgentAppGeneratorError("Build draft requires an account user") - stmt = stmt.where(AgentConfigDraft.account_id == account_id) - else: - stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) draft = session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) if draft is not None: return draft - if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: - raise AgentAppGeneratorError("Agent build draft not found") - _, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id( - tenant_id=tenant_id, - agent_id=agent.id, - snapshot_id=agent.active_config_snapshot_id, - session=session, - ) - draft = AgentConfigDraft( - tenant_id=tenant_id, - agent_id=agent.id, - draft_type=AgentConfigDraftType.DRAFT, - account_id=None, - draft_owner_key="", - base_snapshot_id=snapshot.id, - config_snapshot=agent_soul, - created_by=agent.created_by, - updated_by=agent.updated_by, - ) - session.add(draft) - session.flush() - return draft + raise AgentAppGeneratorError("Agent build draft not found") @staticmethod def _resolve_agent_by_id( diff --git a/api/core/plugin/impl/base.py b/api/core/plugin/impl/base.py index 6977f643859..755c7362428 100644 --- a/api/core/plugin/impl/base.py +++ b/api/core/plugin/impl/base.py @@ -1,7 +1,7 @@ import inspect import json import logging -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, Mapping from typing import Any, cast from urllib.parse import unquote @@ -23,6 +23,7 @@ from core.plugin.impl.exc import ( PluginLLMPollingUnsupportedError, PluginNotFoundError, PluginPermissionDeniedError, + PluginRuntimeError, PluginUniqueIdentifierError, ) from core.trigger.errors import ( @@ -375,6 +376,18 @@ class BasePluginClient: # type `PluginLLMPollingUnsupportedError`. case PluginLLMPollingUnsupportedError.__name__: raise PluginLLMPollingUnsupportedError(description=error_object.get("message")) + case PluginRuntimeError.__name__: + args = error_object.get("args") + lambda_request_id = args.get("request_id") if isinstance(args, Mapping) else None + if not isinstance(lambda_request_id, str): + lambda_request_id = None + runtime_message = error_object.get("message") + if not isinstance(runtime_message, str): + runtime_message = "Plugin runtime request failed" + raise PluginRuntimeError( + description=runtime_message, + lambda_request_id=lambda_request_id, + ) case _: raise PluginInvokeError(description=message) case PluginDaemonInternalServerError.__name__: diff --git a/api/core/plugin/impl/exc.py b/api/core/plugin/impl/exc.py index abb9f0b1713..20ab58e281b 100644 --- a/api/core/plugin/impl/exc.py +++ b/api/core/plugin/impl/exc.py @@ -49,6 +49,18 @@ class PluginDaemonBadRequestError(PluginDaemonClientSideError): description: str = "Bad Request" +class PluginRuntimeError(PluginDaemonInternalError): + """A plugin runtime failed before it could return a valid plugin response.""" + + lambda_request_id: str | None + + def __init__(self, description: str, lambda_request_id: str | None = None) -> None: + self.lambda_request_id = lambda_request_id + if lambda_request_id: + description = description.replace(f"RequestId: {lambda_request_id} Error: ", "", 1) + super().__init__(description) + + class PluginInvokeError(PluginDaemonClientSideError, ValueError): description: str = "Invoke Error" diff --git a/api/core/tools/__base/tool.py b/api/core/tools/__base/tool.py index b16f80169fc..e023ce117b1 100644 --- a/api/core/tools/__base/tool.py +++ b/api/core/tools/__base/tool.py @@ -58,7 +58,6 @@ class Tool(ABC): if self.runtime and self.runtime.runtime_parameters: tool_parameters.update(self.runtime.runtime_parameters) - # try parse tool parameters into the correct type tool_parameters = self._transform_tool_parameters_type(tool_parameters) result = self._invoke( @@ -87,14 +86,14 @@ class Tool(ABC): return result def _transform_tool_parameters_type(self, tool_parameters: dict[str, Any]) -> dict[str, Any]: - """ - Transform tool parameters type - """ - # Temp fix for the issue that the tool parameters will be converted to empty while validating the credentials + """Transform declared tool parameter values without resolving runtime schemas.""" result = deepcopy(tool_parameters) for parameter in self.entity.parameters or []: if parameter.name in tool_parameters: - result[parameter.name] = parameter.type.cast_value(tool_parameters[parameter.name]) + if parameter.multiple: + result[parameter.name] = parameter.init_frontend_parameter(result.get(parameter.name)) + else: + result[parameter.name] = parameter.type.cast_value(tool_parameters[parameter.name]) return result @@ -196,17 +195,31 @@ class Tool(ABC): }: continue - parameter_schema: dict[str, Any] = ( - { - "type": parameter.type.as_normal_type(), - "description": parameter.llm_description or "", - } - if parameter.input_schema is None - else deepcopy(parameter.input_schema) - ) + is_multiple_select = parameter.multiple and parameter.type in { + ToolParameter.ToolParameterType.SELECT, + ToolParameter.ToolParameterType.DYNAMIC_SELECT, + } + if is_multiple_select: + item_schema: dict[str, Any] = {"type": "string"} + if parameter.type == ToolParameter.ToolParameterType.SELECT and parameter.options: + item_schema["enum"] = [option.value for option in parameter.options] + parameter_schema: dict[str, Any] = {"type": "array", "items": item_schema} + else: + parameter_schema = ( + { + "type": parameter.type.as_normal_type(), + "description": parameter.llm_description or "", + } + if parameter.input_schema is None + else deepcopy(parameter.input_schema) + ) parameter_schema.setdefault("description", parameter.llm_description or "") - if parameter.type == ToolParameter.ToolParameterType.SELECT and parameter.options: + if ( + not is_multiple_select + and parameter.type == ToolParameter.ToolParameterType.SELECT + and parameter.options + ): parameter_schema["enum"] = [option.value for option in parameter.options] schema["properties"][parameter.name] = parameter_schema diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index 0c77693dde4..786910d91d4 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -292,9 +292,7 @@ class ToolInvokeMessageBinary(BaseModel): class ToolParameter(PluginParameter): - """ - Overrides type - """ + """Tool-specific parameter declaration and invocation-value normalization.""" class ToolParameterType(StrEnum): """ @@ -333,12 +331,28 @@ class ToolParameter(PluginParameter): LLM = auto() # will be set by LLM type: ToolParameterType = Field(..., description="The type of the parameter") + multiple: bool = Field( + default=False, + description="Whether the parameter is multiple select, only valid for select or dynamic-select type", + ) human_description: I18nObject | None = Field(default=None, description="The description presented to the user") form: ToolParameterForm = Field(..., description="The form of the parameter, schema/form/llm") llm_description: str | None = None # MCP object and array type parameters use this field to store the schema input_schema: dict[str, Any] | None = None + @model_validator(mode="after") + def validate_multiple(self) -> ToolParameter: + supports_multiple = self.type in { + self.ToolParameterType.SELECT, + self.ToolParameterType.DYNAMIC_SELECT, + } + if self.multiple and not supports_multiple: + raise ValueError("multiple is only valid for select and dynamic-select parameters") + if supports_multiple and self.default is not None and (isinstance(self.default, list) != self.multiple): + raise ValueError("default must be a list exactly when multiple is true") + return self + @classmethod def get_simple_instance( cls, @@ -378,8 +392,25 @@ class ToolParameter(PluginParameter): options=option_objs, ) - def init_frontend_parameter(self, value: Any): - return init_frontend_parameter(self, self.type, value) + def init_frontend_parameter(self, value: Any) -> Any: + """Normalize a value against this tool parameter's full declaration.""" + if not self.multiple: + return init_frontend_parameter(self, self.type, value) + + parameter_value = self.default if value is None else value + if parameter_value is None: + parameter_value = [] + if not isinstance(parameter_value, list): + raise ValueError(f"tool parameter {self.name} must be a list when multiple is true") + if not all(isinstance(item, str) for item in parameter_value): + raise ValueError(f"tool parameter {self.name} must contain only strings") + if self.required and not parameter_value: + raise ValueError(f"tool parameter {self.name} not found in tool config") + if self.type == self.ToolParameterType.SELECT: + options = [option.value for option in self.options] + if any(item not in options for item in parameter_value): + raise ValueError(f"tool parameter {self.name} value {parameter_value} not in options {options}") + return parameter_value class ToolProviderIdentity(BaseModel): diff --git a/api/dev/generate_knowledge_fs_contract.py b/api/dev/generate_knowledge_fs_contract.py index cd4e7f9aef1..d2a06a6db0f 100644 --- a/api/dev/generate_knowledge_fs_contract.py +++ b/api/dev/generate_knowledge_fs_contract.py @@ -12,6 +12,7 @@ import json import subprocess import sys import tempfile +from copy import deepcopy from pathlib import Path from typing import Any, Literal, TypedDict @@ -24,6 +25,16 @@ LOCK_PATH = API_ROOT / "knowledge-fs-contract.lock.json" DEFAULT_REPOSITORY = WORKSPACE_ROOT.parent / "knowledge-fs" OPENAPI_METHODS = ("delete", "get", "head", "options", "patch", "post", "put", "trace") PROXY_METHODS = frozenset({"delete", "get", "patch", "post", "put"}) +CONSOLE_PROXY_ERROR_SCHEMA_NAME = "ConsoleProxyError" +CONSOLE_PROXY_ERROR_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["code", "message", "status"], + "properties": { + "code": {"type": "string"}, + "message": {"type": "string"}, + "status": {"type": "integer"}, + }, +} class ContractDeclaration(TypedDict): @@ -38,6 +49,7 @@ class ContractDeclaration(TypedDict): request_headers: tuple[str, ...] response_headers: tuple[str, ...] response_media_types: tuple[str, ...] + error_status_map: tuple[tuple[int, int], ...] type DeclarationField = Literal[ @@ -70,6 +82,7 @@ def main() -> None: mode.add_argument("--check", action="store_true") mode.add_argument("--update-lock", action="store_true") parser.add_argument("--repository", type=Path, default=DEFAULT_REPOSITORY) + parser.add_argument("--output-openapi", type=Path) args = parser.parse_args() repository = args.repository.resolve() @@ -101,7 +114,15 @@ def main() -> None: ) document: dict[str, Any] = json.loads(openapi_content) - validate_declarations(document, console_contract_declarations()) + declarations = console_contract_declarations() + validate_declarations(document, declarations) + + if args.output_openapi: + filtered_document = filter_openapi_document(document, declarations) + filtered_document["x-dify-source-openapi-sha256"] = openapi_sha256 + filtered_document["x-dify-console-declarations-sha256"] = contract_declarations_sha256(declarations) + args.output_openapi.parent.mkdir(parents=True, exist_ok=True) + args.output_openapi.write_text(json.dumps(filtered_document, indent=2) + "\n") if args.update_lock: LOCK_PATH.write_text( @@ -147,8 +168,7 @@ def validate_declarations(document: dict[str, Any], declarations: tuple[Contract raise ValueError(f"KnowledgeFS OpenAPI path must be absolute: {path}") if method not in PROXY_METHODS: raise ValueError(f"KnowledgeFS proxy does not support {method.upper()} {path}") - expected: ContractDeclaration = { - "operation_id": operation_id, + expected: dict[DeclarationField, object] = { "method": method.upper(), "path": path[1:], "required_scope": required_scope(operation), @@ -166,11 +186,114 @@ def validate_declarations(document: dict[str, Any], declarations: tuple[Contract f"KnowledgeFS operation {operation_id} field {field} drifted: " f"expected {expected_value!r}, received {received_value!r}" ) + validate_error_status_map(operation_id, declaration["error_status_map"]) + + +def filter_openapi_document( + document: dict[str, Any], + declarations: tuple[ContractDeclaration, ...], +) -> dict[str, Any]: + """Return a code-generation document containing only Console-allowlisted operations.""" + filtered_document: dict[str, Any] = { + key: value for key, value in document.items() if key not in {"components", "paths"} + } + source_paths = document.get("paths", {}) + filtered_paths: dict[str, Any] = {} + + for declaration in declarations: + path = f"/{declaration['path']}" + method = declaration["method"].lower() + source_path_item = source_paths[path] + path_metadata = {key: value for key, value in source_path_item.items() if key not in OPENAPI_METHODS} + filtered_path_item = filtered_paths.setdefault(path, path_metadata) + filtered_operation = deepcopy(source_path_item[method]) + _rewrite_proxy_error_responses(filtered_operation, declaration["error_status_map"]) + filtered_path_item[method] = filtered_operation + + filtered_document["paths"] = filtered_paths + + source_components = document.get("components", {}) + filtered_components = {key: value for key, value in source_components.items() if key != "schemas"} + source_schemas = source_components.get("schemas", {}) + available_schemas = {**source_schemas, CONSOLE_PROXY_ERROR_SCHEMA_NAME: CONSOLE_PROXY_ERROR_SCHEMA} + schema_names = _referenced_schema_names(filtered_paths, available_schemas) + filtered_components["schemas"] = { + name: schema for name, schema in available_schemas.items() if name in schema_names + } + filtered_document["components"] = filtered_components + return filtered_document + + +def validate_error_status_map(operation_id: str, error_status_map: tuple[tuple[int, int], ...]) -> None: + """Validate the status normalization advertised by one Console operation.""" + upstream_statuses: set[int] = set() + for upstream_status, console_status in error_status_map: + if upstream_status in upstream_statuses: + raise ValueError(f"KnowledgeFS operation {operation_id} has duplicate error status: {upstream_status}") + if not 400 <= upstream_status <= 599 or not 400 <= console_status <= 599: + raise ValueError(f"KnowledgeFS operation {operation_id} has invalid error status mapping") + upstream_statuses.add(upstream_status) + + +def _rewrite_proxy_error_responses( + operation: dict[str, Any], + error_status_map: tuple[tuple[int, int], ...], +) -> None: + responses = operation.setdefault("responses", {}) + proxy_error_response = { + "description": "Error normalized by the Dify Console KnowledgeFS proxy.", + "content": { + "application/json": {"schema": {"$ref": f"#/components/schemas/{CONSOLE_PROXY_ERROR_SCHEMA_NAME}"}} + }, + } + for upstream_status, console_status in error_status_map: + existing_target = responses.get(str(console_status)) if upstream_status != console_status else None + responses.pop(str(upstream_status), None) + normalized_response: dict[str, Any] = deepcopy(proxy_error_response) + existing_schema = ( + existing_target.get("content", {}).get("application/json", {}).get("schema") + if isinstance(existing_target, dict) + else None + ) + if existing_schema is not None: + normalized_response["content"]["application/json"]["schema"] = { + "oneOf": [ + deepcopy(existing_schema), + {"$ref": f"#/components/schemas/{CONSOLE_PROXY_ERROR_SCHEMA_NAME}"}, + ] + } + responses[str(console_status)] = normalized_response + + +def _referenced_schema_names(value: Any, schemas: dict[str, Any]) -> set[str]: + reference_prefix = "#/components/schemas/" + selected: set[str] = set() + pending: list[Any] = [value] + + while pending: + current = pending.pop() + if isinstance(current, list): + pending.extend(current) + continue + if not isinstance(current, dict): + continue + + reference = current.get("$ref") + if isinstance(reference, str) and reference.startswith(reference_prefix): + name = reference.removeprefix(reference_prefix) + if name not in selected: + if name not in schemas: + raise ValueError(f"KnowledgeFS OpenAPI references missing schema: {name}") + selected.add(name) + pending.append(schemas[name]) + pending.extend(current.values()) + + return selected def console_contract_declarations() -> tuple[ContractDeclaration, ...]: """Return transport declarations from the runtime Console operation registry.""" - from services.knowledge_fs_proxy import KNOWLEDGE_FS_CONSOLE_OPERATIONS + from services.knowledge_fs_operations import KNOWLEDGE_FS_CONSOLE_OPERATIONS return tuple( { @@ -183,11 +306,18 @@ def console_contract_declarations() -> tuple[ContractDeclaration, ...]: "request_headers": operation.request_headers, "response_headers": operation.response_headers, "response_media_types": operation.response_media_types, + "error_status_map": operation.error_status_map, } for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS ) +def contract_declarations_sha256(declarations: tuple[ContractDeclaration, ...]) -> str: + """Return a stable digest for the runtime Console operation declarations.""" + content = json.dumps(declarations, separators=(",", ":"), sort_keys=True).encode() + return sha256(content) + + def response_kind(operation: dict[str, Any]) -> str: media_types = response_media_types(operation) if "text/event-stream" in media_types: diff --git a/api/extensions/ext_storage.py b/api/extensions/ext_storage.py index db5a6e48124..bc8b83b268b 100644 --- a/api/extensions/ext_storage.py +++ b/api/extensions/ext_storage.py @@ -119,6 +119,19 @@ class Storage: def delete(self, filename: str): return self.storage_runner.delete(filename) + def generate_presigned_url( + self, + filename: str, + *, + expires_in: int, + content_type: str | None = None, + ) -> str: + return self.storage_runner.generate_presigned_url( + filename, + expires_in=expires_in, + content_type=content_type, + ) + def scan(self, path: str, files: bool = True, directories: bool = False) -> list[str]: return self.storage_runner.scan(path, files=files, directories=directories) diff --git a/api/extensions/storage/aws_s3_storage.py b/api/extensions/storage/aws_s3_storage.py index 018aa17ac45..cf69f82bce1 100644 --- a/api/extensions/storage/aws_s3_storage.py +++ b/api/extensions/storage/aws_s3_storage.py @@ -92,3 +92,21 @@ class AwsS3Storage(BaseStorage): @override def delete(self, filename: str): self.client.delete_object(Bucket=self.bucket_name, Key=filename) + + @override + def generate_presigned_url( + self, + filename: str, + *, + expires_in: int, + content_type: str | None = None, + ) -> str: + params = {"Bucket": self.bucket_name, "Key": filename} + if content_type: + params["ResponseContentType"] = content_type + + return self.client.generate_presigned_url( + "get_object", + Params=params, + ExpiresIn=expires_in, + ) diff --git a/api/extensions/storage/base_storage.py b/api/extensions/storage/base_storage.py index a73d429ccd2..d9c3ff6c4c3 100644 --- a/api/extensions/storage/base_storage.py +++ b/api/extensions/storage/base_storage.py @@ -31,6 +31,16 @@ class BaseStorage(ABC): def delete(self, filename: str): raise NotImplementedError + def generate_presigned_url( + self, + filename: str, + *, + expires_in: int, + content_type: str | None = None, + ) -> str: + """Generate a temporary direct-download URL when the backend supports it.""" + raise NotImplementedError("This storage backend doesn't support presigned URLs") + def scan(self, path, files=True, directories=False) -> list[str]: """ Scan files and directories in the given path. diff --git a/api/knowledge-fs-contract.lock.json b/api/knowledge-fs-contract.lock.json index b45b5e9ec4b..b908f81da62 100644 --- a/api/knowledge-fs-contract.lock.json +++ b/api/knowledge-fs-contract.lock.json @@ -1,5 +1,5 @@ { - "commit": "4310e2d582d25e7de58183f27720afab01e123cf", - "openapiSha256": "5827ca930ce38462bfd1b2bef387efbf37eb7ffcaedde4558af2fbaeccbfbc4b", + "commit": "a0f50470612cc0b3656f89e4f2435aaf412e6e3b", + "openapiSha256": "f18910e9c45a64f0855e0643a7a626fb2889021b4f943458de86c6bd2469facb", "repository": "https://github.com/langgenius/knowledge-fs" } diff --git a/api/libs/external_api.py b/api/libs/external_api.py index 06419b16f88..271023750ca 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -9,6 +9,8 @@ from werkzeug.http import HTTP_STATUS_CODES from configs import dify_config from core.errors.error import AppInvokeQuotaExceededError +from core.plugin.impl.exc import PluginRuntimeError +from extensions.ext_logging import get_request_id from libs.flask_restx_compat import install_swagger_compatibility from libs.token import build_force_logout_cookie_headers @@ -100,6 +102,20 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte data = {"code": "too_many_requests", "message": str(e), "status": status_code} return _finalize(e, data, status_code), status_code + def handle_plugin_runtime_error(e: PluginRuntimeError): + got_request_exception.send(current_app, exception=e) + status_code = 502 + details = {"request_id": get_request_id()} + if e.lambda_request_id: + details["lambda_request_id"] = e.lambda_request_id + data = { + "code": "plugin_runtime_error", + "message": e.description, + "details": details, + "status": status_code, + } + return _finalize(e, data, status_code), status_code + def handle_general_exception(e: Exception): got_request_exception.send(current_app, exception=e) @@ -121,6 +137,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte api.errorhandler(HTTPException)(handle_http_exception) api.errorhandler(ValueError)(handle_value_error) api.errorhandler(AppInvokeQuotaExceededError)(handle_quota_exceeded) + api.errorhandler(PluginRuntimeError)(handle_plugin_runtime_error) api.errorhandler(Exception)(handle_general_exception) diff --git a/api/libs/helper.py b/api/libs/helper.py index 7066f9eab45..752342bfad6 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -221,8 +221,11 @@ def current_timestamp() -> int: def email(email): # Define a regex pattern for email addresses pattern = r"^[\w\.!#$%&'*+\-/=?^_`{|}~]+@([\w-]+\.)+[\w-]{2,}$" - # Check if the email matches the pattern - if re.match(pattern, email) is not None: + # Use re.fullmatch instead of re.match to reject trailing newlines. + # In Python, '$' matches at end-of-string OR just before a trailing newline, + # so re.match accepts "user@example.com\n". re.fullmatch requires the entire + # string to match, closing the mail header-injection vector. (#39234) + if re.fullmatch(pattern, email) is not None: return email error = f"{email} is not a valid email." diff --git a/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py b/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py new file mode 100644 index 00000000000..2e3e41e2b82 --- /dev/null +++ b/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py @@ -0,0 +1,77 @@ +"""scope agent debug conversations by draft type + +Revision ID: d2825e7b9c10 +Revises: b8c9d0e1f2a3 +Create Date: 2026-07-22 15:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "d2825e7b9c10" +down_revision = "b8c9d0e1f2a3" +branch_labels = None +depends_on = None + + +def upgrade(): + # Existing pointers have always represented Build chat because the Agent + # detail API exposes them as ``debug_conversation_id`` for that surface. + op.add_column( + "agent_debug_conversations", + sa.Column( + "draft_type", + sa.String(length=32), + nullable=False, + server_default=sa.text("'debug_build'"), + ), + ) + op.drop_constraint( + "agent_debug_conversation_agent_account_unique", + "agent_debug_conversations", + type_="unique", + ) + op.create_unique_constraint( + "agent_debug_conversation_agent_account_draft_type_unique", + "agent_debug_conversations", + ["tenant_id", "agent_id", "account_id", "draft_type"], + ) + + +def downgrade(): + debug_conversations = sa.table( + "agent_debug_conversations", + sa.column("tenant_id", models.types.StringUUID()), + sa.column("agent_id", models.types.StringUUID()), + sa.column("account_id", models.types.StringUUID()), + sa.column("draft_type", sa.String(length=32)), + ) + build_conversations = debug_conversations.alias("build_conversations") + op.get_bind().execute( + sa.delete(debug_conversations).where( + debug_conversations.c.draft_type == "draft", + sa.exists( + sa.select(sa.literal(1)).where( + build_conversations.c.tenant_id == debug_conversations.c.tenant_id, + build_conversations.c.agent_id == debug_conversations.c.agent_id, + build_conversations.c.account_id == debug_conversations.c.account_id, + build_conversations.c.draft_type == "debug_build", + ) + ), + ) + ) + op.drop_constraint( + "agent_debug_conversation_agent_account_draft_type_unique", + "agent_debug_conversations", + type_="unique", + ) + op.create_unique_constraint( + "agent_debug_conversation_agent_account_unique", + "agent_debug_conversations", + ["tenant_id", "agent_id", "account_id"], + ) + op.drop_column("agent_debug_conversations", "draft_type") diff --git a/api/models/agent.py b/api/models/agent.py index 467d7a4b753..cd3d371481d 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -222,11 +222,13 @@ class Agent(DefaultFieldsMixin, Base): class AgentDebugConversation(DefaultFieldsMixin, Base): - """Per-account console debug conversation for an Agent App. + """Per-account, per-draft console debug conversation for an Agent App. Agent App preview state must be isolated by editor account. The Agent row is shared by everyone in the workspace, so this table owns the user-specific - conversation pointer used by console debug chat. + conversation pointers used by console debug chat. ``draft`` is the Preview + conversation and ``debug_build`` is the Build conversation; they must never + share persisted messages or runtime sessions. """ __tablename__ = "agent_debug_conversations" @@ -236,7 +238,8 @@ class AgentDebugConversation(DefaultFieldsMixin, Base): "tenant_id", "agent_id", "account_id", - name="agent_debug_conversation_agent_account_unique", + "draft_type", + name="agent_debug_conversation_agent_account_draft_type_unique", ), Index("agent_debug_conversation_conversation_idx", "conversation_id"), Index("agent_debug_conversation_account_idx", "tenant_id", "account_id"), @@ -246,6 +249,12 @@ class AgentDebugConversation(DefaultFieldsMixin, Base): agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + draft_type: Mapped[AgentConfigDraftType] = mapped_column( + EnumText(AgentConfigDraftType, length=32), + nullable=False, + default=AgentConfigDraftType.DEBUG_BUILD, + server_default=sa.text("'debug_build'"), + ) conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False) diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index a47129dcc20..a6a0cd544a2 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -44,18 +44,28 @@ _DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA = { }, "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "required": {"type": "boolean"}, - "file": {"type": "object", "additionalProperties": True}, + "file": { + "anyOf": [ + {"type": "object", "additionalProperties": True}, + {"type": "null"}, + ] + }, "array_item": { - "type": "object", - "additionalProperties": True, - "properties": { - "type": { - "type": "string", - "enum": [item.value for item in DeclaredOutputType], + "anyOf": [ + { + "type": "object", + "additionalProperties": True, + "properties": { + "type": { + "type": "string", + "enum": [item.value for item in DeclaredOutputType], + }, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + }, }, - "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, - "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, - }, + {"type": "null"}, + ] }, "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, }, diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index ecefaac1954..15562e91955 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -260,7 +260,12 @@ Get account avatar url | 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [POST] /activate +**Accept an invitation without letting an existing session act for another account** + Activate account with invitation token +Token-only activation remains available for legacy clients. When the request already +carries a console session, that session must belong to the account encoded in the +invitation before the token is consumed or tenant membership is changed. #### Request Body @@ -531,6 +536,7 @@ Run a build-draft Agent App turn that asks the agent to push config updates | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Agent build draft | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)
| +| 404 | Agent build draft not found | | ### [PUT] /agent/{agent_id}/build-draft #### Parameters @@ -958,6 +964,12 @@ Stop a running Agent App chat message generation | ---- | ---------- | ----------- | -------- | ------ | | agent_id | path | | Yes | string (uuid) | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| No | **application/json**: [AgentDebugConversationRefreshPayload](#agentdebugconversationrefreshpayload)
| + #### Responses | Code | Description | Schema | @@ -11332,6 +11344,12 @@ Returns permission flags that control workspace features like member invitations | 200 | Success | **application/json**: [PluginDecodeResponse](#plugindecoderesponse)
| ### [POST] /workspaces/current/plugin/upload/pkg +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"pkg"**: binary }
| + #### Responses | Code | Description | Schema | @@ -13278,7 +13296,7 @@ Model class for AI model. | maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | -| model_config | [ModelConfig](#modelconfig) | | No | +| model_config | [AppModelConfigResponse](#appmodelconfigresponse) | | No | | name | string | | Yes | | permission_keys | [ string ] | | No | | role | string | | No | @@ -13899,6 +13917,12 @@ Stable Agent Soul reference to one normalized skill archive. | date | string | | Yes | | message_count | integer | | Yes | +#### AgentDebugConversationRefreshPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | Agent draft surface whose conversation should be refreshed | No | + #### AgentDebugConversationRefreshResponse | Name | Type | Description | Required | @@ -15348,7 +15372,6 @@ This class is used to store the schema information of an api based tool. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | access_mode | string | | No | -| app_model_config | [ModelConfig](#modelconfig) | | No | | created_at | integer | | No | | created_by | string | | No | | description | string | | No | @@ -15358,7 +15381,8 @@ This class is used to store the schema information of an api based tool. | icon_background | string | | No | | id | string | | Yes | | maintainer | string | | No | -| mode_compatible_with_agent | string | | Yes | +| mode | string | | Yes | +| model_config | [AppModelConfigResponse](#appmodelconfigresponse) | | No | | name | string | | Yes | | permission_keys | [ string ] | | No | | tags | [ [Tag](#tag) ] | | No | @@ -15420,7 +15444,7 @@ This class is used to store the schema information of an api based tool. | maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | -| model_config | [ModelConfig](#modelconfig) | | No | +| model_config | [AppModelConfigResponse](#appmodelconfigresponse) | | No | | name | string | | Yes | | permission_keys | [ string ] | | No | | site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No | @@ -15519,6 +15543,35 @@ AppMCPServer Status Enum | ---- | ---- | ----------- | -------- | | AppMCPServerStatus | string | AppMCPServer Status Enum | | +#### AppModelConfigResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_mode | | | No | +| annotation_reply | | | No | +| chat_prompt_config | | | No | +| completion_prompt_config | | | No | +| created_at | integer | | No | +| created_by | string | | No | +| dataset_configs | | | No | +| dataset_query_variable | string | | No | +| external_data_tools | | | No | +| file_upload | | | No | +| model | | | No | +| more_like_this | | | No | +| opening_statement | string | | No | +| pre_prompt | string | | No | +| prompt_type | string | | No | +| retriever_resource | | | No | +| sensitive_word_avoidance | | | No | +| speech_to_text | | | No | +| suggested_questions | | | No | +| suggested_questions_after_answer | | | No | +| text_to_speech | | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| user_input_form | | | No | + #### AppNamePayload | Name | Type | Description | Required | @@ -17147,7 +17200,7 @@ about. Stage 4 §4.2. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | +| children | [ { **"array_item"**: , **"children"**: [ object ], **"description"**: , **"file"**: , **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | type | [DeclaredOutputType](#declaredoutputtype) | | Yes | @@ -17176,7 +17229,7 @@ code can call ``output.failure_strategy.on_failure`` without None-guards. | ---- | ---- | ----------- | -------- | | array_item | [DeclaredArrayItem](#declaredarrayitem) | | No | | check | [DeclaredOutputCheckConfig](#declaredoutputcheckconfig) | | No | -| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | +| children | [ { **"array_item"**: , **"children"**: [ object ], **"description"**: , **"file"**: , **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No | | file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No | @@ -21954,9 +22007,9 @@ The subscription constructor of the trigger provider | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| hash | string | | No | -| result | string | | No | -| updated_at | string | | No | +| hash | string | | Yes | +| result | string | | Yes | +| updated_at | integer | | Yes | #### SystemConfigurationResponse @@ -21988,6 +22041,7 @@ Model class for provider system configuration response. | is_allow_create_workspace | boolean | | Yes | | is_allow_register | boolean | | Yes | | is_email_setup | boolean | | Yes | +| knowledge_fs_enabled | boolean | | Yes | | license | [LicenseModel](#licensemodel) | | Yes | | max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | | plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | @@ -22281,7 +22335,7 @@ Tool label #### ToolParameter -Overrides type +Tool-specific parameter declaration and invocation-value normalization. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -22294,6 +22348,7 @@ Overrides type | llm_description | string | | No | | max | number
integer | | No | | min | number
integer | | No | +| multiple | boolean | Whether the parameter is multiple select, only valid for select or dynamic-select type | No | | name | string | The name of the parameter | Yes | | options | [ [PluginParameterOption](#pluginparameteroption) ] | | No | | placeholder | [I18nObject](#i18nobject) | The placeholder presented to the user | No | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 44296235014..b826b5fe8e0 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1582,6 +1582,7 @@ Default configuration for form inputs. | is_allow_create_workspace | boolean | | Yes | | is_allow_register | boolean | | Yes | | is_email_setup | boolean | | Yes | +| knowledge_fs_enabled | boolean | | Yes | | license | [LicenseModel](#licensemodel) | | Yes | | max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | | plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | @@ -1733,7 +1734,7 @@ in form definiton, or a variable while the workflow is running. | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | -| icon_url | string | | Yes | +| icon_url | string | | No | | input_placeholder | string | | No | | privacy_policy | string | | No | | prompt_public | boolean | | No | diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index b9cd1db148c..48bb6d54a4f 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -1164,6 +1164,20 @@ class AgentComposerService: agent.active_config_is_published = True agent.updated_by = account_id binding.current_snapshot_id = version.id + normal_draft = cls._get_agent_draft( + session=session, + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + ) + if normal_draft is not None and cls._rebase_workflow_only_normal_draft( + agent=agent, + draft=normal_draft, + snapshot=version, + updated_by=account_id, + ): + session.flush() binding.updated_by = account_id return binding @@ -1748,6 +1762,47 @@ class AgentComposerService: stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) return session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) + @classmethod + def get_or_create_normal_agent_draft( + cls, + *, + session: Session, + tenant_id: str, + agent: Agent, + created_by: str | None, + ) -> AgentConfigDraft: + """Resolve the shared Preview draft, rebasing inline agents when needed.""" + return cls._get_or_create_agent_draft( + session=session, + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=created_by, + ) + + @staticmethod + def _rebase_workflow_only_normal_draft( + *, + agent: Agent, + draft: AgentConfigDraft, + snapshot: AgentConfigSnapshot, + updated_by: str | None, + ) -> bool: + if ( + agent.scope != AgentScope.WORKFLOW_ONLY + or draft.draft_type != AgentConfigDraftType.DRAFT + or draft.account_id is not None + or not agent.active_config_snapshot_id + or draft.base_snapshot_id == agent.active_config_snapshot_id + or snapshot.id != agent.active_config_snapshot_id + ): + return False + draft.base_snapshot_id = snapshot.id + draft.config_snapshot = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + draft.updated_by = updated_by + return True + @classmethod def _get_or_create_agent_draft( cls, @@ -1767,6 +1822,26 @@ class AgentComposerService: account_id=account_id, ) if draft is not None: + if ( + agent.scope == AgentScope.WORKFLOW_ONLY + and draft_type == AgentConfigDraftType.DRAFT + and draft.account_id is None + and agent.active_config_snapshot_id + and draft.base_snapshot_id != agent.active_config_snapshot_id + ): + active_snapshot = cls._get_version_if_present( + session=session, + tenant_id=tenant_id, + agent_id=agent.id, + version_id=agent.active_config_snapshot_id, + ) + if active_snapshot is not None and cls._rebase_workflow_only_normal_draft( + agent=agent, + draft=draft, + snapshot=active_snapshot, + updated_by=agent.updated_by or agent.created_by, + ): + session.flush() return draft base_snapshot = cls._get_version_if_present( session=session, diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index c6087843ae0..3d9401facf3 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -430,7 +430,11 @@ class AgentRosterService: agent.active_config_has_model = agent_soul_has_model(soul) agent.active_config_is_published = False self._session.flush() - self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) + self._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id=account_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + ) return agent def create_hidden_backing_app_for_workflow_agent( @@ -527,7 +531,11 @@ class AgentRosterService: self._session.flush() return backing_app.id - def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str: + def _get_or_create_agent_app_debug_conversation( + self, *, agent: Agent, account_id: str, draft_type: AgentConfigDraftType + ) -> str: + """Return the editor's conversation for one Agent draft surface.""" + backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id) if not backing_app_id: raise AgentNotFoundError() @@ -537,6 +545,7 @@ class AgentRosterService: AgentDebugConversation.tenant_id == agent.tenant_id, AgentDebugConversation.agent_id == agent.id, AgentDebugConversation.account_id == account_id, + AgentDebugConversation.draft_type == draft_type, ) ) if mapping is not None: @@ -570,6 +579,7 @@ class AgentRosterService: agent_id=agent.id, app_id=backing_app_id, account_id=account_id, + draft_type=draft_type, conversation_id=conversation_id, ) ) @@ -577,9 +587,15 @@ class AgentRosterService: return conversation_id def get_or_create_agent_app_debug_conversation_id( - self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, + commit: bool = True, ) -> str: - """Return the current editor's debug conversation for an Agent App.""" + """Return the current editor's Build or Preview conversation for an Agent App.""" agent = self._session.scalar( select(Agent).where( @@ -591,13 +607,24 @@ class AgentRosterService: if agent is None: raise AgentNotFoundError() - conversation_id = self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) + conversation_id = self._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id=account_id, + draft_type=draft_type, + ) if commit: self._session.commit() return conversation_id - def load_agent_app_debug_conversation_id(self, *, tenant_id: str, agent_id: str, account_id: str) -> str | None: - """Return the current editor's existing debug conversation without creating or repairing rows.""" + def load_agent_app_debug_conversation_id( + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, + ) -> str | None: + """Return the editor's existing scoped conversation without creating or repairing rows.""" return self._session.scalar( select(Conversation.id) @@ -606,6 +633,7 @@ class AgentRosterService: AgentDebugConversation.tenant_id == tenant_id, AgentDebugConversation.agent_id == agent_id, AgentDebugConversation.account_id == account_id, + AgentDebugConversation.draft_type == draft_type, AgentDebugConversation.app_id == Conversation.app_id, Conversation.from_source == ConversationFromSource.CONSOLE, Conversation.from_account_id == account_id, @@ -626,16 +654,21 @@ class AgentRosterService: ) def refresh_agent_app_debug_conversation_id( - self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, + commit: bool = True, ) -> str: - """Start a new console debug conversation for the current Agent App editor. + """Start a new scoped console conversation for the current Agent App editor. - If this account already has a debug conversation mapping, the previous + If this account already has a mapping for the requested draft surface, the previous conversation is abandoned first: any ACTIVE conversation-owned Agent runtime sessions for that old conversation are sent through best-effort - backend cleanup and then retired locally even when enqueueing fails. - The debug mapping is then repointed to the freshly created - conversation. + backend cleanup and then retired locally even when enqueueing fails. The + other draft surface is left untouched. """ agent = self._session.scalar( @@ -663,6 +696,7 @@ class AgentRosterService: AgentDebugConversation.tenant_id == tenant_id, AgentDebugConversation.agent_id == agent_id, AgentDebugConversation.account_id == account_id, + AgentDebugConversation.draft_type == draft_type, ) ) if mapping is None: @@ -672,6 +706,7 @@ class AgentRosterService: agent_id=agent_id, app_id=backing_app_id, account_id=account_id, + draft_type=draft_type, conversation_id=conversation_id, ) ) @@ -683,6 +718,7 @@ class AgentRosterService: tenant_id=tenant_id, agent_id=agent_id, account_id=account_id, + draft_type=draft_type, app_id=previous_app_id or backing_app_id, conversation_id=previous_conversation_id, ) @@ -699,6 +735,7 @@ class AgentRosterService: tenant_id: str, agent_id: str, account_id: str, + draft_type: AgentConfigDraftType, app_id: str, conversation_id: str, ) -> None: @@ -727,7 +764,8 @@ class AgentRosterService: session_snapshot=stored_session.session_snapshot, runtime_layer_specs=stored_session.runtime_layer_specs, idempotency_key=( - f"{tenant_id}:{agent_id}:{account_id}:{conversation_id}:debug-session-cleanup:" + f"{tenant_id}:{agent_id}:{account_id}:{draft_type.value}:{conversation_id}:" + "debug-session-cleanup:" f"{stored_session.scope.agent_id}:" f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:" f"{stored_session.backend_run_id or 'no-run'}" @@ -738,6 +776,7 @@ class AgentRosterService: "conversation_id": stored_session.scope.conversation_id, "agent_id": stored_session.scope.agent_id, "agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id, + "draft_type": draft_type.value, "previous_agent_backend_run_id": stored_session.backend_run_id, }, ) @@ -772,9 +811,14 @@ class AgentRosterService: ) def load_or_create_agent_app_debug_conversation_ids_by_agent_id( - self, *, tenant_id: str, agents: list[Agent], account_id: str + self, + *, + tenant_id: str, + agents: list[Agent], + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, ) -> dict[str, str]: - """Return per-account debug conversations for a page of Agent Apps.""" + """Return per-account scoped conversations for a page of Agent Apps.""" conversation_ids_by_agent_id: dict[str, str] = {} changed = False @@ -784,6 +828,7 @@ class AgentRosterService: conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation( agent=agent, account_id=account_id, + draft_type=draft_type, ) changed = True if changed: diff --git a/api/services/enterprise/base.py b/api/services/enterprise/base.py index 96c362b3dfc..5ddb51b0696 100644 --- a/api/services/enterprise/base.py +++ b/api/services/enterprise/base.py @@ -5,6 +5,7 @@ from typing import Any import httpx +from configs import dify_config from core.helper.trace_id_helper import generate_traceparent_header from services.errors.enterprise import ( EnterpriseAPIBadRequestError, @@ -96,12 +97,14 @@ class BaseRequest: logger.debug("Failed to generate traceparent header", exc_info=True) with httpx.Client(mounts=mounts) as client: - # IMPORTANT: - # - In httpx, passing timeout=None disables timeouts (infinite) and overrides the library default. - # - To preserve httpx's default timeout behavior for existing call sites, only pass the kwarg when set. - request_kwargs: dict[str, Any] = {"json": json, "params": params, "headers": headers} - if timeout is not None: - request_kwargs["timeout"] = timeout + # Callers that pass an explicit timeout keep it; everyone else gets the + # configured budget rather than httpx's implicit 5s default. + request_kwargs: dict[str, Any] = { + "json": json, + "params": params, + "headers": headers, + "timeout": timeout if timeout is not None else dify_config.ENTERPRISE_REQUEST_TIMEOUT, + } response = client.request(method, url, **request_kwargs) @@ -206,9 +209,8 @@ class EnterpriseRequest(BaseRequest): "json": json, "params": params, "headers": {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key, **inner_headers}, + "timeout": timeout if timeout is not None else dify_config.ENTERPRISE_RBAC_REQUEST_TIMEOUT, } - if timeout is not None: - request_kwargs["timeout"] = timeout response = client.request(method, url, **request_kwargs) if not response.is_success: cls._handle_error_response(response) diff --git a/api/services/external_knowledge_service.py b/api/services/external_knowledge_service.py index d069c259165..1a15fee1e97 100644 --- a/api/services/external_knowledge_service.py +++ b/api/services/external_knowledge_service.py @@ -93,8 +93,20 @@ class ExternalDatasetService: raise ValueError(f"invalid endpoint: {endpoint} must start with http:// or https://") else: raise ValueError(f"invalid endpoint: {endpoint}") + # Send a minimal body shaped like the External Knowledge API retrieval contract so providers + # that require a JSON payload (e.g. RAGFlow) accept the validation probe instead of rejecting + # a body-less POST. Mirrors the request built in fetch_external_knowledge_retrieval. + validation_payload = { + "knowledge_id": "", + "query": "", + "retrieval_setting": {"top_k": 1, "score_threshold": 0.0}, + } try: - response = ssrf_proxy.post(endpoint, headers={"Authorization": f"Bearer {api_key}"}) + response = ssrf_proxy.post( + endpoint, + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + data=json.dumps(validation_payload), + ) except Exception as e: raise ValueError(f"failed to connect to the endpoint: {endpoint}") from e if response.status_code == 502: diff --git a/api/services/feature_service.py b/api/services/feature_service.py index de72792f4d5..b23638bba20 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -186,6 +186,7 @@ class SystemFeatureModel(FeatureResponseModel): enable_learn_app: bool = True enable_step_by_step_tour: bool = False rbac_enabled: bool = False + knowledge_fs_enabled: bool = False class FeatureService: @@ -289,6 +290,7 @@ class FeatureService: system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR + system_features.knowledge_fs_enabled = dify_config.KNOWLEDGE_FS_ENABLED @classmethod def _fulfill_trial_models_from_env(cls) -> list[str]: diff --git a/api/services/file_service.py b/api/services/file_service.py index fba7a760dfa..9409d2ac64d 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -141,6 +141,29 @@ class FileService: blob = storage.load_once(upload_file_key) return base64.b64encode(blob).decode() + def get_file_presigned_url(self, *, file_id: str, tenant_id: str) -> str: + """Generate a direct storage URL for a tenant-owned upload file.""" + with self._session_maker(expire_on_commit=False) as session: + upload_file = session.scalar( + select(UploadFile) + .where( + UploadFile.id == file_id, + UploadFile.tenant_id == tenant_id, + ) + .limit(1) + ) + if upload_file is None: + raise NotFound("File not found") + + file_key = upload_file.key + content_type = upload_file.mime_type + + return storage.generate_presigned_url( + file_key, + expires_in=dify_config.FILES_ACCESS_TIMEOUT, + content_type=content_type, + ) + def upload_text(self, text: str, text_name: str, user_id: str, tenant_id: str) -> UploadFile: if len(text_name) > 200: text_name = text_name[:200] diff --git a/api/services/knowledge_fs_operations.py b/api/services/knowledge_fs_operations.py new file mode 100644 index 00000000000..b8ee5fbd517 --- /dev/null +++ b/api/services/knowledge_fs_operations.py @@ -0,0 +1,502 @@ +"""Product-facing KnowledgeFS operation and authorization declarations. + +This registry is Dify's explicit Console surface. Transport concerns live in +knowledge_fs_proxy so contract review does not require reading proxy mechanics. +""" + +from __future__ import annotations + +from typing import Final, Literal, NamedTuple + +from core.rbac import RBACPermission + +type KnowledgeFSMethod = Literal["DELETE", "GET", "PATCH", "POST", "PUT"] +type KnowledgeFSResponseKind = Literal["binary", "buffered", "stream"] +type KnowledgeFSRequiredScope = Literal["knowledge-spaces:read", "knowledge-spaces:write"] +type KnowledgeFSLegacyRole = Literal["reader", "dataset_editor", "admin"] +type KnowledgeFSErrorStatusMap = tuple[tuple[int, int], ...] + + +class KnowledgeFSOperation(NamedTuple): + operation_id: str + method: KnowledgeFSMethod + path: str + response_kind: KnowledgeFSResponseKind + required_scope: KnowledgeFSRequiredScope + rbac_permission: RBACPermission + legacy_role: KnowledgeFSLegacyRole + max_response_bytes: int + request_headers: tuple[str, ...] + response_headers: tuple[str, ...] + response_media_types: tuple[str, ...] + error_status_map: KnowledgeFSErrorStatusMap + + +def _console_operation( + operation_id: str, + method: KnowledgeFSMethod, + path: str, + *, + rbac_permission: RBACPermission, + legacy_role: KnowledgeFSLegacyRole, + max_response_bytes: int = 1_048_576, + request_headers: tuple[str, ...] = ("x-trace-id",), + response_kind: KnowledgeFSResponseKind = "buffered", + response_media_types: tuple[str, ...] = ("application/json",), + error_status_map: KnowledgeFSErrorStatusMap = ((401, 502), (403, 403)), +) -> KnowledgeFSOperation: + """Declare one contract-pinned operation with an explicit Dify authorization policy.""" + is_read = method == "GET" + return KnowledgeFSOperation( + operation_id=operation_id, + method=method, + path=path, + response_kind=response_kind, + required_scope="knowledge-spaces:read" if is_read else "knowledge-spaces:write", + rbac_permission=rbac_permission, + legacy_role=legacy_role, + max_response_bytes=max_response_bytes, + request_headers=request_headers, + response_headers=("x-trace-id",), + response_media_types=response_media_types, + error_status_map=error_status_map, + ) + + +def _dataset_read_operation(operation_id: str, path: str) -> KnowledgeFSOperation: + """Declare a dataset-readable buffered JSON operation.""" + return _console_operation( + operation_id, + "GET", + path, + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ) + + +def _dataset_edit_operation( + operation_id: str, + method: KnowledgeFSMethod, + path: str, + *, + request_headers: tuple[str, ...] = ("x-trace-id",), +) -> KnowledgeFSOperation: + """Declare a dataset-editable buffered JSON operation.""" + return _console_operation( + operation_id, + method, + path, + rbac_permission=RBACPermission.DATASET_EDIT, + legacy_role="dataset_editor", + request_headers=request_headers, + ) + + +def _external_source_operation( + operation_id: str, + method: KnowledgeFSMethod, + path: str, + *, + request_headers: tuple[str, ...] = ("x-trace-id",), +) -> KnowledgeFSOperation: + """Declare a source-connection operation restricted to dataset editors.""" + return _console_operation( + operation_id, + method, + path, + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + request_headers=request_headers, + ) + + +KNOWLEDGE_FS_CONSOLE_OPERATIONS: Final[tuple[KnowledgeFSOperation, ...]] = ( + _console_operation( + operation_id="listKnowledgeSpaces", + method="GET", + path="knowledge-spaces", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _console_operation( + operation_id="createKnowledgeSpace", + method="POST", + path="knowledge-spaces", + rbac_permission=RBACPermission.DATASET_CREATE_AND_MANAGEMENT, + legacy_role="dataset_editor", + ), + _console_operation( + operation_id="getKnowledgeSpacesById", + method="GET", + path="knowledge-spaces/{id}", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _dataset_edit_operation("patchKnowledgeSpacesById", "PATCH", "knowledge-spaces/{id}"), + _dataset_edit_operation( + "deleteKnowledgeSpacesById", + "DELETE", + "knowledge-spaces/{id}", + request_headers=("idempotency-key", "x-trace-id"), + ), + _dataset_read_operation("getKnowledgeSpacesByIdStats", "knowledge-spaces/{id}/stats"), + _console_operation( + operation_id="getKnowledgeSpacesByIdAccessPolicy", + method="GET", + path="knowledge-spaces/{id}/access-policy", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _console_operation( + operation_id="patchKnowledgeSpacesByIdAccessPolicy", + method="PATCH", + path="knowledge-spaces/{id}/access-policy", + rbac_permission=RBACPermission.DATASET_ACCESS_CONFIG, + legacy_role="admin", + ), + _console_operation( + operation_id="getSourceProviders", + method="GET", + path="source-providers", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdSourceConnections", + method="GET", + path="knowledge-spaces/{id}/source-connections", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + ), + _console_operation( + operation_id="postKnowledgeSpacesByIdSourceConnections", + method="POST", + path="knowledge-spaces/{id}/source-connections", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + ), + _external_source_operation( + "postKnowledgeSpacesByIdSourceConnectionsOauth", + "POST", + "knowledge-spaces/{id}/source-connections/oauth", + ), + _external_source_operation("postSourceOauthCallback", "POST", "source-oauth/callback"), + _external_source_operation( + "getKnowledgeSpacesByIdSourceConnectionsByConnectionId", + "GET", + "knowledge-spaces/{id}/source-connections/{connectionId}", + ), + _external_source_operation( + "deleteKnowledgeSpacesByIdSourceConnectionsByConnectionId", + "DELETE", + "knowledge-spaces/{id}/source-connections/{connectionId}", + ), + _console_operation( + operation_id="postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh", + method="POST", + path="knowledge-spaces/{id}/source-connections/{connectionId}/refresh", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdSources", + method="GET", + path="knowledge-spaces/{id}/sources", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _console_operation( + operation_id="postKnowledgeSpacesByIdSources", + method="POST", + path="knowledge-spaces/{id}/sources", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + ), + _external_source_operation( + "getKnowledgeSpacesByIdSourcesBySourceId", + "GET", + "knowledge-spaces/{id}/sources/{sourceId}", + ), + _external_source_operation( + "patchKnowledgeSpacesByIdSourcesBySourceId", + "PATCH", + "knowledge-spaces/{id}/sources/{sourceId}", + ), + _external_source_operation( + "deleteKnowledgeSpacesByIdSourcesBySourceId", + "DELETE", + "knowledge-spaces/{id}/sources/{sourceId}", + request_headers=("idempotency-key", "x-trace-id"), + ), + _external_source_operation( + "putKnowledgeSpacesByIdSourcesBySourceIdCredentials", + "PUT", + "knowledge-spaces/{id}/sources/{sourceId}/credentials", + ), + _external_source_operation( + "deleteKnowledgeSpacesByIdSourcesBySourceIdCredentials", + "DELETE", + "knowledge-spaces/{id}/sources/{sourceId}/credentials", + ), + _external_source_operation( + "postKnowledgeSpacesByIdSourcesBySourceIdSync", + "POST", + "knowledge-spaces/{id}/sources/{sourceId}/sync", + request_headers=("idempotency-key", "x-trace-id"), + ), + _console_operation( + operation_id="postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview", + method="POST", + path="knowledge-spaces/{id}/sources/{sourceId}/crawl-preview", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + request_headers=("idempotency-key", "x-trace-id"), + ), + _external_source_operation( + "postKnowledgeSpacesByIdSourcesBySourceIdWorkflowImports", + "POST", + "knowledge-spaces/{id}/sources/{sourceId}/workflow-imports", + request_headers=("idempotency-key", "x-trace-id"), + ), + _external_source_operation( + "getKnowledgeSpacesByIdSourcesBySourceIdPages", + "GET", + "knowledge-spaces/{id}/sources/{sourceId}/pages", + ), + _external_source_operation( + "getKnowledgeSpacesByIdSourcesBySourceIdFiles", + "GET", + "knowledge-spaces/{id}/sources/{sourceId}/files", + ), + _external_source_operation( + "postKnowledgeSpacesByIdSourcesBySourceIdCrawl", + "POST", + "knowledge-spaces/{id}/sources/{sourceId}/crawl", + ), + _external_source_operation( + "postKnowledgeSpacesByIdSourcesBySourceIdImport", + "POST", + "knowledge-spaces/{id}/sources/{sourceId}/import", + ), + _external_source_operation( + "postKnowledgeSpacesByIdSourcesBySourceIdTest", + "POST", + "knowledge-spaces/{id}/sources/{sourceId}/test", + ), + _external_source_operation( + "postKnowledgeSpacesByIdSourcesBySourceIdImportFiles", + "POST", + "knowledge-spaces/{id}/sources/{sourceId}/import-files", + ), + _external_source_operation( + "postKnowledgeSpacesByIdSourcesBulk", + "POST", + "knowledge-spaces/{id}/sources/bulk", + request_headers=("idempotency-key", "x-trace-id"), + ), + _external_source_operation( + "getKnowledgeSpacesByIdSourceWorkflows", + "GET", + "knowledge-spaces/{id}/source-workflows", + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdSourceWorkflowsByRunId", + method="GET", + path="knowledge-spaces/{id}/source-workflows/{runId}", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + ), + _external_source_operation( + "getKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItems", + "GET", + "knowledge-spaces/{id}/source-workflows/{runId}/bulk-items", + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages", + method="GET", + path="knowledge-spaces/{id}/source-workflows/{runId}/pages", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + ), + _console_operation( + operation_id="postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel", + method="POST", + path="knowledge-spaces/{id}/source-workflows/{runId}/cancel", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + ), + _console_operation( + operation_id="postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry", + method="POST", + path="knowledge-spaces/{id}/source-workflows/{runId}/retry", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + ), + _console_operation( + operation_id="postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection", + method="POST", + path="knowledge-spaces/{id}/source-workflows/{runId}/selection", + rbac_permission=RBACPermission.DATASET_EXTERNAL_CONNECT, + legacy_role="dataset_editor", + request_headers=("idempotency-key", "x-trace-id"), + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy", + method="GET", + path="knowledge-spaces/{id}/sources/{sourceId}/sync-policy", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _console_operation( + operation_id="putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy", + method="PUT", + path="knowledge-spaces/{id}/sources/{sourceId}/sync-policy", + rbac_permission=RBACPermission.DATASET_EDIT, + legacy_role="dataset_editor", + ), + _dataset_read_operation("getKnowledgeSpacesByIdDocuments", "knowledge-spaces/{id}/documents"), + _dataset_edit_operation("postKnowledgeSpacesByIdDocuments", "POST", "knowledge-spaces/{id}/documents"), + _dataset_edit_operation( + "deleteKnowledgeSpacesByIdDocumentsBulk", + "DELETE", + "knowledge-spaces/{id}/documents/bulk", + request_headers=("idempotency-key", "x-trace-id"), + ), + _dataset_edit_operation( + "postKnowledgeSpacesByIdDocumentsBulk", + "POST", + "knowledge-spaces/{id}/documents/bulk", + ), + _dataset_edit_operation( + "postKnowledgeSpacesByIdDocumentsBulkReindex", + "POST", + "knowledge-spaces/{id}/documents/bulk/reindex", + ), + _dataset_read_operation( + "getKnowledgeSpacesByIdDocumentsByDocumentId", + "knowledge-spaces/{id}/documents/{documentId}", + ), + _dataset_edit_operation( + "deleteKnowledgeSpacesByIdDocumentsByDocumentId", + "DELETE", + "knowledge-spaces/{id}/documents/{documentId}", + request_headers=("idempotency-key", "x-trace-id"), + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdLogicalDocuments", + method="GET", + path="knowledge-spaces/{id}/logical-documents", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _dataset_edit_operation( + "deleteKnowledgeSpacesByIdLogicalDocumentsByDocumentId", + "DELETE", + "knowledge-spaces/{id}/logical-documents/{documentId}", + request_headers=("idempotency-key", "x-trace-id"), + ), + _dataset_read_operation( + "getKnowledgeSpacesByIdDocumentsByDocumentIdOutline", + "knowledge-spaces/{id}/documents/{documentId}/outline", + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdLogicalDocumentsByDocumentId", + method="GET", + path="knowledge-spaces/{id}/logical-documents/{documentId}", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions", + method="GET", + path="knowledge-spaces/{id}/documents/{documentId}/revisions", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _dataset_edit_operation( + "postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollback", + "POST", + "knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/rollback", + ), + _dataset_edit_operation( + "patchKnowledgeSpacesByIdDocumentsByDocumentIdMetadata", + "PATCH", + "knowledge-spaces/{id}/documents/{documentId}/metadata", + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunks", + method="GET", + path="knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/chunks", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _dataset_read_operation( + "getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkId", + "knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/chunks/{chunkId}", + ), + _dataset_edit_operation( + "postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdState", + "POST", + "knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/chunks/{chunkId}/state", + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdProcessingTasks", + method="GET", + path="knowledge-spaces/{id}/processing-tasks", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + ), + _dataset_read_operation( + "getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasks", + "knowledge-spaces/{id}/documents/{documentId}/processing-tasks", + ), + _dataset_read_operation( + "getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId", + "knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}", + ), + _console_operation( + operation_id="getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents", + method="GET", + path="knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}/events", + rbac_permission=RBACPermission.DATASET_READONLY, + legacy_role="reader", + max_response_bytes=67_108_864, + request_headers=("last-event-id", "x-trace-id"), + response_kind="stream", + response_media_types=("text/event-stream",), + ), + _console_operation( + operation_id="deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId", + method="DELETE", + path="knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}", + rbac_permission=RBACPermission.DATASET_EDIT, + legacy_role="dataset_editor", + ), + _console_operation( + operation_id="postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry", + method="POST", + path="knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}/retry", + rbac_permission=RBACPermission.DATASET_EDIT, + legacy_role="dataset_editor", + ), + _dataset_read_operation( + "getKnowledgeSpacesByIdDocumentsByDocumentIdSettings", + "knowledge-spaces/{id}/documents/{documentId}/settings", + ), + _dataset_edit_operation( + "putKnowledgeSpacesByIdDocumentsByDocumentIdSettings", + "PUT", + "knowledge-spaces/{id}/documents/{documentId}/settings", + ), + _dataset_read_operation("getJobsById", "jobs/{id}"), + _dataset_edit_operation("deleteJobsById", "DELETE", "jobs/{id}"), + _dataset_edit_operation("postJobsByIdRetry", "POST", "jobs/{id}/retry"), + _dataset_read_operation("getDeletionJobsByJobId", "deletion-jobs/{jobId}"), + _dataset_edit_operation( + "postDeletionJobsByJobIdRetry", + "POST", + "deletion-jobs/{jobId}/retry", + request_headers=("idempotency-key", "x-trace-id"), + ), + _dataset_read_operation("getBulkJobsById", "bulk-jobs/{id}"), +) diff --git a/api/services/knowledge_fs_proxy.py b/api/services/knowledge_fs_proxy.py index dab2a040d47..1e4f43329ea 100644 --- a/api/services/knowledge_fs_proxy.py +++ b/api/services/knowledge_fs_proxy.py @@ -1,33 +1,32 @@ -"""Transport-only forwarding for the explicitly enabled KnowledgeFS Console operations. +"""Authorize and forward the explicitly enabled KnowledgeFS Console operations. -KnowledgeFS owns the request and response contract. This module binds short-lived -account and workspace identities, enforces Dify's coarse workspace policy, and -normalizes transport failures. Dify deliberately maintains a small product-facing -operation registry instead of exposing the full upstream OpenAPI surface. The -dedicated request path uses Dify's shared SSRF policy, never follows redirects, -bounds buffered responses, and rejects compressed responses. +The dedicated request path uses Dify's shared SSRF policy, never follows redirects, +bounds buffered responses, and rejects compressed streaming responses. """ from __future__ import annotations from collections.abc import Iterable, Mapping +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from http import HTTPStatus -from typing import Final, Literal, NamedTuple, Protocol +from typing import NamedTuple, Protocol import httpx import jwt from configs import dify_config from core.helper import ssrf_proxy -from core.rbac import RBACPermission, RBACResourceScope +from core.rbac import RBACResourceScope from core.tools.errors import ToolSSRFError from models import Account from services.enterprise.rbac_service import RBACService - -type KnowledgeFSMethod = Literal["DELETE", "GET", "PATCH", "POST", "PUT"] -type KnowledgeFSResponseKind = Literal["binary", "buffered", "stream"] -type KnowledgeFSRequiredScope = Literal["knowledge-spaces:read", "knowledge-spaces:write"] +from services.knowledge_fs_operations import ( + KNOWLEDGE_FS_CONSOLE_OPERATIONS, + KnowledgeFSMethod, + KnowledgeFSOperation, + KnowledgeFSResponseKind, +) _JWT_AUDIENCE = "knowledge-fs" _JWT_ISSUER = "dify" @@ -35,56 +34,55 @@ _JWT_TTL_SECONDS = 60 _MAX_BUFFERED_RESPONSE_BYTES = 1024 * 1024 -class KnowledgeFSOperation(NamedTuple): - operation_id: str - method: KnowledgeFSMethod - path: str - response_kind: KnowledgeFSResponseKind - required_scope: KnowledgeFSRequiredScope - rbac_permission: RBACPermission - requires_dataset_editor: bool - max_response_bytes: int - request_headers: tuple[str, ...] - response_headers: tuple[str, ...] - response_media_types: tuple[str, ...] - - -KNOWLEDGE_FS_CONSOLE_OPERATIONS: Final[tuple[KnowledgeFSOperation, ...]] = ( - KnowledgeFSOperation( - operation_id="listKnowledgeSpaces", - method="GET", - path="knowledge-spaces", - response_kind="buffered", - required_scope="knowledge-spaces:read", - rbac_permission=RBACPermission.DATASET_READONLY, - requires_dataset_editor=False, - max_response_bytes=1_048_576, - request_headers=("x-trace-id",), - response_headers=("x-trace-id",), - response_media_types=("application/json",), - ), - KnowledgeFSOperation( - operation_id="createKnowledgeSpace", - method="POST", - path="knowledge-spaces", - response_kind="buffered", - required_scope="knowledge-spaces:write", - rbac_permission=RBACPermission.DATASET_CREATE_AND_MANAGEMENT, - requires_dataset_editor=True, - max_response_bytes=1_048_576, - request_headers=("x-trace-id",), - response_headers=("x-trace-id",), - response_media_types=("application/json",), - ), -) - - class KnowledgeFSUpstreamResponse(NamedTuple): response: httpx.Response response_kind: KnowledgeFSResponseKind operation: KnowledgeFSOperation +_AUTHORIZATION_MARKER = object() + + +@dataclass(eq=False, frozen=True, init=False, slots=True) +class KnowledgeFSAuthorization: + """Single-use forwarding capability created after Dify workspace policy checks. + + Callers obtain this value from :func:`authorize_knowledge_fs_request`. Direct + construction and repeated forwarding are rejected before outbound I/O. + """ + + account_id: str + tenant_id: str + operation: KnowledgeFSOperation + _used: bool + + def __init__( + self, + account_id: str, + tenant_id: str, + operation: KnowledgeFSOperation, + *, + _marker: object | None = None, + ) -> None: + if _marker is not _AUTHORIZATION_MARKER: + raise KnowledgeFSAccessDeniedError("KnowledgeFS authorization must be created by workspace authorization") + object.__setattr__(self, "account_id", account_id) + object.__setattr__(self, "tenant_id", tenant_id) + object.__setattr__(self, "operation", operation) + object.__setattr__(self, "_used", False) + + def consume(self) -> tuple[str, str, KnowledgeFSOperation]: + """Return the authorized principals and canonical operation exactly once. + + Raises: + KnowledgeFSAccessDeniedError: The capability was already consumed. + """ + if self._used: + raise KnowledgeFSAccessDeniedError("KnowledgeFS authorization has already been used") + object.__setattr__(self, "_used", True) + return self.account_id, self.tenant_id, self.operation + + class _RequestHeaders(Protocol): def items(self) -> Iterable[tuple[str, str]]: ... @@ -113,20 +111,29 @@ def authorize_knowledge_fs_request( *, account: Account, tenant_id: str, - operation: KnowledgeFSOperation, -) -> None: + method: KnowledgeFSMethod, + path: str, +) -> KnowledgeFSAuthorization: """Enforce Dify's workspace policy before KFS performs resource authorization. Args: account: Authenticated Dify account with its current workspace role. tenant_id: Current Dify workspace identifier. - operation: Dify-maintained KnowledgeFS operation and policy metadata. + method: Requested upstream HTTP method. + path: Requested relative KnowledgeFS path. Raises: + KnowledgeFSRouteNotAllowedError: The method and path do not resolve to a declared operation. KnowledgeFSAccessDeniedError: The account lacks a required legacy or enterprise permission. + + Returns: + A request-scoped capability binding the authorized account, workspace, and operation. """ - if operation.requires_dataset_editor and not account.is_dataset_editor: - raise KnowledgeFSAccessDeniedError("KnowledgeFS mutations require dataset edit access") + operation = get_knowledge_fs_operation(method, path) + if operation.legacy_role == "dataset_editor" and not account.is_dataset_editor: + raise KnowledgeFSAccessDeniedError("KnowledgeFS operation requires dataset edit access") + if operation.legacy_role == "admin" and not account.is_admin_or_owner: + raise KnowledgeFSAccessDeniedError("KnowledgeFS operation requires workspace administration access") if not RBACService.CheckAccess.check( tenant_id, account.id, @@ -134,6 +141,7 @@ def authorize_knowledge_fs_request( resource_type=RBACResourceScope.DATASET.value, ): raise KnowledgeFSAccessDeniedError("KnowledgeFS operation is denied by workspace RBAC") + return KnowledgeFSAuthorization(account.id, tenant_id, operation, _marker=_AUTHORIZATION_MARKER) def proxy_knowledge_fs_request( @@ -149,20 +157,62 @@ def proxy_knowledge_fs_request( request_headers: _RequestHeaders | None = None, ) -> KnowledgeFSUpstreamResponse: """Authorize and forward one allowlisted KnowledgeFS request as a single use case.""" - operation = get_knowledge_fs_operation(method, path) - authorize_knowledge_fs_request( + authorization = authorize_knowledge_fs_request( account=account, tenant_id=tenant_id, - operation=operation, + method=method, + path=path, ) + + return proxy_authorized_knowledge_fs_request( + authorization=authorization, + accept=accept, + content_type=content_type, + query=query, + body=body, + request_headers=request_headers, + ) + + +def proxy_authorized_knowledge_fs_request( + *, + authorization: KnowledgeFSAuthorization, + accept: str | None = None, + content_type: str | None = None, + query: bytes | None = None, + body: bytes | None = None, + request_headers: _RequestHeaders | None = None, +) -> KnowledgeFSUpstreamResponse: + """Forward one request whose operation and workspace policy were already authorized. + + This performs one outbound KnowledgeFS request and does not repeat Dify RBAC checks. + + Args: + authorization: Request-scoped capability returned by :func:`authorize_knowledge_fs_request`. + accept: Original Accept header, when present. + content_type: Original request Content-Type header, when present. + query: Original encoded query string from the Console request. + body: Original request body, when present. + request_headers: Incoming headers; only names declared by the operation are forwarded. + + Returns: + The bounded KnowledgeFS response together with its transport metadata. + + Raises: + KnowledgeFSConfigurationError: The connection is incomplete or blocked by outbound policy. + KnowledgeFSRouteNotAllowedError: A forwarded request header is outside the operation contract. + KnowledgeFSTimeoutError: KnowledgeFS exceeds the configured timeout. + KnowledgeFSTransportError: The request fails or its response violates transport bounds. + """ + account_id, tenant_id, operation = authorization.consume() incoming_request_headers = {name.lower(): value for name, value in (request_headers or {}).items()} contract_request_headers = { name: incoming_request_headers[name] for name in operation.request_headers if name in incoming_request_headers } return _forward_knowledge_fs_request( - account_id=account.id, - method=method, - path=path, + account_id=account_id, + method=operation.method, + path=operation.path, tenant_id=tenant_id, accept=accept, content_type=content_type, diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_site.py b/api/tests/test_containers_integration_tests/controllers/web/test_site.py index fa85f68bc4e..7f4fd45d037 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_site.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_site.py @@ -9,7 +9,9 @@ from flask import Flask from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden +from configs import dify_config from controllers.web.site import AppSiteApi, WebAppSiteResponse, WebModelConfigResponse +from extensions.storage.storage_type import StorageType from models import Tenant, TenantStatus from models.account import TenantCustomConfigDict from models.model import App, AppMode, AppModelConfig, CustomizeTokenStrategy, EndUser, Site @@ -96,6 +98,39 @@ class TestAppSiteApi: assert result["plan"] == "basic" assert result["enable_site"] is True + @patch("controllers.web.site.FileService.get_file_presigned_url") + @patch("controllers.web.site.FeatureService.get_features") + def test_image_icon_uses_s3_presigned_url( + self, + mock_features: MagicMock, + mock_get_file_presigned_url: MagicMock, + app: Flask, + db_session_with_containers: Session, + ) -> None: + app.config["RESTX_MASK_HEADER"] = "X-Fields" + tenant = _create_tenant(db_session_with_containers) + app_model = _create_app(db_session_with_containers, tenant.id) + site = _create_site(db_session_with_containers, app_model.id) + site.icon_type = "image" + site.icon = "11111111-1111-4111-8111-111111111111" + db_session_with_containers.commit() + end_user = _end_user(tenant.id, app_model.id) + mock_features.return_value = FeatureModel(can_replace_logo=False) + mock_get_file_presigned_url.return_value = "https://s3.example.com/icon.png?signature=test" + + with ( + patch.object(dify_config, "EDITION", "CLOUD"), + patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), + app.test_request_context("/site"), + ): + result = AppSiteApi().get(app_model, end_user) + + assert result["site"]["icon_url"] == "https://s3.example.com/icon.png?signature=test" + mock_get_file_presigned_url.assert_called_once_with( + file_id="11111111-1111-4111-8111-111111111111", + tenant_id=tenant.id, + ) + def test_missing_site_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers) diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index a0caf0263e7..72669270ad1 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -212,6 +212,17 @@ def test_generate_specs_include_console_contract_shapes_for_schema_migration(tmp assert {"type": "null"} in app_detail_nullable_schema["anyOf"] assert schemas["RecommendedAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True assert schemas["InstalledAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True + assert _response_schema(paths["/apps/{app_id}"]["get"])["$ref"] == "#/components/schemas/AppDetailWithSite" + app_model_config = schemas["AppDetailWithSite"]["properties"]["model_config"] + assert {"$ref": "#/components/schemas/AppModelConfigResponse"} in app_model_config["anyOf"] + app_detail = schemas["AppDetail"] + assert "mode" in app_detail["properties"] + assert "mode_compatible_with_agent" not in app_detail["properties"] + sync_draft_workflow = schemas["SyncDraftWorkflowResponse"] + assert _response_schema(paths["/apps/{app_id}/workflows/draft"]["post"])["$ref"] == ( + "#/components/schemas/SyncDraftWorkflowResponse" + ) + assert sync_draft_workflow["properties"]["updated_at"]["type"] == "integer" tool_icon_schema = schemas["ExploreAppMetaResponse"]["properties"]["tool_icons"]["additionalProperties"] assert {"type": "string"} in tool_icon_schema["anyOf"] assert {"additionalProperties": True, "type": "object"} in tool_icon_schema["anyOf"] diff --git a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py index 31b4d71d0ff..59a12a616b5 100644 --- a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py +++ b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py @@ -1,15 +1,24 @@ -"""Unit tests for the reset-encrypt-key-pair CLI command (#35396). +"""SQLite-backed tests for the reset-encrypt-key-pair CLI command (#35396). The command must purge every table that stores ciphertext encrypted with the tenant's asymmetric key, otherwise stale rows cause downstream API failures such as `/console/api/workspaces/current/tool-providers` returning 500. +Tests bind the command-owned transaction to the fixture engine and assert the +committed state rather than inspecting fabricated ``Session.execute`` calls. """ -from unittest.mock import MagicMock, patch +from types import SimpleNamespace + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session import commands from commands import system as system_commands -from models.provider import Provider, ProviderModel +from core.tools.entities.tool_entities import ApiProviderSchemaType +from graphon.model_runtime.entities.model_entities import ModelType +from models import Tenant +from models.provider import Provider, ProviderModel, ProviderType from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider @@ -21,17 +30,60 @@ def _invoke_reset() -> int: return 0 -def _delete_targets(session_mock: MagicMock) -> list: - """Extract the model class targeted by each `delete(...)` call on the session.""" - targets = [] - for call in session_mock.execute.call_args_list: - stmt = call.args[0] - # `delete(Foo)` constructs a `Delete` statement whose entity is `Foo`. - try: - targets.append(stmt.table.name) - except AttributeError: - targets.append(repr(stmt)) - return targets +TENANT_ID = "11111111-1111-1111-1111-111111111111" +OTHER_TENANT_ID = "11111111-1111-1111-1111-111111111112" +USER_ID = "22222222-2222-2222-2222-222222222222" + + +def _tenant(tenant_id: str, *, name: str = "Test tenant") -> Tenant: + tenant = Tenant(name=name, encrypt_public_key="old-key") + tenant.id = tenant_id + return tenant + + +def _encrypted_rows(tenant_id: str, *, suffix: str = "1") -> tuple[object, ...]: + """Build one persisted credential-bearing row for every purge target.""" + return ( + Provider(tenant_id=tenant_id, provider_name=f"provider-{suffix}"), + ProviderModel( + tenant_id=tenant_id, + provider_name=f"provider-{suffix}", + model_name=f"model-{suffix}", + model_type=ModelType.LLM, + ), + BuiltinToolProvider( + name=f"builtin-credential-{suffix}", + tenant_id=tenant_id, + user_id=USER_ID, + provider=f"builtin-{suffix}", + encrypted_credentials="ciphertext", + ), + ApiToolProvider( + name=f"api-{suffix}", + icon="icon", + schema="{}", + schema_type_str=ApiProviderSchemaType.OPENAPI, + user_id=USER_ID, + tenant_id=tenant_id, + description="description", + tools_str="[]", + credentials_str="{}", + ), + MCPToolProvider( + name=f"mcp-{suffix}", + server_identifier=f"server-{suffix}", + server_url="ciphertext", + server_url_hash=f"hash-{suffix}", + icon=None, + tenant_id=tenant_id, + user_id=USER_ID, + encrypted_credentials="ciphertext", + ), + ) + + +def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) -> None: + monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=session.get_bind())) def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys): @@ -44,65 +96,73 @@ def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys): assert "only for SELF_HOSTED" in captured.out -def test_reset_purges_provider_and_tool_tables_for_each_tenant(monkeypatch, capsys): +@pytest.mark.parametrize( + "sqlite_session", + [(Tenant, Provider, ProviderModel, BuiltinToolProvider, ApiToolProvider, MCPToolProvider)], + indirect=True, +) +def test_reset_purges_provider_and_tool_tables_for_each_tenant( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], sqlite_session: Session +) -> None: """The command must purge LLM provider rows AND every tool provider table that stores ciphertext encrypted under the tenant key (#35396).""" monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") + _bind_command_to_sqlite(monkeypatch, sqlite_session) - fake_tenant = MagicMock(id="tenant-abc", encrypt_public_key="old-key") - session = MagicMock() - session.scalars.return_value.all.return_value = [fake_tenant] + tenant = _tenant(TENANT_ID) + other_tenant = _tenant(OTHER_TENANT_ID, name="Other tenant") + system_provider = Provider( + tenant_id=TENANT_ID, + provider_name="system-provider", + provider_type=ProviderType.SYSTEM, + ) + sqlite_session.add_all((tenant, other_tenant, system_provider, *_encrypted_rows(TENANT_ID))) + sqlite_session.commit() - fake_sessionmaker = MagicMock() - fake_sessionmaker.begin.return_value.__enter__.return_value = session - fake_sessionmaker.begin.return_value.__exit__.return_value = False - - with ( - patch.object(system_commands, "db", MagicMock()), - patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker), - ): - exit_code = _invoke_reset() + exit_code = _invoke_reset() captured = capsys.readouterr() assert exit_code == 0 - assert "tenant-abc" in captured.out + assert TENANT_ID in captured.out - # New key pair generated and assigned. - assert fake_tenant.encrypt_public_key == "new-key-tenant-abc" - - # Every encrypted-credential table should have been purged for this tenant. - table_names = _delete_targets(session) - expected = { - Provider.__tablename__, - ProviderModel.__tablename__, - BuiltinToolProvider.__tablename__, - ApiToolProvider.__tablename__, - MCPToolProvider.__tablename__, - } - assert expected.issubset(set(table_names)), f"missing purges: expected {expected}, got {table_names}" + sqlite_session.expire_all() + assert sqlite_session.get(Tenant, TENANT_ID).encrypt_public_key == f"new-key-{TENANT_ID}" + assert sqlite_session.get(Tenant, OTHER_TENANT_ID).encrypt_public_key == f"new-key-{OTHER_TENANT_ID}" + assert sqlite_session.scalars(select(Provider).where(Provider.provider_type == ProviderType.CUSTOM)).all() == [] + assert sqlite_session.scalars(select(ProviderModel)).all() == [] + assert sqlite_session.scalars(select(BuiltinToolProvider)).all() == [] + assert sqlite_session.scalars(select(ApiToolProvider)).all() == [] + assert sqlite_session.scalars(select(MCPToolProvider)).all() == [] + assert ( + sqlite_session.scalar(select(Provider).where(Provider.provider_type == ProviderType.SYSTEM)) is system_provider + ) -def test_reset_iterates_all_tenants(monkeypatch, capsys): +@pytest.mark.parametrize( + "sqlite_session", + [(Tenant, Provider, ProviderModel, BuiltinToolProvider, ApiToolProvider, MCPToolProvider)], + indirect=True, +) +def test_reset_iterates_all_tenants(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: """Multi-tenant deployments must purge every tenant, not just the first.""" monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") - tenants = [MagicMock(id=f"tenant-{i}", encrypt_public_key="old") for i in range(3)] - session = MagicMock() - session.scalars.return_value.all.return_value = tenants + _bind_command_to_sqlite(monkeypatch, sqlite_session) + tenant_ids = [f"11111111-1111-1111-1111-{index:012d}" for index in range(3)] + tenants = [_tenant(tenant_id, name=f"Tenant {index}") for index, tenant_id in enumerate(tenant_ids)] + for index, tenant in enumerate(tenants): + sqlite_session.add(tenant) + sqlite_session.add_all(_encrypted_rows(tenant.id, suffix=str(index))) + sqlite_session.commit() - fake_sessionmaker = MagicMock() - fake_sessionmaker.begin.return_value.__enter__.return_value = session - fake_sessionmaker.begin.return_value.__exit__.return_value = False + assert _invoke_reset() == 0 - with ( - patch.object(system_commands, "db", MagicMock()), - patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker), - ): - _invoke_reset() - - # Five purges per tenant × 3 tenants = 15 execute calls. - assert session.execute.call_count == 15 - for tenant in tenants: - assert tenant.encrypt_public_key == f"new-key-{tenant.id}" + sqlite_session.expire_all() + persisted_tenants = sqlite_session.scalars(select(Tenant).order_by(Tenant.id)).all() + assert [tenant.encrypt_public_key for tenant in persisted_tenants] == [ + f"new-key-{tenant_id}" for tenant_id in tenant_ids + ] + for model in (Provider, ProviderModel, BuiltinToolProvider, ApiToolProvider, MCPToolProvider): + assert sqlite_session.scalars(select(model)).all() == [] diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index d95e5e8501d..0714ef1bd89 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -37,6 +37,7 @@ os.environ.setdefault("STORAGE_TYPE", "opendal") from core.db.session_factory import configure_session_factory, session_factory from extensions import ext_redis +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.base import TypeBase @@ -148,32 +149,45 @@ def _configure_session_factory(_unit_test_engine): configure_session_factory(_unit_test_engine, expire_on_commit=False) -def setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_owner): - """ - Helper to stub the tenant-owner execute result for service API app authentication. +def persist_service_api_tenant_owner(session: Session, tenant: Tenant, owner: Account) -> TenantAccountJoin: + """Persist the owner identity resolved by service-API app authentication. - The validate_app_token decorator currently resolves the active tenant owner - via db.session.execute(select(Tenant, Account)...).one_or_none(). - - Args: - mock_db: The mocked db object - mock_tenant: Mock tenant object to return - mock_owner: Mock owner object to return from the execute result + The legacy name is retained temporarily for consumers on independent + conversion branches, but this helper no longer fabricates an execute result. """ + membership = TenantAccountJoin( + tenant_id=tenant.id, + account_id=owner.id, + role=TenantAccountRole.OWNER, + ) + owner._current_tenant = tenant + session.add_all([tenant, owner, membership]) + session.commit() + return membership + + +def persist_service_api_dataset_owner( + session: Session, + tenant: Tenant, + tenant_account_join: TenantAccountJoin, +) -> None: + """Persist the tenant-owner mapping resolved by dataset-token authentication.""" + session.add_all([tenant, tenant_account_join]) + session.commit() + + +def setup_mock_tenant_owner_execute_result(mock_db: MagicMock, mock_tenant: object, mock_owner: object) -> None: + """Stub the legacy owner query; SQLite-backed tests use ``persist_service_api_tenant_owner``.""" mock_db.session.execute.return_value.one_or_none.return_value = (mock_tenant, mock_owner) -def setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_tenant_account_join): - """ - Helper to stub the tenant-owner execute result for dataset token authentication. - - The validate_dataset_token decorator currently resolves the owner mapping via - db.session.execute(select(Tenant, TenantAccountJoin)...).one_or_none(), and - then loads the Account separately via db.session.get(...). - - Args: - mock_db: The mocked db object - mock_tenant: Mock tenant object to return - mock_tenant_account_join: Mock tenant-account join object to return - """ - mock_db.session.execute.return_value.one_or_none.return_value = (mock_tenant, mock_tenant_account_join) +def setup_mock_dataset_owner_execute_result( + mock_db: MagicMock, + mock_tenant: object, + mock_tenant_account_join: object, +) -> None: + """Stub the legacy dataset-owner query; SQLite tests use ``persist_service_api_dataset_owner``.""" + mock_db.session.execute.return_value.one_or_none.return_value = ( + mock_tenant, + mock_tenant_account_join, + ) 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 b026c0e0c85..ee13618fe1b 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 @@ -53,6 +53,7 @@ from controllers.console.app.message import ( AgentMessageFeedbackApi, AgentMessageSuggestedQuestionApi, ) +from models.agent import AgentConfigDraftType from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant @@ -371,6 +372,7 @@ def test_agent_app_list_and_create_use_agent_route( "tenant_id": "tenant-1", "agent_id": "agent-created", "account_id": account_id, + "draft_type": AgentConfigDraftType.DEBUG_BUILD, "commit": False, } @@ -544,8 +546,19 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( } -def test_agent_debug_conversation_refresh_uses_current_user( - app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +@pytest.mark.parametrize( + ("payload", "expected_draft_type"), + [ + (None, AgentConfigDraftType.DEBUG_BUILD), + ({"draft_type": "draft"}, AgentConfigDraftType.DRAFT), + ], +) +def test_agent_debug_conversation_refresh_uses_current_user_and_draft_type( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + account_id: str, + payload: dict[str, str] | None, + expected_draft_type: AgentConfigDraftType, ) -> None: agent_id = "00000000-0000-0000-0000-000000000001" captured: dict[str, object] = {} @@ -557,7 +570,9 @@ def test_agent_debug_conversation_refresh_uses_current_user( monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda *_args: FakeRosterService()) with app.test_request_context( - "/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh", method="POST" + "/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh", + method="POST", + json=payload, ): response = unwrap(AgentDebugConversationRefreshApi.post)( AgentDebugConversationRefreshApi(), MagicMock(), "tenant-1", SimpleNamespace(id=account_id), agent_id @@ -567,7 +582,12 @@ def test_agent_debug_conversation_refresh_uses_current_user( "debug_conversation_has_messages": False, "debug_conversation_message_count": 0, } - assert captured == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id} + assert captured == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "account_id": account_id, + "draft_type": expected_draft_type, + } def test_agent_publish_and_build_draft_routes_call_composer_service( @@ -1456,6 +1476,7 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt( "current_user": SimpleNamespace(id=account_id), "app_model": app_model, "agent_id": "agent-1", + "draft_type": AgentConfigDraftType.DEBUG_BUILD, } generate_call = cast(dict[str, object], captured["generate"]) assert generate_call["app_model"] is app_model @@ -1520,11 +1541,15 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( captured.update(kwargs) return {"answer": "ok"} + def resolve_debug_conversation(**kwargs: object) -> str: + captured["resolve_debug_conversation"] = kwargs + return "debug-conversation-1" + monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate) monkeypatch.setattr( completion_controller, "_resolve_current_user_agent_debug_conversation_id", - lambda **kwargs: "debug-conversation-1", + resolve_debug_conversation, ) monkeypatch.setattr( completion_controller.helper, "compact_generate_response", lambda response: {"response": response} @@ -1544,6 +1569,7 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( assert args["conversation_id"] == "debug-conversation-1" assert args["auto_generate_name"] is False assert args["external_trace_id"] == "trace-1" + assert cast(dict[str, object], captured["resolve_debug_conversation"])["draft_type"] == AgentConfigDraftType.DRAFT def test_agent_chat_helper_ignores_private_exit_intent_payload_key( @@ -1642,6 +1668,7 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app current_user=SimpleNamespace(id="account-1"), app_model=SimpleNamespace(id="app-1"), agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, ) fallback_id = completion_controller._resolve_current_user_agent_debug_conversation_id( session="session-1", # type: ignore[arg-type] @@ -1649,13 +1676,26 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app current_user=SimpleNamespace(id="account-1"), app_model=SimpleNamespace(id="app-1"), agent_id=None, + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) assert explicit_id == "debug-agent-1" assert fallback_id == "debug-backing-agent" - assert calls[1] == {"get_or_create": {"tenant_id": "tenant-1", "agent_id": "agent-1", "account_id": "account-1"}} + assert calls[1] == { + "get_or_create": { + "tenant_id": "tenant-1", + "agent_id": "agent-1", + "account_id": "account-1", + "draft_type": AgentConfigDraftType.DRAFT, + } + } assert calls[3] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}} assert calls[4] == { - "get_or_create": {"tenant_id": "tenant-1", "agent_id": "backing-agent", "account_id": "account-1"} + "get_or_create": { + "tenant_id": "tenant-1", + "agent_id": "backing-agent", + "account_id": "account-1", + "draft_type": AgentConfigDraftType.DEBUG_BUILD, + } } diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index 1933ed41732..3b675600539 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -9,13 +9,14 @@ from unittest.mock import MagicMock import pytest from flask import Flask -from sqlalchemy import event +from sqlalchemy import Engine, event from sqlalchemy.orm import Session from controllers.console.app import app_import as app_import_module from models.account import Account +from models.base import TypeBase from models.engine import db -from models.model import App +from models.model import App, AppMode from services.app_dsl_service import ImportStatus from services.entities.dsl_entities import CheckDependenciesResult from services.feature_service import SystemFeatureModel, WebAppAuthModel @@ -66,6 +67,13 @@ def app() -> Iterator[Flask]: yield app +@pytest.fixture +def sqlite_app_engine(app: Flask) -> Engine: + engine = db.engine + TypeBase.metadata.create_all(engine, tables=[TypeBase.metadata.tables[App.__tablename__]]) + return engine + + @dataclass class TransactionEvents: commits: int = 0 @@ -93,11 +101,34 @@ def transaction_events() -> TransactionEvents: event.remove(Session, "after_rollback", record_rollback) -def _failed_result_after_starting_transaction( - service: app_import_module.AppDslService, *, app_id: str | None = None -) -> _Result: - service._session.begin() - return _Result(ImportStatus.FAILED, app_id=app_id) +def _install_persisting_service_result( + monkeypatch: pytest.MonkeyPatch, + *, + method_name: str, + result: _Result, +) -> str: + app_id = result.app_id or "rolled-back-app" + + def _return_result(import_service: app_import_module.AppDslService, *_args, **_kwargs): + import_service._session.add( + App( + id=app_id, + tenant_id="tenant-1", + name="Imported App", + mode=AppMode.WORKFLOW, + enable_site=True, + enable_api=True, + ) + ) + return result + + monkeypatch.setattr(app_import_module.AppDslService, method_name, _return_result) + return app_id + + +def _assert_app_persistence(sqlite_app_engine: Engine, app_id: str, *, persisted: bool) -> None: + with Session(sqlite_app_engine) as session: + assert (session.get(App, app_id) is not None) is persisted class TestAppImportApi: @@ -110,15 +141,16 @@ class TestAppImportApi: api, app: Flask, monkeypatch: pytest.MonkeyPatch, + sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: method = unwrap(api.post) _install_features(monkeypatch, enabled=False) - monkeypatch.setattr( - app_import_module.AppDslService, - "import_app", - lambda service, *_args, **_kwargs: _failed_result_after_starting_transaction(service, app_id=None), + app_id = _install_persisting_service_result( + monkeypatch, + method_name="import_app", + result=_Result(ImportStatus.FAILED, app_id=None), ) with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}): @@ -126,6 +158,7 @@ class TestAppImportApi: assert transaction_events.rollbacks == 1 assert transaction_events.commits == 0 + _assert_app_persistence(sqlite_app_engine, app_id, persisted=False) assert status == 400 assert response["status"] == ImportStatus.FAILED @@ -134,15 +167,16 @@ class TestAppImportApi: api, app: Flask, monkeypatch: pytest.MonkeyPatch, + sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: method = unwrap(api.post) _install_features(monkeypatch, enabled=False) - monkeypatch.setattr( - app_import_module.AppDslService, - "import_app", - lambda *_args, **_kwargs: _Result(ImportStatus.PENDING), + app_id = _install_persisting_service_result( + monkeypatch, + method_name="import_app", + result=_Result(ImportStatus.PENDING), ) with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}): @@ -150,6 +184,7 @@ class TestAppImportApi: assert transaction_events.commits == 1 assert transaction_events.rollbacks == 0 + _assert_app_persistence(sqlite_app_engine, app_id, persisted=True) assert status == 202 assert response["status"] == ImportStatus.PENDING @@ -158,15 +193,16 @@ class TestAppImportApi: api, app: Flask, monkeypatch: pytest.MonkeyPatch, + sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: method = unwrap(api.post) _install_features(monkeypatch, enabled=True) - monkeypatch.setattr( - app_import_module.AppDslService, - "import_app", - lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"), + app_id = _install_persisting_service_result( + monkeypatch, + method_name="import_app", + result=_Result(ImportStatus.COMPLETED, app_id="app-123"), ) update_access = MagicMock() monkeypatch.setattr(app_import_module.EnterpriseService.WebAppAuth, "update_app_access_mode", update_access) @@ -176,6 +212,7 @@ class TestAppImportApi: assert transaction_events.commits == 1 assert transaction_events.rollbacks == 0 + _assert_app_persistence(sqlite_app_engine, app_id, persisted=True) update_access.assert_called_once_with("app-123", "private") assert status == 200 assert response["status"] == ImportStatus.COMPLETED @@ -185,6 +222,7 @@ class TestAppImportApi: api, app: Flask, monkeypatch: pytest.MonkeyPatch, + sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: method = _unwrap(api.post) @@ -196,10 +234,10 @@ class TestAppImportApi: lambda: (_make_account(), "tenant-1"), ) monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) - monkeypatch.setattr( - app_import_module.AppDslService, - "import_app", - lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"), + app_id = _install_persisting_service_result( + monkeypatch, + method_name="import_app", + result=_Result(ImportStatus.COMPLETED, app_id="app-123"), ) monkeypatch.setattr( app_import_module, @@ -211,6 +249,7 @@ class TestAppImportApi: response, status = method() assert transaction_events.commits == 1 + _assert_app_persistence(sqlite_app_engine, app_id, persisted=True) assert status == 200 assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] @@ -219,6 +258,7 @@ class TestAppImportApi: api, app: Flask, monkeypatch: pytest.MonkeyPatch, + sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: method = _unwrap(api.post) @@ -230,10 +270,10 @@ class TestAppImportApi: lambda: (_make_account(), "tenant-1"), ) monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) - monkeypatch.setattr( - app_import_module.AppDslService, - "import_app", - lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"), + app_id = _install_persisting_service_result( + monkeypatch, + method_name="import_app", + result=_Result(ImportStatus.COMPLETED, app_id="app-123"), ) monkeypatch.setattr( app_import_module, @@ -249,6 +289,7 @@ class TestAppImportApi: response, status = method() assert transaction_events.commits == 1 + _assert_app_persistence(sqlite_app_engine, app_id, persisted=True) assert status == 200 assert response["permission_keys"] == [] @@ -263,14 +304,15 @@ class TestAppImportConfirmApi: api, app: Flask, monkeypatch: pytest.MonkeyPatch, + sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: method = unwrap(api.post) - monkeypatch.setattr( - app_import_module.AppDslService, - "confirm_import", - lambda service, *_args, **_kwargs: _failed_result_after_starting_transaction(service), + app_id = _install_persisting_service_result( + monkeypatch, + method_name="confirm_import", + result=_Result(ImportStatus.FAILED), ) with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"): @@ -278,6 +320,7 @@ class TestAppImportConfirmApi: assert transaction_events.rollbacks == 1 assert transaction_events.commits == 0 + _assert_app_persistence(sqlite_app_engine, app_id, persisted=False) assert status == 400 assert response["status"] == ImportStatus.FAILED @@ -286,6 +329,7 @@ class TestAppImportConfirmApi: api, app: Flask, monkeypatch: pytest.MonkeyPatch, + sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: method = _unwrap(api.post) @@ -304,10 +348,10 @@ class TestAppImportConfirmApi: ), ) monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) - monkeypatch.setattr( - app_import_module.AppDslService, - "confirm_import", - lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-456"), + app_id = _install_persisting_service_result( + monkeypatch, + method_name="confirm_import", + result=_Result(ImportStatus.COMPLETED, app_id="app-456"), ) monkeypatch.setattr( app_import_module, @@ -319,6 +363,7 @@ class TestAppImportConfirmApi: response, status = method(import_id="import-1") assert transaction_events.commits == 1 + _assert_app_persistence(sqlite_app_engine, app_id, persisted=True) assert status == 200 assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] @@ -327,6 +372,7 @@ class TestAppImportConfirmApi: api, app: Flask, monkeypatch: pytest.MonkeyPatch, + sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: method = _unwrap(api.post) @@ -345,10 +391,10 @@ class TestAppImportConfirmApi: ), ) monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) - monkeypatch.setattr( - app_import_module.AppDslService, - "confirm_import", - lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-456"), + app_id = _install_persisting_service_result( + monkeypatch, + method_name="confirm_import", + result=_Result(ImportStatus.COMPLETED, app_id="app-456"), ) monkeypatch.setattr( app_import_module, @@ -360,6 +406,7 @@ class TestAppImportConfirmApi: response, status = method(import_id="import-1") assert transaction_events.commits == 1 + _assert_app_persistence(sqlite_app_engine, app_id, persisted=True) assert status == 200 assert response["permission_keys"] == [] diff --git a/api/tests/unit_tests/controllers/console/app/test_generator_api_missing.py b/api/tests/unit_tests/controllers/console/app/test_generator_api_missing.py index bdc3976e14e..d6f6bd703f4 100644 --- a/api/tests/unit_tests/controllers/console/app/test_generator_api_missing.py +++ b/api/tests/unit_tests/controllers/console/app/test_generator_api_missing.py @@ -1,5 +1,6 @@ import pytest from flask import Flask +from sqlalchemy.orm import Session from controllers.console.app import generator as generator_module from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError @@ -102,12 +103,14 @@ def test_structured_output_generate_exceptions(app: Flask, monkeypatch: pytest.M method(api, "t1") -def test_instruction_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [()], indirect=True) +def test_instruction_generate_exceptions( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, +) -> None: api = generator_module.InstructionGenerateApi() method = unwrap(api.post) - from types import SimpleNamespace - - session = SimpleNamespace() exceptions_to_test = [ (ProviderTokenNotInitError("token error"), generator_module.ProviderNotInitializeError), @@ -135,4 +138,4 @@ def test_instruction_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyP }, ): with pytest.raises(expected_exception): - method(api, session, "t1") + method(api, sqlite_session, "t1") diff --git a/api/tests/unit_tests/controllers/console/app/test_model_config_api.py b/api/tests/unit_tests/controllers/console/app/test_model_config_api.py index 8257605fed4..d95214cf0ac 100644 --- a/api/tests/unit_tests/controllers/console/app/test_model_config_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_model_config_api.py @@ -11,7 +11,7 @@ import pytest from flask import Flask from sqlalchemy import func, select from sqlalchemy.engine import Engine -from sqlalchemy.orm import object_session, sessionmaker +from sqlalchemy.orm import Session, object_session, sessionmaker from controllers.common import session as controller_session from controllers.console.app import model_config as model_config_module @@ -29,11 +29,14 @@ def _poison_implicit_app_config_properties(monkeypatch: pytest.MonkeyPatch) -> N @pytest.mark.parametrize("app_mode", [AppMode.CHAT, AppMode.COMPLETION]) +@pytest.mark.parametrize("sqlite_session", [(AppModelConfig,)], indirect=True) def test_post_updates_non_agent_model_config_without_implicit_properties( app: Flask, monkeypatch: pytest.MonkeyPatch, app_mode: AppMode, + sqlite_session: Session, ) -> None: + """Flush a non-agent config through the injected session without legacy model properties.""" api = model_config_module.ModelConfigResource() method = unwrap(api.post) @@ -45,14 +48,16 @@ def test_post_updates_non_agent_model_config_without_implicit_properties( updated_at=None, ) original_config = AppModelConfig(app_id="app-1", created_by="u1", updated_by="u1") + original_config.id = "config-0" original_config.agent_mode = None + sqlite_session.add(original_config) + sqlite_session.commit() _poison_implicit_app_config_properties(monkeypatch) monkeypatch.setattr( model_config_module.AppModelConfigService, "validate_configuration", lambda **_kwargs: {"pre_prompt": "hi"}, ) - session = MagicMock() def _from_model_config_dict(self, model_config): self.pre_prompt = model_config["pre_prompt"] @@ -62,18 +67,16 @@ def test_post_updates_non_agent_model_config_without_implicit_properties( monkeypatch.setattr(AppModelConfig, "from_model_config_dict", _from_model_config_dict) send_mock = MagicMock() monkeypatch.setattr(model_config_module.app_model_config_was_updated, "send", send_mock) - session.get.return_value = original_config with app.test_request_context("/console/api/apps/app-1/model-config", method="POST", json={"pre_prompt": "hi"}): - response = method(api, session, "t1", "u1", app_model=app_model) + response = method(api, sqlite_session, "t1", "u1", app_model=app_model) - session.get.assert_called_once_with(AppModelConfig, "config-0") - session.add.assert_called_once() - session.flush.assert_called_once() - session.commit.assert_not_called() - assert send_mock.call_args.kwargs["session"] is session + assert send_mock.call_args.kwargs["session"] is sqlite_session assert app_model.app_model_config_id == "config-1" assert app_model.mode == app_mode + persisted_config = sqlite_session.get(AppModelConfig, "config-1") + assert persisted_config is not None + assert persisted_config.pre_prompt == "hi" assert response["result"] == "success" @@ -160,7 +163,11 @@ def test_post_uses_one_session_and_rolls_back_when_signal_fails( assert config_count == 1 -def test_post_encrypts_agent_tool_parameters(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [(AppModelConfig,)], indirect=True) +def test_post_encrypts_agent_tool_parameters( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + """Agent parameter encryption reads and writes persisted model configurations.""" api = model_config_module.ModelConfigResource() method = unwrap(api.post) @@ -174,6 +181,7 @@ def test_post_encrypts_agent_tool_parameters(app: Flask, monkeypatch: pytest.Mon _poison_implicit_app_config_properties(monkeypatch) original_config = AppModelConfig(app_id="app-1", created_by="u1", updated_by="u1") + original_config.id = "config-0" original_config.agent_mode = json.dumps( { "enabled": True, @@ -190,8 +198,8 @@ def test_post_encrypts_agent_tool_parameters(app: Flask, monkeypatch: pytest.Mon } ) - session = MagicMock() - session.scalar.return_value = original_config + sqlite_session.add(original_config) + sqlite_session.commit() monkeypatch.setattr( model_config_module.AppModelConfigService, @@ -236,11 +244,11 @@ def test_post_encrypts_agent_tool_parameters(app: Flask, monkeypatch: pytest.Mon monkeypatch.setattr(model_config_module.app_model_config_was_updated, "send", send_mock) with app.test_request_context("/console/api/apps/app-1/model-config", method="POST", json={"pre_prompt": "hi"}): - response = method(api, session, "t1", "u1", app_model=app_model) + response = method(api, sqlite_session, "t1", "u1", app_model=app_model) - stored_config = session.add.call_args[0][0] + stored_config = sqlite_session.get(AppModelConfig, app_model.app_model_config_id) + assert stored_config is not None stored_agent_mode = json.loads(stored_config.agent_mode) - session.scalar.assert_called_once() assert app_model.mode == AppMode.AGENT_CHAT assert stored_agent_mode["tools"][0]["tool_parameters"]["secret"] == "encrypted" assert response["result"] == "success" diff --git a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py index 001ca0bf8fb..7669eed8d2a 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py +++ b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py @@ -14,6 +14,7 @@ import pytest from flask import Flask from controllers.console.auth.activate import ActivateApi, ActivateCheckApi +from controllers.console.auth.error import InvitationAccountMismatchError from controllers.console.error import AccountInFreezeError, AlreadyActivateError from models.account import AccountStatus, TenantAccountRole @@ -202,6 +203,50 @@ class TestActivateApi: with patch("controllers.console.auth.activate.TenantService.switch_tenant") as mock: yield mock + @patch("controllers.console.auth.activate.TenantService.create_tenant_member") + @patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback") + @patch("controllers.console.auth.activate.RegisterService.revoke_token") + @patch("controllers.console.auth.activate.current_account_with_tenant") + @patch("controllers.console.auth.activate.extract_access_token", return_value="access-token") + @patch("controllers.console.auth.activate.db") + def test_activation_rejects_invitation_for_different_authenticated_account( + self, + mock_db: MagicMock, + mock_extract_access_token: MagicMock, + mock_current_account_with_tenant: MagicMock, + mock_revoke_token: MagicMock, + mock_get_invitation: MagicMock, + mock_create_tenant_member: MagicMock, + app: Flask, + mock_invitation: MagicMock, + mock_account: MagicMock, + mock_switch_tenant: MagicMock, + ): + """A logged-in account cannot consume another account's invitation token.""" + current_account = MagicMock() + current_account.id = "current-account-id" + mock_account.id = "invited-account-id" + mock_account.status = AccountStatus.ACTIVE + mock_invitation["data"]["requires_setup"] = False + mock_get_invitation.return_value = mock_invitation + mock_current_account_with_tenant.return_value = (current_account, "current-workspace-id") + + with app.test_request_context( + "/activate", + method="POST", + json={ + "token": "valid_token", + }, + ): + with pytest.raises(InvitationAccountMismatchError): + ActivateApi().post() + + mock_extract_access_token.assert_called_once() + mock_revoke_token.assert_not_called() + mock_create_tenant_member.assert_not_called() + mock_switch_tenant.assert_not_called() + mock_db.session.scalar.assert_not_called() + @patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid") @patch("controllers.console.auth.activate.RegisterService.revoke_token") @patch("controllers.console.auth.activate.db") diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py similarity index 92% rename from api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py rename to api/tests/unit_tests/controllers/console/auth/test_oauth.py index d681bcfdce0..a32cac0225f 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -1,4 +1,4 @@ -"""Testcontainers integration tests for OAuth controller endpoints.""" +"""Unit tests for OAuth controller endpoints.""" from __future__ import annotations @@ -16,15 +16,10 @@ from controllers.console.auth.oauth import ( ) from libs.oauth import OAuthUserInfo, encode_oauth_state from models.account import AccountStatus -from services.account_service import AccountService from services.errors.account import AccountRegisterError class TestGetOAuthProviders: - @pytest.fixture - def app(self, flask_app_with_containers: Flask): - return flask_app_with_containers - @pytest.mark.parametrize( ("github_config", "google_config", "expected_github", "expected_google"), [ @@ -65,10 +60,6 @@ class TestOAuthLogin: def resource(self): return OAuthLogin() - @pytest.fixture - def app(self, flask_app_with_containers: Flask): - return flask_app_with_containers - @pytest.fixture def mock_oauth_provider(self): provider = MagicMock() @@ -181,10 +172,6 @@ class TestOAuthCallback: def resource(self): return OAuthCallback() - @pytest.fixture - def app(self, flask_app_with_containers: Flask): - return flask_app_with_containers - @pytest.fixture def oauth_setup(self): """Common OAuth setup for callback tests""" @@ -263,10 +250,12 @@ class TestOAuthCallback: @patch("controllers.console.auth.oauth.dify_config") @patch("controllers.console.auth.oauth.get_oauth_providers") @patch("controllers.console.auth.oauth.RegisterService") + @patch("controllers.console.auth.oauth.AccountService") @patch("controllers.console.auth.oauth.redirect") def test_invitation_comparison_is_case_insensitive( self, mock_redirect, + mock_account_service, mock_register_service, mock_get_providers, mock_config, @@ -280,13 +269,20 @@ class TestOAuthCallback: ) mock_get_providers.return_value = {"github": oauth_setup["provider"]} mock_register_service.is_valid_invite_token.return_value = True - mock_register_service.get_invitation_by_token.return_value = {"email": "user@example.com"} + mock_register_service.get_invitation_if_token_valid.return_value = { + "account": oauth_setup["account"], + "data": {"email": "user@example.com"}, + "tenant": MagicMock(), + } + mock_account_service.login.return_value = oauth_setup["token_pair"] state = encode_oauth_state(invite_token="invite123", timezone="Asia/Shanghai") with app.test_request_context(f"/auth/oauth/github/callback?code=test_code&state={state}"): resource.get("github") - mock_register_service.get_invitation_by_token.assert_called_once_with(token="invite123") + mock_register_service.get_invitation_if_token_valid.assert_called_once_with( + None, None, "invite123", session=ANY + ) mock_redirect.assert_called_once_with("http://localhost:3000/signin/invite-settings?invite_token=invite123") @pytest.mark.parametrize( @@ -448,10 +444,6 @@ class TestOAuthCallback: class TestAccountGeneration: - @pytest.fixture - def app(self, flask_app_with_containers: Flask): - return flask_app_with_containers - @pytest.fixture def user_info(self): return OAuthUserInfo(id="123", name="Test User", email="test@example.com") @@ -468,39 +460,25 @@ class TestAccountGeneration: self, mock_account_model, mock_get_account, - flask_req_ctx_with_containers, + app: Flask, user_info: OAuthUserInfo, mock_account, ): - # Test OpenID found - mock_account_model.get_by_openid.return_value = mock_account - result = _get_account_by_openid_or_email("github", user_info) - assert result == mock_account - mock_account_model.get_by_openid.assert_called_once_with("github", "123") - mock_get_account.assert_not_called() + with app.test_request_context("/"): + # Test OpenID found + mock_account_model.get_by_openid.return_value = mock_account + result = _get_account_by_openid_or_email("github", user_info) + assert result == mock_account + mock_account_model.get_by_openid.assert_called_once_with("github", "123") + mock_get_account.assert_not_called() - # Test fallback to email lookup - mock_account_model.get_by_openid.return_value = None - mock_get_account.return_value = mock_account + # Test fallback to email lookup + mock_account_model.get_by_openid.return_value = None + mock_get_account.return_value = mock_account - result = _get_account_by_openid_or_email("github", user_info) - assert result == mock_account - mock_get_account.assert_called_once() - - def test_get_account_by_email_with_case_fallback_falls_back_to_lowercase(self): - """Test that case fallback tries lowercase when exact match fails.""" - mock_session = MagicMock() - first_result = MagicMock() - first_result.scalar_one_or_none.return_value = None - expected_account = MagicMock() - second_result = MagicMock() - second_result.scalar_one_or_none.return_value = expected_account - mock_session.execute.side_effect = [first_result, second_result] - - result = AccountService.get_account_by_email_with_case_fallback("Case@Test.com", session=mock_session) - - assert result is expected_account - assert mock_session.execute.call_count == 2 + result = _get_account_by_openid_or_email("github", user_info) + assert result == mock_account + mock_get_account.assert_called_once() @pytest.mark.parametrize( ("allow_register", "existing_account", "should_create"), diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py b/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py index ac1bace882e..e5a4891fe80 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py @@ -1,5 +1,5 @@ import urllib.parse -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest from flask import Flask @@ -91,3 +91,102 @@ def test_oauth_callback_validates_redirect_url_and_appends_new_user_flag( assert response.headers["Location"] == ( f"{expected_target_url}{query_char}oauth_new_user={str(oauth_new_user).lower()}" ) + + +def test_oauth_callback_with_invitation_establishes_console_session(app: Flask) -> None: + oauth_provider = MagicMock() + oauth_provider.get_access_token.return_value = "google-access-token" + oauth_provider.get_user_info.return_value = OAuthUserInfo( + id="google-user-123", + name="Test User", + email="Invitee@Example.com", + ) + account = MagicMock() + account.status = AccountStatus.ACTIVE + token_pair = MagicMock() + token_pair.access_token = "dify-access-token" + token_pair.refresh_token = "dify-refresh-token" + token_pair.csrf_token = "dify-csrf-token" + state = encode_oauth_state(invite_token="invite-token") + + with ( + patch("controllers.console.auth.oauth.get_oauth_providers", return_value={"google": oauth_provider}), + patch("controllers.console.auth.oauth.dify_config.CONSOLE_WEB_URL", CONSOLE_WEB_URL), + patch("controllers.console.auth.oauth.RegisterService") as register_service, + patch("controllers.console.auth.oauth.AccountService.link_account_integrate") as link_account, + patch("controllers.console.auth.oauth.AccountService.login", return_value=token_pair) as login, + patch("controllers.console.auth.oauth.TenantService.create_owner_tenant_if_not_exist") as create_workspace, + patch("controllers.console.auth.oauth.set_access_token_to_cookie") as set_access_cookie, + patch("controllers.console.auth.oauth.set_refresh_token_to_cookie") as set_refresh_cookie, + patch("controllers.console.auth.oauth.set_csrf_token_to_cookie") as set_csrf_cookie, + app.test_request_context(f"/oauth/authorize/google?code=test-code&state={state}"), + ): + register_service.is_valid_invite_token.return_value = True + register_service.get_invitation_if_token_valid.return_value = { + "account": account, + "data": { + "account_id": "account-id", + "email": "invitee@example.com", + "workspace_id": "workspace-id", + }, + "tenant": MagicMock(), + } + + response = OAuthCallback().get("google") + + assert response.status_code == 302 + assert response.headers["Location"] == (f"{CONSOLE_WEB_URL}/signin/invite-settings?invite_token=invite-token") + link_account.assert_called_once_with("google", "google-user-123", account, session=ANY) + login.assert_called_once_with(account=account, session=ANY, ip_address=ANY) + create_workspace.assert_not_called() + set_access_cookie.assert_called_once_with(ANY, response, "dify-access-token") + set_refresh_cookie.assert_called_once_with(ANY, response, "dify-refresh-token") + set_csrf_cookie.assert_called_once_with(ANY, response, "dify-csrf-token") + + +def test_oauth_callback_with_invitation_rejects_another_account(app: Flask) -> None: + oauth_provider = MagicMock() + oauth_provider.get_access_token.return_value = "google-access-token" + oauth_provider.get_user_info.return_value = OAuthUserInfo( + id="google-user-123", + name="Test User", + email="another@example.com", + ) + account = MagicMock() + account.status = AccountStatus.ACTIVE + state = encode_oauth_state(invite_token="invite-token") + + with ( + patch("controllers.console.auth.oauth.get_oauth_providers", return_value={"google": oauth_provider}), + patch("controllers.console.auth.oauth.dify_config.CONSOLE_WEB_URL", CONSOLE_WEB_URL), + patch("controllers.console.auth.oauth.RegisterService") as register_service, + patch("controllers.console.auth.oauth.AccountService.link_account_integrate") as link_account, + patch("controllers.console.auth.oauth.AccountService.login") as login, + patch("controllers.console.auth.oauth.set_access_token_to_cookie") as set_access_cookie, + patch("controllers.console.auth.oauth.set_refresh_token_to_cookie") as set_refresh_cookie, + patch("controllers.console.auth.oauth.set_csrf_token_to_cookie") as set_csrf_cookie, + app.test_request_context(f"/oauth/authorize/google?code=test-code&state={state}"), + ): + register_service.is_valid_invite_token.return_value = True + register_service.get_invitation_if_token_valid.return_value = { + "account": account, + "data": { + "account_id": "account-id", + "email": "invitee@example.com", + "workspace_id": "workspace-id", + }, + "tenant": MagicMock(), + } + + response = OAuthCallback().get("google") + + query = urllib.parse.parse_qs(urllib.parse.urlparse(response.headers["Location"]).query) + assert response.status_code == 302 + assert query["message"] == ["This invitation was sent to another account. Please sign in with the invited account."] + assert query["invite_token"] == ["invite-token"] + link_account.assert_not_called() + login.assert_not_called() + register_service.revoke_token.assert_not_called() + set_access_cookie.assert_not_called() + set_refresh_cookie.assert_not_called() + set_csrf_cookie.assert_not_called() diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py similarity index 77% rename from api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py rename to api/tests/unit_tests/controllers/console/auth/test_password_reset.py index 5fc3b3084a9..0ee9cf5248b 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py @@ -1,12 +1,14 @@ -"""Testcontainers integration tests for password reset authentication flows.""" +"""Unit tests for password reset controller flows.""" from __future__ import annotations +from collections.abc import Generator +from contextlib import contextmanager from unittest.mock import MagicMock, patch import pytest from flask import Flask -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, scoped_session, sessionmaker from controllers.console.auth.error import ( EmailCodeError, @@ -21,47 +23,58 @@ from controllers.console.auth.forgot_password import ( ForgotPasswordSendEmailApi, ) from controllers.console.error import AccountNotFound, EmailSendIpLimitError -from tests.test_containers_integration_tests.controllers.console.helpers import ensure_dify_setup +from models.account import Account, Tenant, TenantAccountJoin +from services.feature_service import SystemFeatureModel + +SQLITE_MODELS = (Account, Tenant, TenantAccountJoin) + + +@contextmanager +def _bind_database_session(session: Session) -> Generator[scoped_session[Session]]: + """Bind the controller's session proxy to the SQLite test engine.""" + + database_session = scoped_session(sessionmaker(bind=session.get_bind(), expire_on_commit=False)) + try: + with patch("controllers.console.auth.forgot_password.db.session", database_session): + yield database_session + finally: + database_session.remove() + + +@pytest.fixture(autouse=True) +def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep endpoint decorators deterministic without requiring the configured app database.""" + + monkeypatch.setattr("controllers.console.wraps.dify_config.EDITION", "CLOUD") + monkeypatch.setattr( + "controllers.console.wraps.FeatureService.get_system_features", + lambda: SystemFeatureModel(enable_email_password_login=True), + ) class TestForgotPasswordSendEmailApi: """Test cases for sending password reset emails.""" - @pytest.fixture - def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): - ensure_dify_setup(db_session_with_containers) - return flask_app_with_containers - - @pytest.fixture - def mock_account(self): - """Create mock account object.""" - account = MagicMock() - account.email = "test@example.com" - account.name = "Test User" - return account - + @pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) @patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit") - @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") @patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email") - @patch("controllers.console.auth.forgot_password.FeatureService.get_system_features") def test_send_reset_email_success( self, - mock_get_features, mock_send_email, - mock_get_account, mock_is_ip_limit, app: Flask, - mock_account, + sqlite_session: Session, ): # Arrange mock_is_ip_limit.return_value = False - mock_get_account.return_value = mock_account mock_send_email.return_value = "reset_token_123" - mock_get_features.return_value.is_allow_register = True # Act - with app.test_request_context( - "/forgot-password", method="POST", json={"email": "test@example.com", "language": "en-US"} + with ( + _bind_database_session(sqlite_session), + app.test_request_context( + "/forgot-password", method="POST", json={"email": "test@example.com", "language": "en-US"} + ), ): api = ForgotPasswordSendEmailApi() response = api.post() @@ -98,20 +111,17 @@ class TestForgotPasswordSendEmailApi: (None, "en-US"), # Defaults to en-US when not provided ], ) + @pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) @patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit") - @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") @patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email") - @patch("controllers.console.auth.forgot_password.FeatureService.get_system_features") def test_send_reset_email_language_handling( self, - mock_get_features, mock_send_email, - mock_get_account, mock_is_ip_limit, - app: Flask, - mock_account, language_input, expected_language, + app: Flask, + sqlite_session: Session, ): """ Test password reset email with different language preferences. @@ -122,13 +132,14 @@ class TestForgotPasswordSendEmailApi: """ # Arrange mock_is_ip_limit.return_value = False - mock_get_account.return_value = mock_account mock_send_email.return_value = "token" - mock_get_features.return_value.is_allow_register = True # Act - with app.test_request_context( - "/forgot-password", method="POST", json={"email": "test@example.com", "language": language_input} + with ( + _bind_database_session(sqlite_session), + app.test_request_context( + "/forgot-password", method="POST", json={"email": "test@example.com", "language": language_input} + ), ): api = ForgotPasswordSendEmailApi() api.post() @@ -141,11 +152,6 @@ class TestForgotPasswordSendEmailApi: class TestForgotPasswordCheckApi: """Test cases for verifying password reset codes.""" - @pytest.fixture - def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): - ensure_dify_setup(db_session_with_containers) - return flask_app_with_containers - @patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit") @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") @patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token") @@ -153,10 +159,10 @@ class TestForgotPasswordCheckApi: @patch("controllers.console.auth.forgot_password.AccountService.reset_forgot_password_error_rate_limit") def test_verify_code_success( self, - mock_reset_rate_limit, - mock_generate_token, - mock_revoke_token, - mock_get_data, + mock_reset_rate_limit: MagicMock, + mock_generate_token: MagicMock, + mock_revoke_token: MagicMock, + mock_get_data: MagicMock, mock_is_rate_limit, app: Flask, ): @@ -200,10 +206,10 @@ class TestForgotPasswordCheckApi: @patch("controllers.console.auth.forgot_password.AccountService.reset_forgot_password_error_rate_limit") def test_verify_code_preserves_token_email_case( self, - mock_reset_rate_limit, - mock_generate_token, - mock_revoke_token, - mock_get_data, + mock_reset_rate_limit: MagicMock, + mock_generate_token: MagicMock, + mock_revoke_token: MagicMock, + mock_get_data: MagicMock, mock_is_rate_limit, app: Flask, ): @@ -325,33 +331,15 @@ class TestForgotPasswordCheckApi: class TestForgotPasswordResetApi: """Test cases for resetting password with verified token.""" - @pytest.fixture - def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): - ensure_dify_setup(db_session_with_containers) - return flask_app_with_containers - - @pytest.fixture - def mock_account(self): - """Create mock account object.""" - account = MagicMock() - account.email = "test@example.com" - account.name = "Test User" - return account - + @pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") @patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token") - @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.forgot_password.db") - @patch("controllers.console.auth.forgot_password.TenantService.get_join_tenants") def test_reset_password_success( self, - mock_get_tenants, - mock_db, - mock_get_account, - mock_revoke_token, - mock_get_data, + mock_revoke_token: MagicMock, + mock_get_data: MagicMock, app: Flask, - mock_account, + sqlite_session: Session, ): """ Test successful password reset. @@ -363,25 +351,39 @@ class TestForgotPasswordResetApi: """ # Arrange mock_get_data.return_value = {"email": "test@example.com", "phase": "reset"} - mock_get_account.return_value = mock_account - mock_db.session.merge.return_value = mock_account - mock_get_tenants.return_value = [MagicMock()] # Act - with app.test_request_context( - "/forgot-password/resets", - method="POST", - json={"token": "valid_token", "new_password": "NewPass123!", "password_confirm": "NewPass123!"}, - ): - api = ForgotPasswordResetApi() - response = api.post() + with _bind_database_session(sqlite_session) as database_session: + account = Account(name="Test User", email="test@example.com") + tenant = Tenant(name="Test Workspace") + database_session.add_all([account, tenant]) + database_session.flush() + database_session.add(TenantAccountJoin(tenant_id=tenant.id, account_id=account.id)) + database_session.commit() + account_id = account.id + + with app.test_request_context( + "/forgot-password/resets", + method="POST", + json={ + "token": "valid_token", + "new_password": "NewPass123!", + "password_confirm": "NewPass123!", + }, + ): + api = ForgotPasswordResetApi() + response = api.post() + + updated_account = database_session.get(Account, account_id) # Assert assert response["result"] == "success" mock_revoke_token.assert_called_once_with("valid_token") + assert updated_account is not None + assert updated_account.password is not None + assert updated_account.password_salt is not None - @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") - def test_reset_password_mismatch(self, mock_get_data, app: Flask): + def test_reset_password_mismatch(self, app: Flask): """ Test password reset with mismatched passwords. @@ -389,9 +391,6 @@ class TestForgotPasswordResetApi: - PasswordMismatchError is raised when passwords don't match - No password update occurs """ - # Arrange - mock_get_data.return_value = {"email": "test@example.com", "phase": "reset"} - # Act & Assert with app.test_request_context( "/forgot-password/resets", @@ -445,10 +444,12 @@ class TestForgotPasswordResetApi: with pytest.raises(InvalidTokenError): api.post() + @pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") @patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token") - @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") - def test_reset_password_account_not_found(self, mock_get_account, mock_revoke_token, mock_get_data, app: Flask): + def test_reset_password_account_not_found( + self, mock_revoke_token, mock_get_data, app: Flask, sqlite_session: Session + ): """ Test password reset for non-existent account. @@ -457,13 +458,15 @@ class TestForgotPasswordResetApi: """ # Arrange mock_get_data.return_value = {"email": "nonexistent@example.com", "phase": "reset"} - mock_get_account.return_value = None # Act & Assert - with app.test_request_context( - "/forgot-password/resets", - method="POST", - json={"token": "token", "new_password": "NewPass123!", "password_confirm": "NewPass123!"}, + with ( + _bind_database_session(sqlite_session), + app.test_request_context( + "/forgot-password/resets", + method="POST", + json={"token": "token", "new_password": "NewPass123!", "password_confirm": "NewPass123!"}, + ), ): api = ForgotPasswordResetApi() with pytest.raises(AccountNotFound): diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index e344a4c8bab..c0f9a902a56 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -1,3 +1,9 @@ +"""RAG pipeline workflow controller serialization tests. + +Handlers that own transactions run against real SQLite sessions so response +DTOs must be materialized before those transaction contexts close. +""" + from __future__ import annotations from datetime import datetime @@ -7,6 +13,8 @@ from unittest.mock import PropertyMock, patch import pytest from flask import Flask +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session from controllers.console.datasets.rag_pipeline import rag_pipeline_workflow as module from models.account import Account, TenantAccountRole @@ -73,90 +81,80 @@ def test_draft_rag_pipeline_workflow_get_serializes_response_model(monkeypatch: def test_published_rag_pipeline_workflows_serialize_items_before_session_closes( - app, monkeypatch: pytest.MonkeyPatch + app, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine ) -> None: api = module.PublishedAllRagPipelineApi() handler = unwrap_all(api.get) - session_state = {"open": False} - - class _SessionContext: - def __enter__(self): - session_state["open"] = True - return object() - - def __exit__(self, exc_type, exc, tb): - session_state["open"] = False - return False - - class _SessionMaker: - def begin(self): - return _SessionContext() + session_state: dict[str, Session] = {} base_workflow = _make_workflow() class _Workflow: def __getattr__(self, name: str): - assert session_state["open"] is True + assert session_state["session"].in_transaction() is True return getattr(base_workflow, name) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object(), session=lambda: object())) - monkeypatch.setattr(module, "sessionmaker", lambda *_args, **_kwargs: _SessionMaker()) + def _get_all_published_workflow(**kwargs): + session_state["session"] = kwargs["session"] + return [_Workflow()], False + monkeypatch.setattr( module, "RagPipelineService", - lambda *_args, **_kwargs: SimpleNamespace(get_all_published_workflow=lambda **_kwargs: ([_Workflow()], False)), + lambda *_args, **_kwargs: SimpleNamespace(get_all_published_workflow=_get_all_published_workflow), ) - with app.test_request_context( - "/rag/pipelines/pipeline-1/workflows", - method="GET", - query_string={"page": 1, "limit": 10, "user_id": "", "named_only": "false"}, - ): - response = handler(api, _account(), pipeline=_pipeline()) + with Session(sqlite_engine) as request_session: + monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine, session=lambda: request_session)) + with app.test_request_context( + "/rag/pipelines/pipeline-1/workflows", + method="GET", + query_string={"page": 1, "limit": 10, "user_id": "", "named_only": "false"}, + ): + response = handler(api, _account(), pipeline=_pipeline()) + assert session_state["session"].in_transaction() is False assert response["items"][0]["id"] == "workflow-1" assert response["page"] == 1 assert response["limit"] == 10 assert response["has_more"] is False -def test_rag_pipeline_workflow_patch_serializes_response_model(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +def test_rag_pipeline_workflow_patch_serializes_response_model( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine +) -> None: workflow = _make_workflow(marked_name="Updated release") + captured_session: dict[str, Session] = {} - class _SessionContext: - def __enter__(self): - return object() + def _update_workflow(**kwargs): + captured_session["session"] = kwargs["session"] + assert kwargs["session"].in_transaction() is True + return workflow - def __exit__(self, exc_type, exc, tb): - return False - - class _SessionMaker: - def begin(self): - return _SessionContext() - - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object(), session=lambda: object())) - monkeypatch.setattr(module, "sessionmaker", lambda *_args, **_kwargs: _SessionMaker()) monkeypatch.setattr( module, "RagPipelineService", - lambda *_args, **_kwargs: SimpleNamespace(update_workflow=lambda **_kwargs: workflow), + lambda *_args, **_kwargs: SimpleNamespace(update_workflow=_update_workflow), ) payload: dict[str, object] = {"marked_name": "Updated release"} api = module.RagPipelineByIdApi() handler = unwrap_all(api.patch) - with ( - app.test_request_context("/rag/pipelines/pipeline-1/workflows/workflow-1", method="PATCH", json=payload), - patch.object(type(module.console_ns), "payload", new_callable=PropertyMock, return_value=payload), - ): - response = handler( - api, - _account(), - pipeline=_pipeline(), - workflow_id="workflow-1", - ) + with Session(sqlite_engine) as request_session: + monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine, session=lambda: request_session)) + with ( + app.test_request_context("/rag/pipelines/pipeline-1/workflows/workflow-1", method="PATCH", json=payload), + patch.object(type(module.console_ns), "payload", new_callable=PropertyMock, return_value=payload), + ): + response = handler( + api, + _account(), + pipeline=_pipeline(), + workflow_id="workflow-1", + ) + assert captured_session["session"].in_transaction() is False assert response["id"] == "workflow-1" assert response["marked_name"] == "Updated release" assert response["hash"] == "hash-1" diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py index 98b538800ac..8a542ef269e 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py @@ -8,6 +8,8 @@ from unittest.mock import Mock import pytest from flask import Flask +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import HTTPException, NotFound from controllers.console.snippets import snippet_workflow as snippet_workflow_module @@ -36,7 +38,9 @@ def _snippet(**overrides) -> CustomizedSnippet: @pytest.fixture(autouse=True) -def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch) -> None: +def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None: + snippet_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + def factory(): try: return snippet_workflow_module.SnippetService(snippet_workflow_module._snippet_session_maker()) @@ -44,7 +48,7 @@ def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch) -> None: return snippet_workflow_module.SnippetService() monkeypatch.setattr(snippet_workflow_module, "_snippet_service", factory) - monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=Mock())) + monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", lambda: snippet_session_maker) def test_get_snippet_requires_snippet_id(app): @@ -150,28 +154,28 @@ def test_published_workflow_get_returns_none_when_not_published(app) -> None: assert handler(api, snippet=SimpleNamespace(id="snippet-1", is_published=False)) is None -def test_published_workflow_post_returns_400_when_publish_fails(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [(CustomizedSnippet,)], indirect=True) +def test_published_workflow_post_returns_400_when_publish_fails( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, +) -> None: user = _account("account-1") snippet = _snippet() - merged_snippet = _snippet() - session = SimpleNamespace(merge=Mock(return_value=merged_snippet), commit=Mock()) + sqlite_session.add(snippet) + sqlite_session.commit() - class SessionContext: - def __init__(self, engine): - self.engine = engine + def fail_publish(*, session: Session, snippet: CustomizedSnippet, account: Account): + snippet.name = "Uncommitted name" + session.add(snippet) + raise ValueError("No valid workflow found.") - def __enter__(self): - return session - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) - monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=sqlite_engine)) monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(publish_workflow=Mock(side_effect=ValueError("No valid workflow found."))), + lambda: SimpleNamespace(publish_workflow=Mock(side_effect=fail_publish)), ) api = snippet_workflow_module.SnippetPublishedWorkflowApi() @@ -182,7 +186,8 @@ def test_published_workflow_post_returns_400_when_publish_fails(app: Flask, monk assert status_code == 400 assert response == {"message": "No valid workflow found."} - session.commit.assert_not_called() + sqlite_session.refresh(snippet) + assert snippet.name == "Snippet" def test_default_block_configs_delegates_to_service(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -203,7 +208,11 @@ def test_default_block_configs_delegates_to_service(app: Flask, monkeypatch: pyt get_default_block_configs.assert_called_once() -def test_list_published_snippet_workflows_includes_input_fields(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +def test_list_published_snippet_workflows_includes_input_fields( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, +) -> None: workflow = SimpleNamespace( id="workflow-1", graph_dict={"nodes": [], "edges": []}, @@ -224,18 +233,7 @@ def test_list_published_snippet_workflows_includes_input_fields(app: Flask, monk input_fields = [{"variable": "query", "type": "text"}] snippet = _snippet(input_fields=json.dumps(input_fields)) - class SessionContext: - def __init__(self, engine): - self.engine = engine - - def __enter__(self): - return Mock() - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) - monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=sqlite_engine)) monkeypatch.setattr( snippet_workflow_module, "SnippetService", @@ -364,8 +362,11 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_gra assert exc.value.description == "invalid snippet workflow graph" +@pytest.mark.parametrize("sqlite_session", [(CustomizedSnippet,)], indirect=True) def test_update_published_snippet_workflow_returns_updated_workflow( - app: Flask, monkeypatch: pytest.MonkeyPatch + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ) -> None: workflow = SimpleNamespace( id="workflow-1", @@ -387,21 +388,15 @@ def test_update_published_snippet_workflow_returns_updated_workflow( user = _account("account-1") input_fields = [{"variable": "query", "type": "text"}] snippet = _snippet(input_fields=json.dumps(input_fields)) - session = SimpleNamespace() - update_workflow = Mock(return_value=workflow) + sqlite_session.add(snippet) + sqlite_session.commit() - class TransactionContext: - def __enter__(self): - return session + def update_persisted_snippet(*, session: Session, snippet: CustomizedSnippet, **_kwargs): + merged_snippet = session.merge(snippet) + merged_snippet.description = "Updated in transaction" + return workflow - def __exit__(self, exc_type, exc, tb): - return False - - class SessionMaker: - def begin(self): - return TransactionContext() - - monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker())) + update_workflow = Mock(side_effect=update_persisted_snippet) monkeypatch.setattr( snippet_workflow_module, "SnippetService", @@ -418,16 +413,18 @@ def test_update_published_snippet_workflow_returns_updated_workflow( ): response = handler(api, user, snippet, workflow_id="workflow-1") - update_workflow.assert_called_once_with( - session=session, - snippet=snippet, - workflow_id="workflow-1", - account=user, - data={"marked_name": "v1", "marked_comment": "first version"}, - ) + update_workflow.assert_called_once() + update_call = update_workflow.call_args.kwargs + assert isinstance(update_call["session"], Session) + assert update_call["snippet"] is snippet + assert update_call["workflow_id"] == "workflow-1" + assert update_call["account"] is user + assert update_call["data"] == {"marked_name": "v1", "marked_comment": "first version"} assert response["marked_name"] == "v1" assert response["marked_comment"] == "first version" assert response["input_fields"] == input_fields + sqlite_session.refresh(snippet) + assert snippet.description == "Updated in transaction" def test_update_published_snippet_workflow_returns_400_when_no_fields(app: Flask) -> None: @@ -441,26 +438,25 @@ def test_update_published_snippet_workflow_returns_400_when_no_fields(app: Flask assert response == {"message": "No valid fields to update"} -def test_update_published_snippet_workflow_raises_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [(CustomizedSnippet,)], indirect=True) +def test_update_published_snippet_workflow_raises_not_found( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, +) -> None: user = _account("account-1") snippet = _snippet() + sqlite_session.add(snippet) + sqlite_session.commit() - class TransactionContext: - def __enter__(self): - return SimpleNamespace() + def update_missing_workflow(*, session: Session, snippet: CustomizedSnippet, **_kwargs): + merged_snippet = session.merge(snippet) + merged_snippet.name = "Rolled back name" - def __exit__(self, exc_type, exc, tb): - return False - - class SessionMaker: - def begin(self): - return TransactionContext() - - monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker())) monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(update_workflow=Mock(return_value=None)), + lambda: SimpleNamespace(update_workflow=Mock(side_effect=update_missing_workflow)), ) api = snippet_workflow_module.SnippetWorkflowByIdApi() @@ -474,6 +470,9 @@ def test_update_published_snippet_workflow_raises_not_found(app: Flask, monkeypa with pytest.raises(NotFound, match="Workflow not found"): handler(api, user, snippet, workflow_id="missing-workflow") + sqlite_session.refresh(snippet) + assert snippet.name == "Snippet" + def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: snippet = _snippet() diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py index 03a6fdb0d60..0a9382f6d59 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py @@ -1,15 +1,29 @@ +from collections.abc import Iterator from inspect import unwrap from types import SimpleNamespace from unittest.mock import Mock import pytest from flask import Flask +from sqlalchemy import event, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, scoped_session, sessionmaker from controllers.console.snippets import snippet_workflow_draft_variable as module -from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID +from graphon.variables import StringSegment from models.account import Account, AccountStatus +from models.workflow import WorkflowDraftVariable, WorkflowDraftVariableFile from services.workflow_draft_variable_service import WorkflowDraftVariableList +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize( + "sqlite_session", + [(WorkflowDraftVariable, WorkflowDraftVariableFile)], + indirect=True, + ), +] + def _make_account() -> Account: account = Account( @@ -21,8 +35,31 @@ def _make_account() -> Account: return account +def _make_node_variable( + variable_id: str, + *, + app_id: str = "snippet-1", + user_id: str = "user-1", + node_id: str = "llm-1", + name: str | None = None, + node_execution_id: str | None = "execution-1", +) -> WorkflowDraftVariable: + """Create a valid node variable for persisted controller tests.""" + variable = WorkflowDraftVariable.new_node_variable( + app_id=app_id, + user_id=user_id, + node_id=node_id, + name=name or variable_id, + value=StringSegment(value=f"value-{variable_id}"), + node_execution_id=node_execution_id or "execution-1", + ) + variable.id = variable_id + variable.node_execution_id = node_execution_id + return variable + + @pytest.fixture(autouse=True) -def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch): +def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch) -> None: def factory(): service_factory = module.SnippetService if isinstance(service_factory, type): @@ -33,33 +70,69 @@ def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch): @pytest.fixture -def app(): +def app() -> Flask: app = Flask("test_snippet_workflow_draft_variable") app.config["TESTING"] = True return app -def test_ensure_snippet_draft_variable_row_allowed_rejects_system_variable(): - variable = SimpleNamespace(node_id=SYSTEM_VARIABLE_NODE_ID) +@pytest.fixture +def controller_sessions( + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, +) -> Iterator[scoped_session[Session]]: + """Bind both controller session styles to the isolated SQLite engine.""" + sessions = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine, session=sessions)) + try: + yield sessions + finally: + sessions.remove() + + +def _persist_variables(sqlite_session: Session, *variables: WorkflowDraftVariable) -> None: + sqlite_session.add_all(variables) + sqlite_session.commit() + + +def _variable_ids(sqlite_engine: Engine) -> set[str]: + with Session(sqlite_engine) as session: + return set(session.scalars(select(WorkflowDraftVariable.id))) + + +def test_ensure_snippet_draft_variable_row_allowed_rejects_system_variable() -> None: + variable = WorkflowDraftVariable.new_sys_variable( + app_id="snippet-1", + user_id="user-1", + name="query", + value=StringSegment(value="query"), + node_execution_id="execution-1", + editable=True, + ) with pytest.raises(module.NotFoundError, match="variable not found"): module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") -def test_ensure_snippet_draft_variable_row_allowed_rejects_conversation_variable(): - variable = SimpleNamespace(node_id=CONVERSATION_VARIABLE_NODE_ID) +def test_ensure_snippet_draft_variable_row_allowed_rejects_conversation_variable() -> None: + variable = WorkflowDraftVariable.new_conversation_variable( + app_id="snippet-1", + user_id="user-1", + name="conversation-name", + value=StringSegment(value="value"), + ) with pytest.raises(module.NotFoundError, match="variable not found"): module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") -def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable(): - variable = SimpleNamespace(node_id="llm-1") +def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable() -> None: + variable = _make_node_variable("var-1") module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") -def test_conversation_variables_returns_empty_list(app: Flask): +def test_conversation_variables_returns_empty_list(app: Flask) -> None: api = module.SnippetConversationVariableCollectionApi() handler = unwrap(api.get) @@ -69,7 +142,7 @@ def test_conversation_variables_returns_empty_list(app: Flask): assert result == WorkflowDraftVariableList(variables=[]) -def test_system_variables_returns_empty_list(app: Flask): +def test_system_variables_returns_empty_list(app: Flask) -> None: api = module.SnippetSystemVariableCollectionApi() handler = unwrap(api.get) @@ -79,12 +152,17 @@ def test_system_variables_returns_empty_list(app: Flask): assert result == WorkflowDraftVariableList(variables=[]) -def test_delete_variable_collection_deletes_current_user_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): - draft_var_service = SimpleNamespace(delete_user_workflow_variables=Mock()) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) +def test_delete_variable_collection_deletes_only_current_user_variables( + app: Flask, + sqlite_session: Session, + sqlite_engine: Engine, + controller_sessions: scoped_session[Session], +) -> None: + matching = _make_node_variable("matching", name="matching") + matching_second = _make_node_variable("matching-second", node_id="tool-1", name="matching-second") + other_user = _make_node_variable("other-user", user_id="user-2", name="other-user") + other_snippet = _make_node_variable("other-snippet", app_id="snippet-2", name="other-snippet") + _persist_variables(sqlite_session, matching, matching_second, other_user, other_snippet) api = module.SnippetWorkflowVariableCollectionApi() handler = unwrap(api.delete) @@ -92,11 +170,14 @@ def test_delete_variable_collection_deletes_current_user_variables(app: Flask, m response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) assert response.status_code == 204 - draft_var_service.delete_user_workflow_variables.assert_called_once_with("snippet-1", user_id="user-1") - db_session.commit.assert_called_once() + assert _variable_ids(sqlite_engine) == {other_user.id, other_snippet.id} + assert not controller_sessions().in_transaction() -def test_variable_collection_get_raises_when_draft_workflow_missing(app: Flask, monkeypatch: pytest.MonkeyPatch): +def test_variable_collection_get_raises_when_draft_workflow_missing( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr( module, "SnippetService", @@ -111,47 +192,37 @@ def test_variable_collection_get_raises_when_draft_workflow_missing(app: Flask, handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) -def test_node_variable_collection_get_lists_node_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): - variables = WorkflowDraftVariableList(variables=[SimpleNamespace(id="var-1")]) - list_node_variables = Mock(return_value=variables) - - class SessionContext: - def __init__(self, bind, expire_on_commit=False): - self.bind = bind - self.expire_on_commit = expire_on_commit - - def __enter__(self): - return SimpleNamespace() - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr(module, "Session", SessionContext) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr( - module, - "WorkflowDraftVariableService", - Mock(return_value=SimpleNamespace(list_node_variables=list_node_variables)), - ) - +def test_node_variable_collection_get_lists_persisted_node_variables( + app: Flask, + sqlite_session: Session, + controller_sessions: scoped_session[Session], +) -> None: + matching = _make_node_variable("matching", name="matching") + other_node = _make_node_variable("other-node", node_id="tool-1", name="other-node") + other_user = _make_node_variable("other-user", user_id="user-2", name="other-user") + other_snippet = _make_node_variable("other-snippet", app_id="snippet-2", name="other-snippet") + _persist_variables(sqlite_session, matching, other_node, other_user, other_snippet) api = module.SnippetNodeVariableCollectionApi() handler = unwrap(api.get) with app.test_request_context("/"): result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1") - assert result is variables - list_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1") + assert [variable.id for variable in result.variables] == [matching.id] + assert controller_sessions().get_bind() is not None -def test_node_variable_collection_delete_deletes_node_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): - delete_node_variables = Mock() - draft_var_service = SimpleNamespace(delete_node_variables=delete_node_variables) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) - +def test_node_variable_collection_delete_deletes_only_requested_node_variables( + app: Flask, + sqlite_session: Session, + sqlite_engine: Engine, + controller_sessions: scoped_session[Session], +) -> None: + matching = _make_node_variable("matching", name="matching") + matching_second = _make_node_variable("matching-second", name="matching-second") + other_node = _make_node_variable("other-node", node_id="tool-1", name="other-node") + other_user = _make_node_variable("other-user", user_id="user-2", name="other-user") + _persist_variables(sqlite_session, matching, matching_second, other_node, other_user) api = module.SnippetNodeVariableCollectionApi() handler = unwrap(api.delete) @@ -159,83 +230,102 @@ def test_node_variable_collection_delete_deletes_node_variables(app: Flask, monk response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1") assert response.status_code == 204 - delete_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1") - db_session.commit.assert_called_once() + assert _variable_ids(sqlite_engine) == {other_node.id, other_user.id} + assert not controller_sessions().in_transaction() -def test_variable_patch_returns_variable_when_no_changes(app: Flask, monkeypatch: pytest.MonkeyPatch): - variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") - draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), update_variable=Mock()) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) +def test_variable_patch_returns_persisted_variable_without_committing_when_no_changes( + app: Flask, + sqlite_session: Session, + controller_sessions: scoped_session[Session], +) -> None: + variable = _make_node_variable("var-1") + _persist_variables(sqlite_session, variable) + session = controller_sessions() + commits: list[bool] = [] + def record_commit(_session: Session) -> None: + commits.append(True) + + event.listen(session, "after_commit", record_commit) api = module.SnippetVariableApi() handler = unwrap(api.patch) + try: + with app.test_request_context("/", method="PATCH", json={}): + result = handler( + api, + _make_account(), + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + variable_id="var-1", + ) + finally: + event.remove(session, "after_commit", record_commit) - with app.test_request_context("/", method="PATCH", json={}): - result = handler( - api, - _make_account(), - snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), - variable_id="var-1", - ) - - assert result is variable - draft_var_service.update_variable.assert_not_called() - db_session.commit.assert_not_called() + assert result.id == variable.id + assert result.app_id == "snippet-1" + assert commits == [] + assert session.in_transaction() -def test_variable_delete_deletes_variable(app: Flask, monkeypatch: pytest.MonkeyPatch): - variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") - delete_variable = Mock() - draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), delete_variable=delete_variable) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) - +def test_variable_delete_deletes_persisted_variable( + app: Flask, + sqlite_session: Session, + sqlite_engine: Engine, + controller_sessions: scoped_session[Session], +) -> None: + variable = _make_node_variable("var-1") + retained = _make_node_variable("var-2", name="retained") + _persist_variables(sqlite_session, variable, retained) api = module.SnippetVariableApi() handler = unwrap(api.delete) with app.test_request_context("/", method="DELETE"): - response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1") + response = handler( + api, + _make_account(), + snippet=SimpleNamespace(id="snippet-1"), + variable_id=variable.id, + ) assert response.status_code == 204 - delete_variable.assert_called_once_with(variable) - db_session.commit.assert_called_once() + assert _variable_ids(sqlite_engine) == {retained.id} + assert not controller_sessions().in_transaction() -def test_variable_reset_returns_no_content_when_reset_result_is_none(app: Flask, monkeypatch: pytest.MonkeyPatch): - variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") - draft_workflow = SimpleNamespace(id="workflow-1") - draft_var_service = SimpleNamespace( - get_variable=Mock(return_value=variable), - reset_variable=Mock(return_value=None), - ) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) +def test_variable_reset_deletes_variable_without_node_execution( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + controller_sessions: scoped_session[Session], +) -> None: + variable = _make_node_variable("var-1", node_execution_id=None) + _persist_variables(sqlite_session, variable) monkeypatch.setattr( module, "SnippetService", - Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=draft_workflow))), + Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=SimpleNamespace(id="workflow-1")))), ) - api = module.SnippetVariableResetApi() handler = unwrap(api.put) with app.test_request_context("/", method="PUT"): - response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1") + response = handler( + api, + _make_account(), + snippet=SimpleNamespace(id="snippet-1"), + variable_id=variable.id, + ) assert response.status_code == 204 - draft_var_service.reset_variable.assert_called_once_with(draft_workflow, variable) - db_session.commit.assert_called_once() + assert _variable_ids(sqlite_engine) == set() + assert not controller_sessions().in_transaction() -def test_environment_variables_returns_workflow_environment_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): +def test_environment_variables_returns_workflow_environment_variables( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: env_var = SimpleNamespace( id="env-1", name="API_KEY", diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index 8aaebeb124a..7d267b50153 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -1,9 +1,11 @@ +from collections.abc import Iterator from types import SimpleNamespace -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import PropertyMock, patch import pytest from flask import Flask -from sqlalchemy.orm import Session +from sqlalchemy import Engine +from sqlalchemy.orm import Session, scoped_session, sessionmaker from werkzeug.exceptions import Forbidden import controllers.console.tag.tags as module @@ -16,15 +18,12 @@ from controllers.console.tag.tags import ( ) from models import Account from models.account import AccountStatus, TenantAccountRole +from models.base import TypeBase from models.enums import TagType +from models.model import Tag from services.tag_service import UpdateTagPayload -class SessionMatcher: - def __eq__(self, other): - return isinstance(other, Session) - - def unwrap(func): """ Recursively unwrap decorated functions. @@ -41,6 +40,26 @@ def app(): return app +@pytest.fixture(autouse=True) +def sqlite_db_session( + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> Iterator[scoped_session[Session]]: + TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[Tag.__tablename__]]) + session_registry = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + monkeypatch.setattr(module.db, "session", session_registry) + try: + yield session_registry + finally: + session_registry.remove() + + +def _assert_sqlite_session(session: object, sqlite_engine: Engine) -> None: + assert isinstance(session, Session) + assert session.get_bind() is sqlite_engine + assert session.is_active + + @pytest.fixture def admin_user(): account = Account( @@ -66,11 +85,16 @@ def readonly_user(): @pytest.fixture -def tag(): - tag = MagicMock() +def tag(sqlite_db_session: scoped_session[Session]): + tag = Tag( + tenant_id="tenant-1", + name="test-tag", + type=TagType.KNOWLEDGE, + created_by="user-1", + ) tag.id = "tag-1" - tag.name = "test-tag" - tag.type = TagType.KNOWLEDGE + sqlite_db_session.add(tag) + sqlite_db_session.commit() return tag @@ -111,7 +135,7 @@ class TestTagListApi: assert status == 200 assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}] - def test_get_snippet_tags(self, app: Flask): + def test_get_snippet_tags(self, app: Flask, sqlite_engine: Engine): api = TagListApi() method = unwrap(api.get) @@ -131,7 +155,9 @@ class TestTagListApi: ): result, status = method(api, "tenant-1") - get_tags_mock.assert_called_once_with("snippet", "tenant-1", None, session=SessionMatcher()) + get_tags_mock.assert_called_once() + assert get_tags_mock.call_args.args == ("snippet", "tenant-1", None) + _assert_sqlite_session(get_tags_mock.call_args.kwargs["session"], sqlite_engine) assert status == 200 assert result == [{"id": "1", "name": "snippet-tag", "type": "snippet", "binding_count": "1"}] @@ -200,7 +226,7 @@ class TestTagListApi: class TestTagUpdateDeleteApi: - def test_patch_success(self, app: Flask, admin_user, tag, payload_patch): + def test_patch_success(self, app: Flask, admin_user, tag, payload_patch, sqlite_engine: Engine): api = TagUpdateDeleteApi() method = unwrap(api.patch) @@ -224,7 +250,7 @@ class TestTagUpdateDeleteApi: update_payload, tag_id, session = update_tags_mock.call_args.args assert update_payload == UpdateTagPayload(name="updated") assert tag_id == "tag-1" - assert session == SessionMatcher() + _assert_sqlite_session(session, sqlite_engine) assert result["binding_count"] == "3" def test_patch_forbidden(self, app: Flask, readonly_user, payload_patch): @@ -240,7 +266,7 @@ class TestTagUpdateDeleteApi: with pytest.raises(Forbidden): method(api, readonly_user, "tag-1") - def test_delete_success(self, app: Flask, admin_user): + def test_delete_success(self, app: Flask, admin_user, sqlite_engine: Engine): api = TagUpdateDeleteApi() method = unwrap(api.delete) @@ -250,12 +276,30 @@ class TestTagUpdateDeleteApi: ): result, status = method(api, "tag-1") - delete_mock.assert_called_once_with("tag-1", SessionMatcher()) + delete_mock.assert_called_once() + tag_id, session = delete_mock.call_args.args + assert tag_id == "tag-1" + _assert_sqlite_session(session, sqlite_engine) assert status == 204 - def test_delete_snippet_tag_checks_type_in_current_tenant(self, app: Flask, admin_user): + def test_delete_snippet_tag_checks_type_in_current_tenant( + self, + app: Flask, + admin_user, + sqlite_db_session: scoped_session[Session], + sqlite_engine: Engine, + ): api = TagUpdateDeleteApi() method = unwrap(api.delete) + tag = Tag( + tenant_id="tenant-1", + name="snippet-tag", + type=TagType.SNIPPET, + created_by="user-1", + ) + tag.id = "tag-1" + sqlite_db_session.add(tag) + sqlite_db_session.commit() with ( app.test_request_context("/"), @@ -264,13 +308,11 @@ class TestTagUpdateDeleteApi: "controllers.console.tag.tags.current_account_with_tenant", return_value=(SimpleNamespace(id="user-1"), "tenant-1"), ), - patch.object(module.db.session, "scalar", return_value=TagType.SNIPPET) as scalar_mock, patch("controllers.console.tag.tags.enforce_rbac_access") as enforce_mock, patch("controllers.console.tag.tags.TagService.delete_tag") as delete_mock, ): result, status = method(api, "tag-1") - scalar_mock.assert_called_once() enforce_mock.assert_called_once_with( tenant_id="tenant-1", account_id="user-1", @@ -278,7 +320,49 @@ class TestTagUpdateDeleteApi: scene=module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False, ) - delete_mock.assert_called_once_with("tag-1", SessionMatcher()) + delete_mock.assert_called_once() + tag_id, session = delete_mock.call_args.args + assert tag_id == "tag-1" + _assert_sqlite_session(session, sqlite_engine) + assert result == "" + assert status == 204 + + def test_delete_does_not_apply_snippet_rbac_to_tag_from_another_tenant( + self, + app: Flask, + admin_user, + sqlite_db_session: scoped_session[Session], + sqlite_engine: Engine, + ): + api = TagUpdateDeleteApi() + method = unwrap(api.delete) + tag = Tag( + tenant_id="other-tenant", + name="other-tenant-snippet-tag", + type=TagType.SNIPPET, + created_by="other-user", + ) + tag.id = "tag-1" + sqlite_db_session.add(tag) + sqlite_db_session.commit() + + with ( + app.test_request_context("/"), + patch("controllers.console.tag.tags.dify_config.RBAC_ENABLED", True), + patch( + "controllers.console.tag.tags.current_account_with_tenant", + return_value=(SimpleNamespace(id="user-1"), "tenant-1"), + ), + patch("controllers.console.tag.tags.enforce_rbac_access") as enforce_mock, + patch("controllers.console.tag.tags.TagService.delete_tag") as delete_mock, + ): + result, status = method(api, "tag-1") + + enforce_mock.assert_not_called() + delete_mock.assert_called_once() + tag_id, session = delete_mock.call_args.args + assert tag_id == "tag-1" + _assert_sqlite_session(session, sqlite_engine) assert result == "" assert status == 204 diff --git a/api/tests/unit_tests/controllers/console/test_init_validate.py b/api/tests/unit_tests/controllers/console/test_init_validate.py index 88d41fa2bd0..377135e3f2f 100644 --- a/api/tests/unit_tests/controllers/console/test_init_validate.py +++ b/api/tests/unit_tests/controllers/console/test_init_validate.py @@ -1,27 +1,16 @@ +"""Initialization validation tests with real setup-state persistence in SQLite.""" + from __future__ import annotations from types import SimpleNamespace -from unittest.mock import Mock import pytest from flask import Flask +from sqlalchemy.orm import Session from controllers.console import init_validate from controllers.console.error import AlreadySetupError, InitValidateFailedError - - -class _SessionStub: - def __init__(self, has_setup: bool): - self._has_setup = has_setup - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def execute(self, *_args, **_kwargs): - return SimpleNamespace(scalar_one_or_none=lambda: Mock() if self._has_setup else None) +from models.model import DifySetup def test_get_init_status_finished(monkeypatch: pytest.MonkeyPatch) -> None: @@ -85,11 +74,15 @@ def test_get_init_validate_status_validated_session(app: Flask, monkeypatch: pyt assert init_validate.get_init_validate_status() is True -def test_get_init_validate_status_setup_exists(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_get_init_validate_status_setup_exists( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setenv("INIT_PASSWORD", "expected") - monkeypatch.setattr(init_validate, "Session", lambda *_args, **_kwargs: _SessionStub(True)) - monkeypatch.setattr(init_validate, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(init_validate, "db", SimpleNamespace(engine=sqlite_session.get_bind())) + sqlite_session.add(DifySetup(version="test-version")) + sqlite_session.commit() app.secret_key = "test-secret" with app.test_request_context("/console/api/init", method="GET"): @@ -97,11 +90,13 @@ def test_get_init_validate_status_setup_exists(app: Flask, monkeypatch: pytest.M assert init_validate.get_init_validate_status() is True -def test_get_init_validate_status_not_validated(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_get_init_validate_status_not_validated( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setenv("INIT_PASSWORD", "expected") - monkeypatch.setattr(init_validate, "Session", lambda *_args, **_kwargs: _SessionStub(False)) - monkeypatch.setattr(init_validate, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(init_validate, "db", SimpleNamespace(engine=sqlite_session.get_bind())) app.secret_key = "test-secret" with app.test_request_context("/console/api/init", method="GET"): diff --git a/api/tests/unit_tests/controllers/console/test_knowledge_fs_proxy.py b/api/tests/unit_tests/controllers/console/test_knowledge_fs_proxy.py index 8edcc033593..34a8ca9fec2 100644 --- a/api/tests/unit_tests/controllers/console/test_knowledge_fs_proxy.py +++ b/api/tests/unit_tests/controllers/console/test_knowledge_fs_proxy.py @@ -7,9 +7,11 @@ from unittest.mock import MagicMock import httpx import pytest from flask import Flask, Response +from pydantic import SecretStr from werkzeug.exceptions import ( BadGateway, Forbidden, + HTTPException, NotFound, RequestEntityTooLarge, ServiceUnavailable, @@ -22,15 +24,18 @@ from controllers.console.knowledge_fs_proxy import ( _proxy_request, _proxy_response, proxy_knowledge_fs_get, + proxy_knowledge_fs_options, proxy_knowledge_fs_write, ) from controllers.console.wraps import RBACPermission -from services.knowledge_fs_proxy import ( - KnowledgeFSAccessDeniedError, - KnowledgeFSConfigurationError, +from services.knowledge_fs_operations import ( KnowledgeFSMethod, KnowledgeFSOperation, KnowledgeFSResponseKind, +) +from services.knowledge_fs_proxy import ( + KnowledgeFSAccessDeniedError, + KnowledgeFSConfigurationError, KnowledgeFSRouteNotAllowedError, KnowledgeFSUpstreamResponse, get_knowledge_fs_operation, @@ -46,6 +51,7 @@ def _upstream( response: httpx.Response, kind: KnowledgeFSResponseKind = "buffered", *, + error_status_map: tuple[tuple[int, int], ...] = ((401, 502), (403, 403)), max_response_bytes: int | None = None, ) -> KnowledgeFSUpstreamResponse: operation = KnowledgeFSOperation( @@ -55,7 +61,7 @@ def _upstream( response_kind=kind, required_scope="knowledge-spaces:read", rbac_permission=RBACPermission.DATASET_READONLY, - requires_dataset_editor=False, + legacy_role="reader", max_response_bytes=max_response_bytes or (64 * 1024 * 1024 if kind == "stream" else 25 * 1024 * 1024 if kind == "binary" else 1024 * 1024), request_headers=(), @@ -66,6 +72,7 @@ def _upstream( "x-session-id", ), response_media_types=(), + error_status_map=error_status_map, ) return KnowledgeFSUpstreamResponse(response, kind, operation) @@ -92,7 +99,7 @@ def _set_current_workspace( def _bypass_policy_wrappers(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "controllers.console.knowledge_fs_proxy._proxy_knowledge_fs_non_get", - unwrap(_proxy_knowledge_fs_non_get), + lambda method, path: _proxy_request(method, path), ) @@ -118,10 +125,61 @@ def test_console_blueprint_registers_generic_knowledge_fs_routes() -> None: "/console/api/knowledge-fs/knowledge-spaces", method="OPTIONS", ) - assert options_endpoint.endswith("proxy_knowledge_fs_get") + assert options_endpoint.endswith("proxy_knowledge_fs_options") assert options_values == {"upstream_path": "knowledge-spaces"} +def test_proxy_options_does_not_require_an_authenticated_account(app: Flask) -> None: + with app.test_request_context( + "/console/api/knowledge-fs/knowledge-spaces", + method="OPTIONS", + headers={"Access-Control-Request-Method": "GET"}, + ): + response = app.make_response(proxy_knowledge_fs_options("knowledge-spaces")) + + assert response.status_code == 204 + + +def test_proxy_options_is_hidden_when_knowledge_fs_is_disabled( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("controllers.console.knowledge_fs_proxy.dify_config.KNOWLEDGE_FS_ENABLED", False) + + with app.test_request_context( + "/console/api/knowledge-fs/knowledge-spaces", + method="OPTIONS", + headers={"Access-Control-Request-Method": "GET"}, + ): + response = app.make_response(proxy_knowledge_fs_options("knowledge-spaces")) + + assert response.status_code == 404 + + +@pytest.mark.parametrize( + ("upstream_path", "requested_method"), + [ + ("unregistered", "GET"), + ("knowledge-spaces", "DELETE"), + ("knowledge-spaces", ""), + ], +) +def test_proxy_options_hides_unregistered_operations( + app: Flask, + upstream_path: str, + requested_method: str, +) -> None: + headers = {"Access-Control-Request-Method": requested_method} if requested_method else None + with app.test_request_context( + f"/console/api/knowledge-fs/{upstream_path}", + method="OPTIONS", + headers=headers, + ): + response = app.make_response(proxy_knowledge_fs_options(upstream_path)) + + assert response.status_code == 404 + + def test_proxy_is_hidden_when_knowledge_fs_is_disabled(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("controllers.console.knowledge_fs_proxy.dify_config.KNOWLEDGE_FS_ENABLED", False) @@ -235,10 +293,8 @@ def test_read_post_applies_knowledge_rate_limit_once( monkeypatch.setattr("controllers.console.knowledge_fs_proxy.current_account_with_tenant", current_workspace) monkeypatch.setattr("controllers.console.wraps.current_account_with_tenant", current_workspace) - monkeypatch.setattr( - "services.knowledge_fs_proxy.RBACService.CheckAccess.check", - MagicMock(return_value=True), - ) + check_access = MagicMock(return_value=True) + monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access) monkeypatch.setattr( "controllers.console.wraps.FeatureService.get_knowledge_rate_limit", MagicMock(return_value=MagicMock(enabled=True, limit=10)), @@ -248,14 +304,19 @@ def test_read_post_applies_knowledge_rate_limit_once( monkeypatch.setattr("controllers.console.wraps.redis_client.zremrangebyscore", MagicMock()) monkeypatch.setattr("controllers.console.wraps.redis_client.zcard", MagicMock(return_value=1)) proxy = MagicMock(return_value=Response(status=200)) - monkeypatch.setattr("controllers.console.knowledge_fs_proxy._proxy_request", proxy) + monkeypatch.setattr("controllers.console.knowledge_fs_proxy._proxy_authorized_request", proxy) with app.test_request_context("/console/api/knowledge-fs/knowledge-spaces", method="POST"): response = _proxy_knowledge_fs_non_get("POST", "knowledge-spaces") assert isinstance(response, Response) zadd.assert_called_once() - proxy.assert_called_once_with("POST", "knowledge-spaces") + proxy.assert_called_once() + authorization = proxy.call_args.args[0] + assert authorization.account_id == "account-1" + assert authorization.tenant_id == "tenant-1" + assert authorization.operation.operation_id == "createKnowledgeSpace" + check_access.assert_called_once() def test_denied_write_does_not_consume_the_workspace_rate_limit( @@ -395,6 +456,65 @@ def test_generic_write_forwards_path_raw_body_and_current_tenant( assert response.get_json()["tenantId"] == "tenant-1" +def test_generic_write_forwards_through_the_authorized_production_path( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + account = MagicMock(id="account-1", is_dataset_editor=True) + + def current_workspace() -> tuple[MagicMock, str]: + return account, "tenant-1" + + monkeypatch.setattr("controllers.console.knowledge_fs_proxy.current_account_with_tenant", current_workspace) + monkeypatch.setattr("controllers.console.wraps.current_account_with_tenant", current_workspace) + check_access = MagicMock(return_value=True) + monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access) + monkeypatch.setattr( + "controllers.console.wraps.FeatureService.get_knowledge_rate_limit", + MagicMock(return_value=MagicMock(enabled=False)), + ) + monkeypatch.setattr( + "services.knowledge_fs_proxy.dify_config.KNOWLEDGE_FS_BASE_URL", + "http://knowledge-fs.test", + raising=False, + ) + monkeypatch.setattr( + "services.knowledge_fs_proxy.dify_config.KNOWLEDGE_FS_JWT_SECRET", + SecretStr("production-secret-with-at-least-32-bytes"), + raising=False, + ) + upstream_request = MagicMock( + return_value=httpx.Response( + 201, + content=b'{"id":"space-1","tenantId":"tenant-1"}', + headers={"Content-Type": "application/json"}, + ) + ) + monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", upstream_request) + route = unwrap(proxy_knowledge_fs_write) + body = b'{"idempotencyKey":"create-product-docs","name":"Product docs"}' + + with app.test_request_context( + "/console/api/knowledge-fs/knowledge-spaces", + method="POST", + query_string={"source": "console"}, + data=body, + content_type="application/json", + headers={"X-Trace-Id": "trace-1"}, + ): + response = route("knowledge-spaces") + + assert isinstance(response, Response) + assert response.status_code == 201 + assert response.get_json() == {"id": "space-1", "tenantId": "tenant-1"} + check_access.assert_called_once() + assert upstream_request.call_args.kwargs["method"] == "POST" + assert upstream_request.call_args.kwargs["url"] == "http://knowledge-fs.test/knowledge-spaces" + assert upstream_request.call_args.kwargs["params"] == b"source=console" + assert upstream_request.call_args.kwargs["content"] == body + assert upstream_request.call_args.kwargs["headers"]["x-trace-id"] == "trace-1" + + def test_generic_write_forwards_contract_declared_request_headers( app: Flask, monkeypatch: pytest.MonkeyPatch, @@ -565,6 +685,43 @@ def test_resource_authorization_rejection_is_exposed_as_forbidden( route("knowledge-spaces") +def test_proxy_response_applies_operation_specific_error_status_mapping() -> None: + upstream = httpx.Response( + 429, + content=b'{"error":"rate limited"}', + headers={"Content-Type": "application/json"}, + ) + + with pytest.raises(ServiceUnavailable): + _proxy_response( + _upstream(upstream, error_status_map=((429, 503),)), + tenant_id="tenant-1", + contract_response_headers=(), + max_response_bytes=1024 * 1024, + ) + + assert upstream.is_closed + + +def test_proxy_response_preserves_nonstandard_mapped_error_status() -> None: + upstream = httpx.Response( + 429, + content=b'{"error":"rate limited"}', + headers={"Content-Type": "application/json"}, + ) + + with pytest.raises(HTTPException) as exc_info: + _proxy_response( + _upstream(upstream, error_status_map=((429, 499),)), + tenant_id="tenant-1", + contract_response_headers=(), + max_response_bytes=1024 * 1024, + ) + + assert exc_info.value.code == 499 + assert upstream.is_closed + + def test_contract_response_headers_are_deduplicated_case_insensitively() -> None: upstream = httpx.Response( 200, @@ -619,6 +776,7 @@ def test_disallowed_non_get_route_is_hidden_as_not_found( monkeypatch: pytest.MonkeyPatch, method: KnowledgeFSMethod, ) -> None: + _set_current_workspace(monkeypatch) route = unwrap(proxy_knowledge_fs_write) with app.test_request_context("/console/api/knowledge-fs/not-a-route", method=method): diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py index c958034126d..5faf7828f57 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py @@ -2,19 +2,75 @@ Unit tests for inner_api plugin decorators """ +from collections.abc import Iterator +from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock, patch import pytest from flask import Flask from pydantic import ValidationError +from sqlalchemy import Engine, event, select +from sqlalchemy.orm import Session, scoped_session, sessionmaker +from controllers.inner_api.plugin import wraps as wraps_module from controllers.inner_api.plugin.wraps import ( TenantUserPayload, get_user, get_user_tenant, plugin_data, ) +from models.account import Tenant +from models.base import TypeBase +from models.enums import EndUserType +from models.model import DefaultEndUserSessionID, EndUser + + +@pytest.fixture +def sqlite_plugin_engine( + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> Iterator[Engine]: + tables = [TypeBase.metadata.tables[model.__tablename__] for model in (Tenant, EndUser)] + TypeBase.metadata.create_all(sqlite_engine, tables=tables) + session_registry = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + monkeypatch.setattr( + wraps_module, + "db", + SimpleNamespace(engine=sqlite_engine, session=session_registry), + ) + try: + yield sqlite_engine + finally: + session_registry.remove() + + +def _persist_tenant(sqlite_engine: Engine, *, tenant_id: str = "tenant123") -> Tenant: + tenant = Tenant(name=f"Tenant {tenant_id}") + tenant.id = tenant_id + with Session(sqlite_engine) as session, session.begin(): + session.add(tenant) + return tenant + + +def _persist_end_user( + sqlite_engine: Engine, + *, + tenant_id: str = "tenant123", + user_id: str, + session_id: str, + is_anonymous: bool = False, +) -> EndUser: + user = EndUser( + id=user_id, + tenant_id=tenant_id, + type=EndUserType.SERVICE_API, + is_anonymous=is_anonymous, + session_id=session_id, + ) + with Session(sqlite_engine) as session, session.begin(): + session.add(user) + return user class TestTenantUserPayload: @@ -41,185 +97,143 @@ class TestTenantUserPayload: class TestGetUser: """Test get_user function""" - @patch("controllers.inner_api.plugin.wraps.select") - @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.sessionmaker") - @patch("controllers.inner_api.plugin.wraps.db") - def test_should_return_existing_user_by_id( - self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask - ): + def test_should_return_existing_user_by_id(self, sqlite_plugin_engine: Engine, app: Flask): """Test returning existing user when found by ID""" - # Arrange - mock_user = MagicMock() - mock_user.id = "user123" - mock_session = MagicMock() - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.scalar.return_value = mock_user - mock_query = MagicMock() - mock_select.return_value.where.return_value.limit.return_value = mock_query + _persist_end_user( + sqlite_plugin_engine, + user_id="user123", + session_id="existing-session", + ) - # Act with app.app_context(): result = get_user("tenant123", "user123") - # Assert - assert result == mock_user - mock_session.scalar.assert_called_once() + assert result.id == "user123" + assert result.tenant_id == "tenant123" - @patch("controllers.inner_api.plugin.wraps.select") - @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.sessionmaker") - @patch("controllers.inner_api.plugin.wraps.db") def test_should_not_resolve_non_anonymous_users_across_tenants( self, - mock_db, - mock_sessionmaker, - mock_enduser_class, - mock_select, + sqlite_plugin_engine: Engine, app: Flask, ): """Test that explicit user IDs remain scoped to the current tenant.""" - # Arrange - mock_session = MagicMock() - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.scalar.return_value = None - mock_new_user = MagicMock() - mock_new_user.tenant_id = "tenant-current" - mock_enduser_class.return_value = mock_new_user + _persist_end_user( + sqlite_plugin_engine, + tenant_id="tenant-foreign", + user_id="foreign-user-id", + session_id="foreign-session", + ) - # Act with app.app_context(): result = get_user("tenant-current", "foreign-user-id") - # Assert - assert result == mock_new_user - mock_session.get.assert_not_called() - # Non-anonymous miss now tries id, then session_id fallback (see - # #36736); both miss in this tenant → fall through to create. - assert mock_session.scalar.call_count == 2 - mock_session.add.assert_called_once_with(mock_new_user) + assert result.id != "foreign-user-id" + assert result.tenant_id == "tenant-current" + assert result.session_id == "foreign-user-id" + with Session(sqlite_plugin_engine) as session: + current_tenant_users = session.scalars(select(EndUser).where(EndUser.tenant_id == "tenant-current")).all() + assert [user.id for user in current_tenant_users] == [result.id] - @patch("controllers.inner_api.plugin.wraps.select") - @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.sessionmaker") - @patch("controllers.inner_api.plugin.wraps.db") def test_should_return_existing_user_by_session_id_fallback_for_non_anonymous( - self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask + self, + sqlite_plugin_engine: Engine, + app: Flask, ): """Non-anonymous user_id misses on EndUser.id but hits on EndUser.session_id — this is the plugin-daemon Reverse Invocation case where the daemon sends a stable session-derived UUID that was written into session_id on the first call. See #36736. """ - # Arrange - mock_user = MagicMock() - mock_session = MagicMock() - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - # First scalar (id lookup) returns None, second (session_id fallback) hits. - mock_session.scalar.side_effect = [None, mock_user] + _persist_end_user( + sqlite_plugin_engine, + user_id="persisted-user-id", + session_id="daemon-session-uuid", + ) - # Act with app.app_context(): result = get_user("tenant123", "daemon-session-uuid") - # Assert - assert result == mock_user - assert mock_session.scalar.call_count == 2 - mock_session.add.assert_not_called() + assert result.id == "persisted-user-id" + with Session(sqlite_plugin_engine) as session: + users = session.scalars(select(EndUser)).all() + assert [user.id for user in users] == ["persisted-user-id"] - @patch("controllers.inner_api.plugin.wraps.select") - @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.sessionmaker") - @patch("controllers.inner_api.plugin.wraps.db") def test_should_return_existing_anonymous_user_by_session_id( - self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask + self, + sqlite_plugin_engine: Engine, + app: Flask, ): """Test returning existing anonymous user by session_id""" - # Arrange - mock_user = MagicMock() - mock_user.session_id = "anonymous_session" - mock_session = MagicMock() - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.scalar.return_value = mock_user - mock_query = MagicMock() - mock_select.return_value.where.return_value.limit.return_value = mock_query + _persist_end_user( + sqlite_plugin_engine, + user_id="anonymous-user-id", + session_id="anonymous_session", + is_anonymous=True, + ) - # Act with app.app_context(): result = get_user("tenant123", "anonymous_session") - # Assert - assert result == mock_user + assert result.id == "anonymous-user-id" - @patch("controllers.inner_api.plugin.wraps.select") - @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.sessionmaker") - @patch("controllers.inner_api.plugin.wraps.db") def test_should_create_new_user_when_not_found( - self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask + self, + sqlite_plugin_engine: Engine, + app: Flask, ): """Test creating new user when not found in database""" - # Arrange - mock_session = MagicMock() - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.scalar.return_value = None - mock_new_user = MagicMock() - mock_enduser_class.return_value = mock_new_user - mock_query = MagicMock() - mock_select.return_value.where.return_value.limit.return_value = mock_query - - # Act with app.app_context(): result = get_user("tenant123", "user123") - # Assert - assert result == mock_new_user - mock_session.add.assert_called_once() - mock_session.refresh.assert_called_once() + assert result.tenant_id == "tenant123" + assert result.session_id == "user123" + with Session(sqlite_plugin_engine) as session: + persisted_user = session.get(EndUser, result.id) + assert persisted_user is not None + assert persisted_user.session_id == "user123" - @patch("controllers.inner_api.plugin.wraps.select") - @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.sessionmaker") - @patch("controllers.inner_api.plugin.wraps.db") def test_should_use_default_session_id_when_user_id_none( - self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask + self, + sqlite_plugin_engine: Engine, + app: Flask, ): """Test using default session ID when user_id is None""" - # Arrange - mock_user = MagicMock() - mock_session = MagicMock() - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - # When user_id is None, is_anonymous=True, so session.scalar() is used - mock_session.scalar.return_value = mock_user + _persist_end_user( + sqlite_plugin_engine, + user_id="default-user-id", + session_id=DefaultEndUserSessionID.DEFAULT_SESSION_ID, + is_anonymous=True, + ) - # Act with app.app_context(): result = get_user("tenant123", None) - # Assert - assert result == mock_user + assert result.id == "default-user-id" + assert result.session_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID - @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.sessionmaker") - @patch("controllers.inner_api.plugin.wraps.db") - def test_should_raise_error_on_database_exception(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask): + def test_should_raise_error_on_database_exception(self, sqlite_plugin_engine: Engine, app: Flask): """Test raising ValueError when database operation fails""" - # Arrange - mock_session = MagicMock() - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.scalar.side_effect = Exception("Database error") - # Act & Assert - with app.app_context(): - with pytest.raises(ValueError, match="user not found"): + def _raise_database_error(*_args, **_kwargs): + raise RuntimeError("Database error") + + event.listen(sqlite_plugin_engine, "before_cursor_execute", _raise_database_error) + try: + with app.app_context(), pytest.raises(ValueError, match="user not found"): get_user("tenant123", "user123") + finally: + event.remove(sqlite_plugin_engine, "before_cursor_execute", _raise_database_error) class TestGetUserTenant: """Test get_user_tenant decorator""" - @patch("controllers.inner_api.plugin.wraps.Tenant") - def test_should_inject_tenant_and_user_models(self, mock_tenant_class, app: Flask, monkeypatch: pytest.MonkeyPatch): + def test_should_inject_tenant_and_user_models( + self, + sqlite_plugin_engine: Engine, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + ): """Test that decorator injects tenant_model and user_model into kwargs""" # Arrange @@ -227,24 +241,20 @@ class TestGetUserTenant: def protected_view(tenant_model, user_model, **kwargs): return {"tenant": tenant_model, "user": user_model} - mock_tenant = MagicMock() - mock_tenant.id = "tenant123" - mock_user = MagicMock() - mock_user.id = "user456" + _persist_tenant(sqlite_plugin_engine) + _persist_end_user( + sqlite_plugin_engine, + user_id="user456", + session_id="user-session", + ) - # Act with app.test_request_context(json={"tenant_id": "tenant123", "user_id": "user456"}): monkeypatch.setattr(app, "login_manager", MagicMock(), raising=False) - with patch("controllers.inner_api.plugin.wraps.db.session.get") as mock_get: - with patch("controllers.inner_api.plugin.wraps.get_user") as mock_get_user: - with patch("controllers.inner_api.plugin.wraps.user_logged_in"): - mock_get.return_value = mock_tenant - mock_get_user.return_value = mock_user - result = protected_view() + with patch("controllers.inner_api.plugin.wraps.user_logged_in"): + result = protected_view() - # Assert - assert result["tenant"] == mock_tenant - assert result["user"] == mock_user + assert result["tenant"].id == "tenant123" + assert result["user"].id == "user456" def test_should_raise_error_when_tenant_id_missing(self, app: Flask): """Test that Pydantic ValidationError is raised when tenant_id is missing from payload""" @@ -259,7 +269,7 @@ class TestGetUserTenant: with pytest.raises(ValidationError): protected_view() - def test_should_raise_error_when_tenant_not_found(self, app: Flask): + def test_should_raise_error_when_tenant_not_found(self, sqlite_plugin_engine: Engine, app: Flask): """Test that ValueError is raised when tenant is not found""" # Arrange @@ -267,16 +277,15 @@ class TestGetUserTenant: def protected_view(tenant_model, user_model, **kwargs): return "success" - # Act & Assert with app.test_request_context(json={"tenant_id": "nonexistent", "user_id": "user456"}): - with patch("controllers.inner_api.plugin.wraps.db.session.get") as mock_get: - mock_get.return_value = None - with pytest.raises(ValueError, match="tenant not found"): - protected_view() + with pytest.raises(ValueError, match="tenant not found"): + protected_view() - @patch("controllers.inner_api.plugin.wraps.Tenant") def test_should_use_default_session_id_when_user_id_empty( - self, mock_tenant_class, app: Flask, monkeypatch: pytest.MonkeyPatch + self, + sqlite_plugin_engine: Engine, + app: Flask, + monkeypatch: pytest.MonkeyPatch, ): """Test that default session ID is used when user_id is empty string""" @@ -285,26 +294,22 @@ class TestGetUserTenant: def protected_view(tenant_model, user_model, **kwargs): return {"tenant": tenant_model, "user": user_model} - mock_tenant = MagicMock() - mock_tenant.id = "tenant123" - mock_user = MagicMock() + _persist_tenant(sqlite_plugin_engine) + _persist_end_user( + sqlite_plugin_engine, + user_id="default-user-id", + session_id=DefaultEndUserSessionID.DEFAULT_SESSION_ID, + is_anonymous=True, + ) - # Act - use empty string for user_id to trigger default logic with app.test_request_context(json={"tenant_id": "tenant123", "user_id": ""}): monkeypatch.setattr(app, "login_manager", MagicMock(), raising=False) - with patch("controllers.inner_api.plugin.wraps.db.session.get") as mock_get: - with patch("controllers.inner_api.plugin.wraps.get_user") as mock_get_user: - with patch("controllers.inner_api.plugin.wraps.user_logged_in"): - mock_get.return_value = mock_tenant - mock_get_user.return_value = mock_user - result = protected_view() + with patch("controllers.inner_api.plugin.wraps.user_logged_in"): + result = protected_view() - # Assert - assert result["tenant"] == mock_tenant - assert result["user"] == mock_user - from models.model import DefaultEndUserSessionID - - mock_get_user.assert_called_once_with("tenant123", DefaultEndUserSessionID.DEFAULT_SESSION_ID) + assert result["tenant"].id == "tenant123" + assert result["user"].id == "default-user-id" + assert result["user"].session_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID class PluginTestPayload: 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 96f1dcaed56..324d66c0b64 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 @@ -2,10 +2,12 @@ Unit tests for inner_api auth decorators """ -from unittest.mock import MagicMock, patch +from unittest.mock import patch +from uuid import NAMESPACE_URL, uuid5 import pytest from flask import Flask +from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import HTTPException from configs import dify_config @@ -16,9 +18,14 @@ from controllers.inner_api.wraps import ( inner_api_only, plugin_inner_api_only, ) +from models.enums import EndUserType from models.model import EndUser +def _stable_uuid(value: str) -> str: + return str(uuid5(NAMESPACE_URL, value)) + + class TestBillingInnerApiOnly: """Test billing_inner_api_only decorator""" @@ -258,7 +265,7 @@ class TestEnterpriseInnerApiUserAuth: assert result == "no_user" def test_should_pass_through_when_hmac_signature_invalid(self, app: Flask): - """Test that request passes through when HMAC signature is invalid""" + """Invalid HMAC auth passes through without opening a database session.""" # Arrange @enterprise_inner_api_user_auth @@ -277,7 +284,8 @@ class TestEnterpriseInnerApiUserAuth: assert result == "no_user" mock_create_session.assert_not_called() - def test_should_inject_user_when_hmac_signature_valid(self, app: Flask): + @pytest.mark.parametrize("sqlite_session", [(EndUser,)], indirect=True) + def test_should_inject_user_when_hmac_signature_valid(self, app: Flask, sqlite_session: Session): """Test that user is injected when HMAC signature is valid""" # Arrange from base64 import b64encode @@ -289,19 +297,25 @@ class TestEnterpriseInnerApiUserAuth: return kwargs.get("user") # Calculate valid HMAC signature - user_id = "user123" + user_id = _stable_uuid("end-user:user123") inner_api_key = "valid_key" data_to_sign = f"DIFY {user_id}" signature = hmac_new(inner_api_key.encode("utf-8"), data_to_sign.encode("utf-8"), sha1) valid_signature = b64encode(signature.digest()).decode("utf-8") - # Create mock user - mock_user = MagicMock() - mock_user.id = user_id - mock_session = MagicMock() - mock_session.get.return_value = mock_user - mock_session_context = MagicMock() - mock_session_context.__enter__.return_value = mock_session + end_user = EndUser( + id=user_id, + tenant_id=_stable_uuid("tenant:inner-api"), + type=EndUserType.BROWSER, + name="Inner API User", + session_id="inner-api-session", + ) + sqlite_session.add(end_user) + sqlite_session.commit() + database_session_factory = sessionmaker( + bind=sqlite_session.get_bind(), + expire_on_commit=False, + ) # Act with app.test_request_context( @@ -310,14 +324,15 @@ class TestEnterpriseInnerApiUserAuth: with patch.object(dify_config, "INNER_API", True): with patch( "controllers.inner_api.wraps.session_factory.create_session", - return_value=mock_session_context, - ) as mock_create_session: + database_session_factory, + ): result = protected_view() # Assert - assert result == mock_user - mock_create_session.assert_called_once_with() - mock_session.get.assert_called_once_with(EndUser, user_id) + assert isinstance(result, EndUser) + assert result.id == end_user.id + assert result.tenant_id == end_user.tenant_id + assert result.session_id == "inner-api-session" class TestPluginInnerApiOnly: 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 index 87511a32b8c..8e5e9a3e04f 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py +++ b/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py @@ -1,14 +1,20 @@ """Unit tests for runtime credential inner API.""" import inspect +import json from unittest.mock import MagicMock, patch +import pytest from flask import Flask +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session from controllers.inner_api.runtime_credentials import ( EnterpriseRuntimeCredentialsResolve, InnerRuntimeCredentialsResolvePayload, ) +from models.provider import ProviderCredential +from models.tools import BuiltinToolProvider def test_runtime_credentials_payload_accepts_items(): @@ -32,14 +38,15 @@ def test_runtime_credentials_payload_accepts_items(): @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") +@pytest.mark.parametrize("sqlite_session", [(ProviderCredential,)], indirect=True) def test_runtime_model_credentials_resolve_returns_decrypted_values( mock_provider_manager_factory, - mock_session_cls, mock_db, mock_decrypt_token, app: Flask, + sqlite_engine: Engine, + sqlite_session: Session, ): provider_configuration = MagicMock() provider_configuration.provider.provider_credential_schema.credential_form_schemas = [] @@ -52,14 +59,16 @@ def test_runtime_model_credentials_resolve_returns_decrypted_values( 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() + credential = ProviderCredential( + tenant_id="tenant-1", + provider_name="langgenius/openai/openai", + credential_name="OpenAI", + encrypted_config='{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}', + ) + credential.id = "credential-1" + sqlite_session.add(credential) + sqlite_session.commit() + mock_db.engine = sqlite_engine mock_decrypt_token.return_value = "sk-test" handler = EnterpriseRuntimeCredentialsResolve() @@ -110,28 +119,32 @@ def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provide @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") +@pytest.mark.parametrize("sqlite_session", [(BuiltinToolProvider,)], indirect=True) 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, + sqlite_engine: Engine, + sqlite_session: Session, ): 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 = BuiltinToolProvider( + tenant_id="tenant-1", + user_id="user-1", + provider="langgenius/tavily/tavily", + name="Tavily", + encrypted_credentials=json.dumps({"tavily_api_key": "encrypted"}), + ) 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() + sqlite_session.add(builtin_provider) + sqlite_session.commit() + mock_db.engine = sqlite_engine provider_encrypter = MagicMock() provider_encrypter.decrypt.return_value = {"tavily_api_key": "tvly-secret"} @@ -157,27 +170,34 @@ def test_runtime_tool_credentials_resolve_returns_decrypted_values( 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 + provider_encrypter.decrypt.assert_called_once_with({"tavily_api_key": "encrypted"}) @patch("controllers.inner_api.runtime_credentials.db") -@patch("controllers.inner_api.runtime_credentials.Session") @patch("controllers.inner_api.runtime_credentials.ToolManager") +@pytest.mark.parametrize("sqlite_session", [(BuiltinToolProvider,)], indirect=True) def test_runtime_tool_credentials_resolve_rejects_unknown_credential( mock_tool_manager, - mock_session_cls, mock_db, app: Flask, + sqlite_engine: Engine, + sqlite_session: Session, ): 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() + # The requested id exists for another tenant, proving the resolver does not + # expose a credential across workspace boundaries. + builtin_provider = BuiltinToolProvider( + tenant_id="tenant-2", + user_id="user-2", + provider="langgenius/tavily/tavily", + name="Other workspace Tavily", + encrypted_credentials=json.dumps({"tavily_api_key": "encrypted"}), + ) + builtin_provider.id = "missing" + sqlite_session.add(builtin_provider) + sqlite_session.commit() + mock_db.engine = sqlite_engine handler = EnterpriseRuntimeCredentialsResolve() unwrapped = inspect.unwrap(handler.post) diff --git a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py index 51d5ecdd36f..15044dbba0c 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py +++ b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py @@ -1,4 +1,9 @@ -"""Tests for openapi workflow events reconnect endpoint.""" +"""Tests for the OpenAPI workflow-events reconnect endpoint. + +The controller constructs a repository session factory, so every case binds +that real SQLAlchemy factory to an isolated SQLite engine. Repository behavior +remains mocked because these tests focus on authorization and SSE responses. +""" from __future__ import annotations @@ -9,6 +14,8 @@ from unittest.mock import Mock import pytest from flask import Flask +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound from controllers.openapi.auth.data import AuthData @@ -47,6 +54,11 @@ def _make_workflow_run( class TestOpenApiWorkflowEventsApi: + @pytest.fixture(autouse=True) + def _bind_sqlite_engine(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None: + module = sys.modules["controllers.openapi.workflow_events"] + monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine)) + def _get_api(self): from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi @@ -59,8 +71,6 @@ class TestOpenApiWorkflowEventsApi: factory_mock = Mock() factory_mock.create_api_workflow_run_repository.return_value = repo_mock monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) - monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) api = self._get_api() from models.model import AppMode @@ -77,6 +87,10 @@ class TestOpenApiWorkflowEventsApi: auth_data=_make_auth_data(app_model, caller, "account"), ) + session_maker = factory_mock.create_api_workflow_run_repository.call_args.args[0] + assert isinstance(session_maker, sessionmaker) + assert session_maker.kw["bind"] is module.db.engine + def test_not_found_when_run_belongs_to_different_app( self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch ): @@ -87,8 +101,6 @@ class TestOpenApiWorkflowEventsApi: factory_mock = Mock() factory_mock.create_api_workflow_run_repository.return_value = repo_mock monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) - monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) api = self._get_api() from models.model import AppMode @@ -116,8 +128,6 @@ class TestOpenApiWorkflowEventsApi: factory_mock = Mock() factory_mock.create_api_workflow_run_repository.return_value = repo_mock monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) - monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) snapshot_builder = Mock(return_value=iter([])) monkeypatch.setattr(module, "build_workflow_event_stream", snapshot_builder) @@ -156,8 +166,6 @@ class TestOpenApiWorkflowEventsApi: factory_mock = Mock() factory_mock.create_api_workflow_run_repository.return_value = repo_mock monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) - monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) from models.model import AppMode @@ -185,8 +193,6 @@ class TestOpenApiWorkflowEventsApi: factory_mock = Mock() factory_mock.create_api_workflow_run_repository.return_value = repo_mock monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) - monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) msg_gen_mock = Mock() msg_gen_mock.retrieve_events.return_value = iter([]) @@ -227,8 +233,6 @@ class TestOpenApiWorkflowEventsApi: factory_mock = Mock() factory_mock.create_api_workflow_run_repository.return_value = repo_mock monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) - monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) finish_response = SimpleNamespace( event=SimpleNamespace(value="workflow_finished"), diff --git a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py index 3197812bc31..09f0d430eff 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py @@ -22,6 +22,8 @@ from unittest.mock import Mock, patch import pytest from flask import Flask +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, NotFound import services @@ -39,19 +41,45 @@ from controllers.service_api.app.conversation import ( ConversationVariableUpdatePayload, ) from controllers.service_api.app.error import NotChatAppError +from core.app.entities.app_invoke_entities import InvokeFrom from fields._value_type_serializer import serialize_value_type from graphon.variables import StringSegment from graphon.variables.types import SegmentType -from models.model import App, AppMode, EndUser +from models.enums import ConversationFromSource +from models.model import App, AppMode, Conversation, EndUser from services.conversation_service import ConversationService from services.errors.conversation import ( ConversationNotExistsError, ConversationVariableNotExistsError, ConversationVariableTypeMismatchError, - LastConversationNotExistsError, ) +def _end_user(user_id: str = "end-user-1") -> EndUser: + end_user = EndUser() + end_user.id = user_id + return end_user + + +def _conversation( + *, + conversation_id: str, + app_id: str = "app-1", + end_user_id: str = "end-user-1", +) -> Conversation: + conversation = Conversation( + app_id=app_id, + mode=AppMode.CHAT, + name="Original Name", + from_source=ConversationFromSource.API, + from_end_user_id=end_user_id, + invoke_from=InvokeFrom.SERVICE_API, + ) + conversation.id = conversation_id + conversation.inputs = {} + return conversation + + class TestConversationListQuery: """Test suite for ConversationListQuery Pydantic model.""" @@ -462,23 +490,30 @@ class TestConversationService: assert hasattr(result, "limit") assert hasattr(result, "has_more") - @patch.object(ConversationService, "rename") - def test_rename_returns_conversation(self, mock_rename): + @pytest.mark.parametrize("sqlite_session", [(Conversation,)], indirect=True) + def test_rename_returns_conversation(self, sqlite_session: Session): """Test rename returns updated conversation.""" - mock_conversation = Mock() - mock_conversation.name = "New Name" - mock_rename.return_value = mock_conversation + conversation_id = "00000000-0000-0000-0000-000000000001" + conversation = _conversation(conversation_id=conversation_id) + sqlite_session.add(conversation) + sqlite_session.commit() + + app_model = App() + app_model.id = "app-1" + end_user = _end_user() result = ConversationService.rename( - app_model=Mock(spec=App), - conversation_id="conv_123", - user=Mock(spec=EndUser), + app_model=app_model, + conversation_id=conversation_id, + user=end_user, name="New Name", auto_generate=False, - session=Mock(), + session=sqlite_session, ) assert result.name == "New Name" + sqlite_session.refresh(conversation) + assert conversation.name == "New Name" class TestConversationPayloadsController: @@ -502,37 +537,29 @@ class TestConversationApiController: with pytest.raises(NotChatAppError): handler(api, app_model=app_model, end_user=end_user) - def test_list_last_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - class _BeginStub: - def __enter__(self): - return SimpleNamespace() + @pytest.mark.parametrize("sqlite_session", [(Conversation,)], indirect=True) + def test_list_last_not_found( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, + ) -> None: + last_id = "00000000-0000-0000-0000-000000000001" + # The id exists for a different app, proving pagination cannot cross app boundaries. + sqlite_session.add(_conversation(conversation_id=last_id, app_id="other-app")) + sqlite_session.commit() - def __exit__(self, exc_type, exc, tb): - return False - - class _SessionMakerStub: - def __init__(self, *args, **kwargs): - pass - - def begin(self): - return _BeginStub() - - monkeypatch.setattr( - ConversationService, - "pagination_by_last_id", - lambda *_args, **_kwargs: (_ for _ in ()).throw(LastConversationNotExistsError()), - ) conversation_module = sys.modules["controllers.service_api.app.conversation"] - monkeypatch.setattr(conversation_module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(conversation_module, "sessionmaker", _SessionMakerStub) + monkeypatch.setattr(conversation_module, "db", SimpleNamespace(engine=sqlite_engine)) api = ConversationApi() handler = unwrap(api.get) - app_model = SimpleNamespace(mode=AppMode.CHAT) - end_user = SimpleNamespace() + app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT) + end_user = _end_user() with app.test_request_context( - "/conversations?last_id=00000000-0000-0000-0000-000000000001&limit=20", + f"/conversations?last_id={last_id}&limit=20", method="GET", ): with pytest.raises(NotFound): diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py index 129220cbc9c..c5072ec2e70 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -14,6 +14,8 @@ from unittest.mock import ANY, MagicMock, Mock import pytest from flask import Flask +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker import services.app_generate_service as ags_module from controllers.service_api.app.workflow_events import WorkflowEventsApi @@ -31,7 +33,7 @@ from core.app.entities.task_entities import ( from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from core.workflow.human_input_policy import FormDisposition, HumanInputSurface from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig -from core.workflow.nodes.human_input.enums import FormInputType +from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType, HumanInputRequired from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason @@ -39,6 +41,7 @@ from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account from models.enums import CreatorUserRole +from models.human_input import HumanInputForm from models.model import AppMode from models.workflow import WorkflowRun from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot @@ -66,7 +69,7 @@ class _DummyRateLimit: return generator -def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run): +def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run, sqlite_engine: Engine): workflow_events_module = sys.modules["controllers.service_api.app.workflow_events"] repo = SimpleNamespace(get_workflow_run_by_id_and_tenant_id=lambda **_kwargs: workflow_run) monkeypatch.setattr( @@ -74,10 +77,33 @@ def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run): "create_api_workflow_run_repository", lambda *_args, **_kwargs: repo, ) - monkeypatch.setattr(workflow_events_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(workflow_events_module, "db", SimpleNamespace(engine=sqlite_engine)) return workflow_events_module +def _persist_human_input_form( + sqlite_session: Session, + *, + expiration_time: datetime, +) -> HumanInputForm: + form = HumanInputForm( + id="form-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + conversation_id=None, + form_kind=HumanInputFormKind.RUNTIME, + node_id="node-1", + form_definition=json.dumps({"display_in_ui": True}), + rendered_content="Rendered", + status=HumanInputFormStatus.WAITING, + expiration_time=expiration_time, + ) + sqlite_session.add(form) + sqlite_session.commit() + return form + + def _build_service_api_pause_converter() -> WorkflowResponseConverter: application_generate_entity = SimpleNamespace( inputs={}, @@ -257,7 +283,10 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: class TestHitlServiceApi: # Service API event-stream continuation def test_workflow_events_continue_on_pause_keeps_stream_open( - self, app: Flask, monkeypatch: pytest.MonkeyPatch + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, ) -> None: workflow_run = SimpleNamespace( id="run-1", @@ -266,7 +295,11 @@ class TestHitlServiceApi: created_by="end-user-1", finished_at=None, ) - workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + workflow_events_module = _mock_repo_for_run( + monkeypatch, + workflow_run=workflow_run, + sqlite_engine=sqlite_engine, + ) msg_generator = Mock() msg_generator.retrieve_events.return_value = ["raw-event"] workflow_generator = Mock() @@ -291,7 +324,10 @@ class TestHitlServiceApi: workflow_generator.convert_to_event_stream.assert_called_once_with(["raw-event"]) def test_workflow_events_snapshot_continue_on_pause_keeps_pause_open( - self, app: Flask, monkeypatch: pytest.MonkeyPatch + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, ) -> None: workflow_run = SimpleNamespace( id="run-1", @@ -300,7 +336,11 @@ class TestHitlServiceApi: created_by="end-user-1", finished_at=None, ) - workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + workflow_events_module = _mock_repo_for_run( + monkeypatch, + workflow_run=workflow_run, + sqlite_engine=sqlite_engine, + ) msg_generator = Mock() workflow_generator = Mock() workflow_generator.convert_to_event_stream.return_value = iter(["data: snapshot\n\n"]) @@ -331,16 +371,24 @@ class TestHitlServiceApi: human_input_surface=HumanInputSurface.SERVICE_API, close_on_pause=False, ) + snapshot_session_maker = snapshot_builder.call_args.kwargs["session_maker"] + assert isinstance(snapshot_session_maker, sessionmaker) + assert snapshot_session_maker.kw["bind"] is sqlite_engine workflow_generator.convert_to_event_stream.assert_called_once_with(["snapshot-events"]) - def test_advanced_chat_blocking_injects_pause_state_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_advanced_chat_blocking_injects_pause_state_config( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + ) -> None: monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", False) monkeypatch.setattr(ags_module, "RateLimit", _DummyRateLimit) workflow = MagicMock() workflow.created_by = "owner-id" monkeypatch.setattr(AppGenerateService, "_get_workflow", lambda *args, **kwargs: workflow) - monkeypatch.setattr(ags_module.session_factory, "get_session_maker", lambda: "session-maker") + sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + monkeypatch.setattr(ags_module.session_factory, "get_session_maker", lambda: sqlite_session_maker) generator_instance = MagicMock() generator_instance.generate.return_value = {"result": "advanced-blocking"} @@ -358,20 +406,21 @@ class TestHitlServiceApi: user = MagicMock() user.id = "user-id" - result = AppGenerateService.generate( - session=Mock(), - app_model=app_model, - user=user, - args={"workflow_id": None, "query": "hi", "inputs": {}}, - invoke_from=InvokeFrom.SERVICE_API, - streaming=False, - ) + with sqlite_session_maker() as session: + result = AppGenerateService.generate( + session=session, + app_model=app_model, + user=user, + args={"workflow_id": None, "query": "hi", "inputs": {}}, + invoke_from=InvokeFrom.SERVICE_API, + streaming=False, + ) assert result == {"result": "advanced-blocking"} call_kwargs = generator_instance.generate.call_args.kwargs assert call_kwargs["streaming"] is False assert call_kwargs["pause_state_config"] is not None - assert call_kwargs["pause_state_config"].session_factory == "session-maker" + assert call_kwargs["pause_state_config"].session_factory is sqlite_session_maker assert call_kwargs["pause_state_config"].state_owner_user_id == "owner-id" # Blocking payload contract @@ -569,7 +618,13 @@ class TestHitlServiceApi: assert response.data.paused_nodes == ["node-1"] assert response.data.reasons == [{"TYPE": "human_input_required", "form_id": "form-1", "expiration_time": 1}] - def test_service_api_pause_event_serializes_hitl_reason(self, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True) + def test_service_api_pause_event_serializes_hitl_reason( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, + ) -> None: converter = _build_service_api_pause_converter() converter.workflow_start_to_stream_response( task_id="task", @@ -578,20 +633,10 @@ class TestHitlServiceApi: reason=WorkflowStartReason.INITIAL, ) - expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + expiration_time = datetime(2024, 1, 1) + _persist_human_input_form(sqlite_session, expiration_time=expiration_time) - class _FakeSession: - def execute(self, _stmt): - return [("form-1", expiration_time, '{"display_in_ui": true}')] - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession()) - monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=sqlite_engine)) monkeypatch.setattr( workflow_response_converter, "load_form_dispositions_by_form_id", @@ -651,10 +696,18 @@ class TestHitlServiceApi: assert hi_resp.data.expiration_time == int(expiration_time.timestamp()) # Snapshot payload contract - def test_snapshot_events_include_pause_payload_contract(self, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True) + def test_snapshot_events_include_pause_payload_contract( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, + ) -> None: workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) resumption_context = _build_resumption_context("task-ctx") + expiration_time = datetime(2024, 1, 1) + _persist_human_input_form(sqlite_session, expiration_time=expiration_time) monkeypatch.setattr( "services.workflow_event_snapshot_service.load_form_dispositions_by_form_id", lambda form_ids, session=None, surface=None: { @@ -662,22 +715,7 @@ class TestHitlServiceApi: }, ) - class _SessionContext: - def __init__(self, session): - self._session = session - - def __enter__(self): - return self._session - - def __exit__(self, exc_type, exc, tb): - return False - - def session_maker() -> _SessionContext: - return _SessionContext( - SimpleNamespace( - execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')], - ) - ) + sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) pause_entity = _FakePauseEntity( pause_id="pause-1", @@ -701,7 +739,7 @@ class TestHitlServiceApi: message_context=None, pause_entity=pause_entity, resumption_context=resumption_context, - session_maker=session_maker, + session_maker=sqlite_session_maker, ) assert [event["event"] for event in events] == [ @@ -713,13 +751,13 @@ class TestHitlServiceApi: ] assert events[2]["data"]["status"] == WorkflowNodeExecutionStatus.PAUSED.value assert events[3]["data"]["form_token"] == "wtok" - assert events[3]["data"]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + assert events[3]["data"]["expiration_time"] == int(expiration_time.timestamp()) pause_data = events[-1]["data"] assert pause_data["paused_nodes"] == ["node-1"] assert pause_data["outputs"] == {"result": "value"} assert pause_data["reasons"][0]["TYPE"] == "human_input_required" assert pause_data["reasons"][0]["form_token"] == "wtok" - assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + assert pause_data["reasons"][0]["expiration_time"] == int(expiration_time.timestamp()) assert pause_data["status"] == WorkflowExecutionStatus.PAUSED.value assert pause_data["created_at"] == int(workflow_run.created_at.timestamp()) assert pause_data["elapsed_time"] == workflow_run.elapsed_time diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py index f381bd3fbc4..7975a935f93 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py @@ -16,20 +16,20 @@ Focus on: import json import sys import uuid -from dataclasses import dataclass, field from datetime import UTC, datetime from inspect import unwrap +from types import SimpleNamespace from unittest.mock import MagicMock, Mock, patch import pytest from flask import Flask -from sqlalchemy.orm import sessionmaker +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, NotFound from controllers.service_api.app.error import NotWorkflowAppError, WorkflowVersionExecutionNotAllowedError from controllers.service_api.app.workflow import ( AppQueueManager, - DifyAPIRepositoryFactory, GraphEngineManager, WorkflowAppLogApi, WorkflowLogQuery, @@ -44,6 +44,7 @@ from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpErr from core.app.entities.app_invoke_entities import InvokeFrom from enums.cloud_plan import CloudPlan from graphon.enums import WorkflowExecutionStatus +from models import Account from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType @@ -51,58 +52,18 @@ from services.app_generate_service import AppGenerateService from services.billing_service import BillingService from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError -from services.workflow_app_service import LogView, LogViewDetails, WorkflowAppService +from services.workflow_app_service import WorkflowAppService def _default_workflow_inputs() -> dict[str, object]: return {"input": "value"} -def _default_log_details() -> LogViewDetails: - return {"trigger_metadata": {"node": "answer", "latency": 1.25}} - - -class _DbSessionStub: - def get(self, *args: object, **kwargs: object) -> None: - return None - - -@dataclass -class _DbStub: - engine: object = field(default_factory=object) - session: _DbSessionStub = field(default_factory=_DbSessionStub) - - -@dataclass -class _WorkflowRunRepositoryStub: - run: WorkflowRun | None - - def get_workflow_run_by_id(self, *, tenant_id: str, app_id: str, run_id: str) -> WorkflowRun | None: - return self.run if tenant_id and app_id and run_id else None - - def get_workflow_run_by_id_without_tenant(self, *, run_id: str) -> WorkflowRun | None: - return self.run if run_id else None - - -class _BeginStub: - def __enter__(self) -> object: - return object() - - def __exit__(self, exc_type: object, exc: object, tb: object) -> bool: - return False - - -class _SessionMakerStub: - def __init__(self, *args: object, **kwargs: object) -> None: - pass - - def begin(self) -> _BeginStub: - return _BeginStub() - - def _make_workflow_run( run_id: str = "run-1", *, + tenant_id: str = "tenant-1", + app_id: str = "app-1", workflow_id: str = "wf-1", inputs: dict[str, object] | None = None, outputs: dict[str, object] | None = None, @@ -111,8 +72,8 @@ def _make_workflow_run( ) -> WorkflowRun: return WorkflowRun( id=run_id, - tenant_id="tenant-1", - app_id="app-1", + tenant_id=tenant_id, + app_id=app_id, workflow_id=workflow_id, type=WorkflowType.WORKFLOW, triggered_from=WorkflowRunTriggeredFrom.APP_RUN, @@ -133,12 +94,17 @@ def _make_workflow_run( ) -def _make_workflow_app_log() -> WorkflowAppLog: +def _make_workflow_app_log( + *, + tenant_id: str = "tenant-1", + app_id: str = "app-1", + workflow_run_id: str = "log-run-1", +) -> WorkflowAppLog: log = WorkflowAppLog( - tenant_id="tenant-1", - app_id="app-1", + tenant_id=tenant_id, + app_id=app_id, workflow_id="wf-1", - workflow_run_id="log-run-1", + workflow_run_id=workflow_run_id, created_from=WorkflowAppLogCreatedFrom.SERVICE_API, created_by_role=CreatorUserRole.ACCOUNT, created_by="account-1", @@ -148,16 +114,6 @@ def _make_workflow_app_log() -> WorkflowAppLog: return log -def _make_workflow_log_page() -> dict[str, object]: - return { - "page": 1, - "limit": 20, - "total": 1, - "has_more": False, - "data": [LogView(_make_workflow_app_log(), _default_log_details())], - } - - def _make_app_model( *, app_id: str = "app-1", @@ -177,6 +133,43 @@ def _make_end_user(user_id: str = "end-user-1") -> EndUser: return end_user +def _bind_sqlite_database( + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, +) -> None: + """Bind controller- and model-owned database access to the test engine.""" + database = SimpleNamespace(engine=sqlite_engine, session=sqlite_session) + monkeypatch.setattr(sys.modules["controllers.service_api.app.workflow"], "db", database) + monkeypatch.setattr(sys.modules["models.workflow"], "db", database) + + +def _persist_workflow_log( + sqlite_session: Session, + *, + tenant_id: str, + app_id: str, +) -> None: + workflow_run_id = "log-run-1" + sqlite_session.add_all( + [ + _make_workflow_run( + run_id=workflow_run_id, + tenant_id=tenant_id, + app_id=app_id, + created_at=datetime(2026, 1, 1, 1, tzinfo=UTC), + finished_at=datetime(2026, 1, 1, 1, 0, 2, tzinfo=UTC), + ), + _make_workflow_app_log( + tenant_id=tenant_id, + app_id=app_id, + workflow_run_id=workflow_run_id, + ), + ] + ) + sqlite_session.commit() + + def _expected_workflow_log_pagination_payload() -> dict[str, object]: return { "page": 1, @@ -195,16 +188,16 @@ def _expected_workflow_log_pagination_payload() -> dict[str, object]: "elapsed_time": 0.1, "total_tokens": 10, "total_steps": 1, - "created_at": 1767229200, - "finished_at": 1767229202, + "created_at": int(datetime(2026, 1, 1, 1).timestamp()), + "finished_at": int(datetime(2026, 1, 1, 1, 0, 2).timestamp()), "exceptions_count": 0, }, - "details": {"trigger_metadata": {"node": "answer", "latency": 1.25}}, + "details": None, "created_from": "service-api", "created_by_role": "account", "created_by_account": None, "created_by_end_user": None, - "created_at": 1767229203, + "created_at": int(datetime(2026, 1, 1, 1, 0, 3).timestamp()), } ], } @@ -364,15 +357,15 @@ class TestWorkflowAppService: assert hasattr(WorkflowAppService, "get_paginate_workflow_app_logs") assert callable(WorkflowAppService.get_paginate_workflow_app_logs) - @patch.object(WorkflowAppService, "get_paginate_workflow_app_logs") - def test_get_paginate_workflow_app_logs_returns_pagination(self, mock_get_logs): - """Test get_paginate_workflow_app_logs returns paginated result.""" - pagination = _make_workflow_log_page() - mock_get_logs.return_value = pagination - + @pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True) + def test_get_paginate_workflow_app_logs_returns_pagination(self, sqlite_session: Session): + """Test pagination returns committed logs scoped to the requested app.""" + log = _make_workflow_app_log() + sqlite_session.add(log) + sqlite_session.commit() service = WorkflowAppService() result = service.get_paginate_workflow_app_logs( - session=Mock(), + session=sqlite_session, app_model=_make_app_model(), keyword=None, status=None, @@ -384,7 +377,11 @@ class TestWorkflowAppService: created_by_account=None, ) - assert result == pagination + assert result["page"] == 1 + assert result["limit"] == 20 + assert result["total"] == 1 + assert result["has_more"] is False + assert [item.id for item in result["data"]] == [log.id] class TestWorkflowExecutionStatus: @@ -409,8 +406,9 @@ class TestWorkflowExecutionStatus: class TestAppGenerateServiceWorkflow: """Test AppGenerateService workflow integration.""" + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @patch.object(AppGenerateService, "generate") - def test_generate_accepts_workflow_args(self, mock_generate: MagicMock): + def test_generate_accepts_workflow_args(self, mock_generate: MagicMock, sqlite_session: Session): """Test generate accepts workflow-specific args.""" mock_generate.return_value = {"result": "success"} @@ -419,15 +417,17 @@ class TestAppGenerateServiceWorkflow: user=_make_end_user(), args={"inputs": {"key": "value"}, "workflow_id": "workflow_123"}, invoke_from=InvokeFrom.SERVICE_API, - session=MagicMock(), + session=sqlite_session, streaming=False, ) assert result == {"result": "success"} mock_generate.assert_called_once() + assert mock_generate.call_args.kwargs["session"] is sqlite_session + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @patch.object(AppGenerateService, "generate") - def test_generate_raises_workflow_not_found_error(self, mock_generate: MagicMock): + def test_generate_raises_workflow_not_found_error(self, mock_generate: MagicMock, sqlite_session: Session): """Test generate raises WorkflowNotFoundError.""" mock_generate.side_effect = WorkflowNotFoundError("Workflow not found") @@ -437,12 +437,13 @@ class TestAppGenerateServiceWorkflow: user=_make_end_user(), args={"workflow_id": "invalid_id"}, invoke_from=InvokeFrom.SERVICE_API, - session=MagicMock(), + session=sqlite_session, streaming=False, ) + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @patch.object(AppGenerateService, "generate") - def test_generate_raises_is_draft_workflow_error(self, mock_generate: MagicMock): + def test_generate_raises_is_draft_workflow_error(self, mock_generate: MagicMock, sqlite_session: Session): """Test generate raises IsDraftWorkflowError.""" mock_generate.side_effect = IsDraftWorkflowError("Workflow is draft") @@ -452,12 +453,13 @@ class TestAppGenerateServiceWorkflow: user=_make_end_user(), args={"workflow_id": "draft_workflow"}, invoke_from=InvokeFrom.SERVICE_API, - session=MagicMock(), + session=sqlite_session, streaming=False, ) + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @patch.object(AppGenerateService, "generate") - def test_generate_supports_streaming_mode(self, mock_generate: MagicMock): + def test_generate_supports_streaming_mode(self, mock_generate: MagicMock, sqlite_session: Session): """Test generate supports streaming response mode.""" mock_stream = Mock() mock_generate.return_value = mock_stream @@ -467,7 +469,7 @@ class TestAppGenerateServiceWorkflow: user=_make_end_user(), args={"inputs": {}, "response_mode": "streaming"}, invoke_from=InvokeFrom.SERVICE_API, - session=MagicMock(), + session=sqlite_session, streaming=True, ) @@ -499,19 +501,23 @@ class TestWorkflowRunRepository: assert hasattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository") - @patch("repositories.factory.DifyAPIRepositoryFactory.create_api_workflow_run_repository") - def test_workflow_run_repository_get_by_id(self, mock_factory): - """Test workflow run repository get_workflow_run_by_id method.""" + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun,)], indirect=True) + def test_workflow_run_repository_get_by_id(self, sqlite_engine: Engine, sqlite_session: Session): + """Test repository lookup against committed tenant-scoped state.""" run = _make_workflow_run(run_id=str(uuid.uuid4())) - mock_factory.return_value = _WorkflowRunRepositoryStub(run=run) - + sqlite_session.add(run) + sqlite_session.commit() from repositories.factory import DifyAPIRepositoryFactory - repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(sessionmaker()) + repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository( + sessionmaker(bind=sqlite_engine, expire_on_commit=False) + ) - result = repo.get_workflow_run_by_id(tenant_id="tenant_123", app_id="app_456", run_id="run_789") + result = repo.get_workflow_run_by_id(tenant_id="tenant-1", app_id="app-1", run_id=run.id) - assert result == run + assert result is not None + assert result.id == run.id + assert repo.get_workflow_run_by_id(tenant_id="other-tenant", app_id="app-1", run_id=run.id) is None class TestWorkflowRunDetailApi: @@ -524,16 +530,17 @@ class TestWorkflowRunDetailApi: with pytest.raises(NotWorkflowAppError): handler(api, app_model=app_model, workflow_run_id="run") - def test_success(self, monkeypatch: pytest.MonkeyPatch) -> None: - run = _make_workflow_run(run_id="run") - repo = _WorkflowRunRepositoryStub(run=run) - workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module, "db", _DbStub()) - monkeypatch.setattr( - DifyAPIRepositoryFactory, - "create_api_workflow_run_repository", - lambda *_args, **_kwargs: repo, - ) + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun,)], indirect=True) + def test_success( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, + ) -> None: + run = _make_workflow_run(run_id="run", tenant_id="t1", app_id="a1") + sqlite_session.add(run) + sqlite_session.commit() + _bind_sqlite_database(monkeypatch, sqlite_engine, sqlite_session) api = WorkflowRunDetailApi() handler = unwrap(api.get) @@ -546,7 +553,8 @@ class TestWorkflowRunDetailApi: class TestWorkflowRunApi: - def test_not_workflow_app(self, app: Flask) -> None: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_not_workflow_app(self, app: Flask, sqlite_session: Session) -> None: api = WorkflowRunApi() handler = unwrap(api.post) app_model = _make_app_model(mode=AppMode.CHAT) @@ -554,9 +562,10 @@ class TestWorkflowRunApi: with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}): with pytest.raises(NotWorkflowAppError): - handler(api, session=Mock(), app_model=app_model, end_user=end_user) + handler(api, session=sqlite_session, app_model=app_model, end_user=end_user) - def test_rate_limit(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_rate_limit(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: monkeypatch.setattr( AppGenerateService, "generate", @@ -570,7 +579,7 @@ class TestWorkflowRunApi: with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}): with pytest.raises(InvokeRateLimitHttpError): - handler(api, session=Mock(), app_model=app_model, end_user=end_user) + handler(api, session=sqlite_session, app_model=app_model, end_user=end_user) def test_sandbox_billing_does_not_gate_default_workflow_run( self, app: Flask, monkeypatch: pytest.MonkeyPatch @@ -680,7 +689,8 @@ class TestWorkflowRunByIdApi: else: billing_get_info.assert_not_called() - def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: workflow_module = sys.modules["controllers.service_api.app.workflow"] monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", False) monkeypatch.setattr( @@ -696,9 +706,10 @@ class TestWorkflowRunByIdApi: with app.test_request_context("/workflows/1/run", method="POST", json={"inputs": {}}): with pytest.raises(NotFound): - handler(api, session=Mock(), app_model=app_model, end_user=end_user, workflow_id="w1") + handler(api, session=sqlite_session, app_model=app_model, end_user=end_user, workflow_id="w1") - def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: workflow_module = sys.modules["controllers.service_api.app.workflow"] monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", False) monkeypatch.setattr( @@ -714,7 +725,7 @@ class TestWorkflowRunByIdApi: with app.test_request_context("/workflows/1/run", method="POST", json={"inputs": {}}): with pytest.raises(BadRequest): - handler(api, session=Mock(), app_model=app_model, end_user=end_user, workflow_id="w1") + handler(api, session=sqlite_session, app_model=app_model, end_user=end_user, workflow_id="w1") class TestWorkflowTaskStopApi: @@ -748,28 +759,16 @@ class TestWorkflowTaskStopApi: class TestWorkflowAppLogApi: - def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - workflow_module = sys.modules["controllers.service_api.app.workflow"] - workflow_model_module = sys.modules["models.workflow"] - monkeypatch.setattr(workflow_module, "db", _DbStub()) - monkeypatch.setattr(workflow_model_module, "db", _DbStub()) - monkeypatch.setattr(workflow_module, "sessionmaker", _SessionMakerStub) - monkeypatch.setattr( - WorkflowAppService, - "get_paginate_workflow_app_logs", - lambda *_args, **_kwargs: _make_workflow_log_page(), - ) - monkeypatch.setattr( - DifyAPIRepositoryFactory, - "create_api_workflow_run_repository", - lambda *_args, **_kwargs: _WorkflowRunRepositoryStub( - run=_make_workflow_run( - run_id="log-run-1", - created_at=datetime(2026, 1, 1, 1, tzinfo=UTC), - finished_at=datetime(2026, 1, 1, 1, 0, 2, tzinfo=UTC), - ) - ), - ) + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun, WorkflowAppLog, Account)], indirect=True) + def test_success( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, + ) -> None: + _persist_workflow_log(sqlite_session, tenant_id="tenant-1", app_id="a1") + _bind_sqlite_database(monkeypatch, sqlite_engine, sqlite_session) api = WorkflowAppLogApi() handler = unwrap(api.get) @@ -803,18 +802,24 @@ class TestWorkflowRunDetailApiGet: and we call the unwrapped method directly in tests. """ - @patch("controllers.service_api.app.workflow.DifyAPIRepositoryFactory") - @patch("controllers.service_api.app.workflow.db") + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun,)], indirect=True) def test_get_workflow_run_success( self, - mock_db, - mock_repo_factory, app: Flask, workflow_app: App, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, ): """Test successful workflow run detail retrieval.""" - run = _make_workflow_run(run_id="run-1") - mock_repo_factory.create_api_workflow_run_repository.return_value = _WorkflowRunRepositoryStub(run=run) + run = _make_workflow_run( + run_id="run-1", + tenant_id=workflow_app.tenant_id, + app_id=workflow_app.id, + ) + sqlite_session.add(run) + sqlite_session.commit() + _bind_sqlite_database(monkeypatch, sqlite_engine, sqlite_session) from controllers.service_api.app.workflow import WorkflowRunDetailApi @@ -834,13 +839,12 @@ class TestWorkflowRunDetailApiGet: "error": None, "total_steps": 1, "total_tokens": 10, - "created_at": 1767225600, - "finished_at": 1767225600, + "created_at": int(datetime(2026, 1, 1).timestamp()), + "finished_at": int(datetime(2026, 1, 1).timestamp()), "elapsed_time": 0.1, } - @patch("controllers.service_api.app.workflow.db") - def test_get_workflow_run_wrong_app_mode(self, mock_db, app: Flask): + def test_get_workflow_run_wrong_app_mode(self, app: Flask): """Test NotWorkflowAppError when app mode is not workflow or advanced_chat.""" from controllers.service_api.app.workflow import WorkflowRunDetailApi @@ -902,46 +906,23 @@ class TestWorkflowAppLogApiGet: ``get`` is wrapped by ``@validate_app_token``. """ - @patch("controllers.service_api.app.workflow.WorkflowAppService") - @patch("controllers.service_api.app.workflow.db") + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun, WorkflowAppLog, Account)], indirect=True) def test_get_workflow_logs_success( self, - mock_db, - mock_wf_svc_cls, app: Flask, workflow_app: App, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, ): """Test successful workflow log retrieval.""" - mock_svc_instance = Mock() - mock_svc_instance.get_paginate_workflow_app_logs.return_value = _make_workflow_log_page() - mock_wf_svc_cls.return_value = mock_svc_instance - mock_repo = _WorkflowRunRepositoryStub( - run=_make_workflow_run( - run_id="log-run-1", - created_at=datetime(2026, 1, 1, 1, tzinfo=UTC), - finished_at=datetime(2026, 1, 1, 1, 0, 2, tzinfo=UTC), - ) - ) - - # Mock sessionmaker(...).begin() context manager - mock_db.engine = object() - mock_db.session.get.return_value = None + _persist_workflow_log(sqlite_session, tenant_id=workflow_app.tenant_id, app_id=workflow_app.id) + _bind_sqlite_database(monkeypatch, sqlite_engine, sqlite_session) from controllers.service_api.app.workflow import WorkflowAppLogApi - with app.test_request_context( - "/workflows/logs?page=1&limit=20", - method="GET", - ): - with ( - patch("controllers.service_api.app.workflow.sessionmaker", _SessionMakerStub), - patch("models.workflow.db", _DbStub()), - patch( - "repositories.factory.DifyAPIRepositoryFactory.create_api_workflow_run_repository", - return_value=mock_repo, - ), - ): - api = WorkflowAppLogApi() - result = unwrap(api.get)(api, app_model=workflow_app) + with app.test_request_context("/workflows/logs?page=1&limit=20", method="GET"): + api = WorkflowAppLogApi() + result = unwrap(api.get)(api, app_model=workflow_app) assert result == _expected_workflow_log_pagination_payload() diff --git a/api/tests/unit_tests/controllers/service_api/conftest.py b/api/tests/unit_tests/controllers/service_api/conftest.py index fff64efd4cb..bede4d75850 100644 --- a/api/tests/unit_tests/controllers/service_api/conftest.py +++ b/api/tests/unit_tests/controllers/service_api/conftest.py @@ -7,18 +7,57 @@ Service API controller tests. """ import uuid +from collections.abc import Iterator +from dataclasses import dataclass from unittest.mock import Mock import pytest from flask import Flask +from sqlalchemy import Engine +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType -from models.account import TenantStatus +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus +from models.base import TypeBase from models.model import App, AppMode, EndUser -from tests.unit_tests.conftest import ( - setup_mock_dataset_owner_execute_result, - setup_mock_tenant_owner_execute_result, -) + + +@dataclass(frozen=True) +class ServiceApiIdentity: + """Persisted owner identity for service-API authentication tests.""" + + session: Session + tenant: Tenant + account: Account + membership: TenantAccountJoin + + +@pytest.fixture +def service_api_identity(sqlite_engine: Engine) -> Iterator[ServiceApiIdentity]: + """Yield an isolated SQLite session with a real active tenant owner.""" + TypeBase.metadata.create_all( + sqlite_engine, + tables=[Account.__table__, Tenant.__table__, TenantAccountJoin.__table__], + ) + with Session(sqlite_engine, expire_on_commit=False) as session: + tenant = Tenant(name="Service API Workspace") + tenant.id = str(uuid.uuid4()) + account = Account(name="Service API Owner", email=f"owner-{tenant.id}@example.com") + account.id = str(uuid.uuid4()) + membership = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + role=TenantAccountRole.OWNER, + ) + account._current_tenant = tenant + session.add_all([tenant, account, membership]) + session.commit() + yield ServiceApiIdentity( + session=session, + tenant=tenant, + account=account, + membership=membership, + ) @pytest.fixture @@ -110,40 +149,6 @@ def mock_dataset_api_token(mock_tenant_id): return token -class AuthenticationMocker: - """ - Helper class to set up common authentication mocking patterns. - - Usage: - auth_mocker = AuthenticationMocker() - with auth_mocker.mock_app_auth(mock_api_token, mock_app_model, mock_tenant): - # Test code here - """ - - @staticmethod - def setup_db_queries(mock_db, mock_app, mock_tenant, mock_account=None): - """Configure mock_db to return app and tenant via session.get().""" - mock_db.session.get.side_effect = [mock_app, mock_tenant] - - if mock_account: - setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) - - @staticmethod - def setup_dataset_auth(mock_db, mock_tenant, mock_account): - """Configure mock_db for dataset token authentication.""" - mock_ta = Mock() - mock_ta.account_id = mock_account.id - - setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta) - mock_db.session.get.return_value = mock_account - - -@pytest.fixture -def auth_mocker(): - """Provide an AuthenticationMocker instance.""" - return AuthenticationMocker() - - @pytest.fixture def mock_dataset(): """Create a mock Dataset model.""" diff --git a/api/tests/unit_tests/controllers/service_api/test_conftest.py b/api/tests/unit_tests/controllers/service_api/test_conftest.py new file mode 100644 index 00000000000..014d99a0636 --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/test_conftest.py @@ -0,0 +1,55 @@ +"""State-based checks for shared service-API authentication fixtures.""" + +from uuid import uuid4 + +from sqlalchemy import select + +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from tests.unit_tests.conftest import ( + persist_service_api_dataset_owner, + persist_service_api_tenant_owner, +) +from tests.unit_tests.controllers.service_api.conftest import ServiceApiIdentity + + +def test_service_api_identity_persists_tenant_scoped_owner(service_api_identity: ServiceApiIdentity) -> None: + identity = service_api_identity + + owner_row = identity.session.execute( + select(Tenant, Account) + .join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id) + .join(Account, TenantAccountJoin.account_id == Account.id) + .where( + Tenant.id == identity.tenant.id, + TenantAccountJoin.role == TenantAccountRole.OWNER, + ) + ).one() + + assert owner_row == (identity.tenant, identity.account) + assert identity.account.current_tenant is identity.tenant + + +def test_shared_helpers_persist_real_app_and_dataset_owner_rows(service_api_identity: ServiceApiIdentity) -> None: + session = service_api_identity.session + app_tenant = Tenant(name="App Workspace") + app_tenant.id = str(uuid4()) + app_owner = Account(name="App Owner", email=f"app-owner-{app_tenant.id}@example.com") + app_owner.id = str(uuid4()) + + app_membership = persist_service_api_tenant_owner(session, app_tenant, app_owner) + + dataset_tenant = Tenant(name="Dataset Workspace") + dataset_tenant.id = str(uuid4()) + dataset_membership = TenantAccountJoin( + tenant_id=dataset_tenant.id, + account_id=service_api_identity.account.id, + role=TenantAccountRole.OWNER, + ) + persist_service_api_dataset_owner(session, dataset_tenant, dataset_membership) + + assert session.get(TenantAccountJoin, app_membership.id) is app_membership + assert session.execute( + select(Tenant, TenantAccountJoin) + .join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id) + .where(Tenant.id == dataset_tenant.id) + ).one() == (dataset_tenant, dataset_membership) diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index 667058de95e..149af9ff76a 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -574,6 +574,27 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest assert params["avatar"]["required"] is True +def test_console_agent_debug_conversation_refresh_body_is_optional(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.console import bp as console_bp + + monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(console_bp) + + payload = app.test_client().get("/console/api/openapi.json").get_json() + operation = payload["paths"]["/agent/{agent_id}/debug-conversation/refresh"]["post"] + request_body = operation["requestBody"] + + assert request_body["required"] is False + assert request_body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AgentDebugConversationRefreshPayload" + } + assert "AgentDebugConversationRefreshPayload" in payload["components"]["schemas"] + + def test_console_member_invite_documents_bad_request_response(monkeypatch: pytest.MonkeyPatch): from configs import dify_config from controllers.console import bp as console_bp diff --git a/api/tests/unit_tests/controllers/web/test_site.py b/api/tests/unit_tests/controllers/web/test_site.py new file mode 100644 index 00000000000..1c2a403994f --- /dev/null +++ b/api/tests/unit_tests/controllers/web/test_site.py @@ -0,0 +1,67 @@ +from unittest.mock import MagicMock, patch + +from configs import dify_config +from controllers.web import site as site_module +from extensions.storage.storage_type import StorageType +from models.model import IconType, Site + + +def test_build_site_icon_url_uses_s3_presigned_url() -> None: + site = MagicMock(spec=Site) + site.icon_type = IconType.IMAGE + site.icon = "11111111-1111-4111-8111-111111111111" + + with ( + patch.object(dify_config, "EDITION", "CLOUD"), + patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), + patch.object(site_module, "db") as mock_db, + patch.object(site_module, "FileService") as mock_file_service, + patch.object(site_module, "build_icon_url") as mock_build_icon_url, + ): + mock_file_service.return_value.get_file_presigned_url.return_value = ( + "https://s3.example.com/icon.png?signature=test" + ) + + result = site_module._build_site_icon_url(site=site, tenant_id="tenant-id") + + assert result == "https://s3.example.com/icon.png?signature=test" + mock_file_service.assert_called_once_with(mock_db.engine) + mock_file_service.return_value.get_file_presigned_url.assert_called_once_with( + file_id="11111111-1111-4111-8111-111111111111", + tenant_id="tenant-id", + ) + mock_build_icon_url.assert_not_called() + + +def test_build_site_icon_url_keeps_preview_url_for_self_hosted_s3() -> None: + site = MagicMock(spec=Site) + site.icon_type = IconType.IMAGE + site.icon = "11111111-1111-4111-8111-111111111111" + + with ( + patch.object(dify_config, "EDITION", "SELF_HOSTED"), + patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), + patch.object(site_module, "FileService") as mock_file_service, + patch.object(site_module, "build_icon_url", return_value="https://api.example.com/files/icon/file-preview"), + ): + result = site_module._build_site_icon_url(site=site, tenant_id="tenant-id") + + assert result == "https://api.example.com/files/icon/file-preview" + mock_file_service.assert_not_called() + + +def test_build_site_icon_url_keeps_preview_url_for_non_s3_storage() -> None: + site = MagicMock(spec=Site) + site.icon_type = IconType.IMAGE + site.icon = "11111111-1111-4111-8111-111111111111" + + with ( + patch.object(dify_config, "EDITION", "CLOUD"), + patch.object(dify_config, "STORAGE_TYPE", StorageType.LOCAL), + patch.object(site_module, "FileService") as mock_file_service, + patch.object(site_module, "build_icon_url", return_value="https://api.example.com/files/icon/file-preview"), + ): + result = site_module._build_site_icon_url(site=site, tenant_id="tenant-id") + + assert result == "https://api.example.com/files/icon/file-preview" + mock_file_service.assert_not_called() diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py index 1970e5c1522..1f592ddec82 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py @@ -1,459 +1,120 @@ -"""Test conversation variable handling in AdvancedChatAppRunner.""" +"""SQLite-backed conversation-variable synchronization tests for AdvancedChatAppRunner.""" -from unittest.mock import MagicMock, patch -from uuid import uuid4 +from unittest.mock import MagicMock +import pytest +from sqlalchemy import select from sqlalchemy.orm import Session +from core.app.apps.advanced_chat import app_runner as app_runner_module from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner -from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from factories import variable_factory from graphon.variables import SegmentType -from models import ConversationVariable, Workflow +from models import ConversationVariable -MINIMAL_GRAPH = { - "nodes": [ +APP_ID = "11111111-1111-1111-1111-111111111111" +CONVERSATION_ID = "22222222-2222-2222-2222-222222222222" +OTHER_CONVERSATION_ID = "22222222-2222-2222-2222-222222222223" +VAR_1_ID = "33333333-3333-3333-3333-333333333333" +VAR_2_ID = "33333333-3333-3333-3333-333333333334" + + +def _variable(variable_id: str, name: str, value: str): + return variable_factory.build_conversation_variable_from_mapping( { - "id": "start", - "data": { - "type": "start", - "title": "Start", - }, + "id": variable_id, + "name": name, + "value_type": SegmentType.STRING, + "value": value, } - ], - "edges": [], -} + ) -def _patch_create_session(mock_session: MagicMock): - session_context = MagicMock() - session_context.__enter__.return_value = mock_session - session_context.__exit__.return_value = False - mock_session.begin.return_value.__enter__.return_value = mock_session - mock_session.begin.return_value.__exit__.return_value = False - return patch("core.app.apps.advanced_chat.app_runner.create_session", return_value=session_context) +def _runner(workflow_variables: list[object]) -> AdvancedChatAppRunner: + workflow = MagicMock() + workflow.conversation_variables = workflow_variables + conversation = MagicMock(app_id=APP_ID, id=CONVERSATION_ID) + return AdvancedChatAppRunner( + application_generate_entity=MagicMock(), + queue_manager=MagicMock(), + conversation=conversation, + message=MagicMock(), + dialogue_count=1, + variable_loader=MagicMock(), + workflow=workflow, + system_user_id="44444444-4444-4444-4444-444444444444", + app=MagicMock(), + workflow_execution_repository=MagicMock(), + workflow_node_execution_repository=MagicMock(), + ) -class TestAdvancedChatAppRunnerConversationVariables: - """Test that AdvancedChatAppRunner correctly handles conversation variables.""" +def _bind_runner_sessions(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + engine = sqlite_session.get_bind() + monkeypatch.setattr( + app_runner_module, + "create_session", + lambda: Session(engine, expire_on_commit=False), + ) - def test_missing_conversation_variables_are_added(self): - """Test that new conversation variables added to workflow are created for existing conversations.""" - # Setup - app_id = str(uuid4()) - conversation_id = str(uuid4()) - workflow_id = str(uuid4()) - # Create workflow with two conversation variables - workflow_vars = [ - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var1", - "name": "existing_var", - "value_type": SegmentType.STRING, - "value": "default1", - } - ), - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var2", - "name": "new_var", - "value_type": SegmentType.STRING, - "value": "default2", - } - ), - ] - - # Mock workflow with conversation variables - mock_workflow = MagicMock(spec=Workflow) - mock_workflow.conversation_variables = workflow_vars - mock_workflow.tenant_id = str(uuid4()) - mock_workflow.app_id = app_id - mock_workflow.id = workflow_id - mock_workflow.type = "chat" - mock_workflow.graph_dict = MINIMAL_GRAPH - mock_workflow.environment_variables = [] - - # Create existing conversation variable (only var1 exists in DB) - existing_db_var = MagicMock(spec=ConversationVariable) - existing_db_var.id = "var1" - existing_db_var.app_id = app_id - existing_db_var.conversation_id = conversation_id - existing_db_var.to_variable = MagicMock(return_value=workflow_vars[0]) - - # Mock conversation and message - mock_conversation = MagicMock() - mock_conversation.app_id = app_id - mock_conversation.id = conversation_id - - mock_message = MagicMock() - mock_message.id = str(uuid4()) - - # Mock app config - mock_app_config = MagicMock() - mock_app_config.app_id = app_id - mock_app_config.workflow_id = workflow_id - mock_app_config.tenant_id = str(uuid4()) - - # Mock app generate entity - mock_app_generate_entity = MagicMock(spec=AdvancedChatAppGenerateEntity) - mock_app_generate_entity.app_config = mock_app_config - mock_app_generate_entity.inputs = {} - mock_app_generate_entity.query = "test query" - mock_app_generate_entity.files = [] - mock_app_generate_entity.user_id = str(uuid4()) - mock_app_generate_entity.invoke_from = InvokeFrom.SERVICE_API - mock_app_generate_entity.workflow_run_id = str(uuid4()) - mock_app_generate_entity.task_id = str(uuid4()) - mock_app_generate_entity.call_depth = 0 - mock_app_generate_entity.single_iteration_run = None - mock_app_generate_entity.single_loop_run = None - mock_app_generate_entity.extras = {} - mock_app_generate_entity.trace_manager = None - - # Create runner - runner = AdvancedChatAppRunner( - application_generate_entity=mock_app_generate_entity, - queue_manager=MagicMock(), - conversation=mock_conversation, - message=mock_message, - dialogue_count=1, - variable_loader=MagicMock(), - workflow=mock_workflow, - system_user_id=str(uuid4()), - app=MagicMock(), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), +def _persist_variable(session: Session, *, variable: object, conversation_id: str = CONVERSATION_ID) -> None: + session.add( + ConversationVariable.from_variable( + app_id=APP_ID, + conversation_id=conversation_id, + variable=variable, ) + ) + session.commit() - # Mock database session - mock_session = MagicMock(spec=Session) - # First query returns only existing variable - mock_scalars_result = MagicMock() - mock_scalars_result.all.return_value = [existing_db_var] - mock_session.scalars.return_value = mock_scalars_result +@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) +def test_missing_conversation_variables_are_added(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + existing_variable = _variable(VAR_1_ID, "existing_var", "default1") + new_variable = _variable(VAR_2_ID, "new_var", "default2") + _persist_variable(sqlite_session, variable=existing_variable) + _persist_variable(sqlite_session, variable=new_variable, conversation_id=OTHER_CONVERSATION_ID) + _bind_runner_sessions(monkeypatch, sqlite_session) - # Track what gets added to session - added_items = [] + variables = _runner([existing_variable, new_variable])._initialize_conversation_variables() - def track_add_all(items): - added_items.extend(items) + assert [variable.id for variable in variables] == [VAR_1_ID, VAR_2_ID] + persisted = sqlite_session.scalars( + select(ConversationVariable) + .where(ConversationVariable.conversation_id == CONVERSATION_ID) + .order_by(ConversationVariable.id) + ).all() + assert [variable.id for variable in persisted] == [VAR_1_ID, VAR_2_ID] - mock_session.add_all.side_effect = track_add_all - # Patch the necessary components - with ( - _patch_create_session(mock_session), - patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, - patch.object(runner, "_init_graph") as mock_init_graph, - patch.object( - runner, - "handle_input_moderation", - return_value=(False, mock_app_generate_entity.inputs, mock_app_generate_entity.query), - ), - patch.object(runner, "handle_annotation_reply", return_value=False), - patch("core.app.apps.advanced_chat.app_runner.WorkflowEntry") as mock_workflow_entry_class, - patch("core.app.apps.advanced_chat.app_runner.GraphRuntimeState") as mock_graph_runtime_state_class, - patch("core.app.apps.advanced_chat.app_runner.redis_client") as mock_redis_client, - patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, - ): - # Mock GraphRuntimeState to accept the variable pool - mock_graph_runtime_state_class.return_value = MagicMock() +@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) +def test_no_variables_creates_all(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + workflow_variables = [ + _variable(VAR_1_ID, "var1", "default1"), + _variable(VAR_2_ID, "var2", "default2"), + ] + _bind_runner_sessions(monkeypatch, sqlite_session) - # Mock graph initialization - mock_init_graph.return_value = MagicMock() + variables = _runner(workflow_variables)._initialize_conversation_variables() - # Mock workflow entry - mock_workflow_entry = MagicMock() - mock_workflow_entry.run.return_value = iter([]) # Empty generator - mock_workflow_entry_class.return_value = mock_workflow_entry + assert [variable.id for variable in variables] == [VAR_1_ID, VAR_2_ID] + persisted = sqlite_session.scalars(select(ConversationVariable).order_by(ConversationVariable.id)).all() + assert [variable.id for variable in persisted] == [VAR_1_ID, VAR_2_ID] - # Run the method - runner.run() - # Verify that the missing variable was added - assert len(added_items) == 1, "Should have added exactly one missing variable" +@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) +def test_all_variables_exist_no_changes(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + workflow_variables = [ + _variable(VAR_1_ID, "var1", "default1"), + _variable(VAR_2_ID, "var2", "default2"), + ] + for variable in workflow_variables: + _persist_variable(sqlite_session, variable=variable) + _bind_runner_sessions(monkeypatch, sqlite_session) - # Check that the added item is the missing variable (var2) - added_var = added_items[0] - assert hasattr(added_var, "id"), "Added item should be a ConversationVariable" - # Note: Since we're mocking ConversationVariable.from_variable, - # we can't directly check the id, but we can verify add_all was called - assert mock_session.add_all.called, "Session add_all should have been called" + variables = _runner(workflow_variables)._initialize_conversation_variables() - def test_no_variables_creates_all(self): - """Test that all conversation variables are created when none exist in DB.""" - # Setup - app_id = str(uuid4()) - conversation_id = str(uuid4()) - workflow_id = str(uuid4()) - - # Create workflow with conversation variables - workflow_vars = [ - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var1", - "name": "var1", - "value_type": SegmentType.STRING, - "value": "default1", - } - ), - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var2", - "name": "var2", - "value_type": SegmentType.STRING, - "value": "default2", - } - ), - ] - - # Mock workflow - mock_workflow = MagicMock(spec=Workflow) - mock_workflow.conversation_variables = workflow_vars - mock_workflow.tenant_id = str(uuid4()) - mock_workflow.app_id = app_id - mock_workflow.id = workflow_id - mock_workflow.type = "chat" - mock_workflow.graph_dict = MINIMAL_GRAPH - mock_workflow.environment_variables = [] - - # Mock conversation and message - mock_conversation = MagicMock() - mock_conversation.app_id = app_id - mock_conversation.id = conversation_id - - mock_message = MagicMock() - mock_message.id = str(uuid4()) - - # Mock app config - mock_app_config = MagicMock() - mock_app_config.app_id = app_id - mock_app_config.workflow_id = workflow_id - mock_app_config.tenant_id = str(uuid4()) - - # Mock app generate entity - mock_app_generate_entity = MagicMock(spec=AdvancedChatAppGenerateEntity) - mock_app_generate_entity.app_config = mock_app_config - mock_app_generate_entity.inputs = {} - mock_app_generate_entity.query = "test query" - mock_app_generate_entity.files = [] - mock_app_generate_entity.user_id = str(uuid4()) - mock_app_generate_entity.invoke_from = InvokeFrom.SERVICE_API - mock_app_generate_entity.workflow_run_id = str(uuid4()) - mock_app_generate_entity.task_id = str(uuid4()) - mock_app_generate_entity.call_depth = 0 - mock_app_generate_entity.single_iteration_run = None - mock_app_generate_entity.single_loop_run = None - mock_app_generate_entity.extras = {} - mock_app_generate_entity.trace_manager = None - - # Create runner - runner = AdvancedChatAppRunner( - application_generate_entity=mock_app_generate_entity, - queue_manager=MagicMock(), - conversation=mock_conversation, - message=mock_message, - dialogue_count=1, - variable_loader=MagicMock(), - workflow=mock_workflow, - system_user_id=str(uuid4()), - app=MagicMock(), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), - ) - - # Mock database session - mock_session = MagicMock(spec=Session) - - # Query returns empty list (no existing variables) - mock_scalars_result = MagicMock() - mock_scalars_result.all.return_value = [] - mock_session.scalars.return_value = mock_scalars_result - - # Track what gets added to session - added_items = [] - - def track_add_all(items): - added_items.extend(items) - - mock_session.add_all.side_effect = track_add_all - - # Patch the necessary components - with ( - _patch_create_session(mock_session), - patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, - patch.object(runner, "_init_graph") as mock_init_graph, - patch.object( - runner, - "handle_input_moderation", - return_value=(False, mock_app_generate_entity.inputs, mock_app_generate_entity.query), - ), - patch.object(runner, "handle_annotation_reply", return_value=False), - patch("core.app.apps.advanced_chat.app_runner.WorkflowEntry") as mock_workflow_entry_class, - patch("core.app.apps.advanced_chat.app_runner.GraphRuntimeState") as mock_graph_runtime_state_class, - patch("core.app.apps.advanced_chat.app_runner.ConversationVariable") as mock_conv_var_class, - patch("core.app.apps.advanced_chat.app_runner.redis_client") as mock_redis_client, - patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, - ): - # Mock ConversationVariable.from_variable to return mock objects - mock_conv_vars = [] - for var in workflow_vars: - mock_cv = MagicMock() - mock_cv.id = var.id - mock_cv.to_variable.return_value = var - mock_conv_vars.append(mock_cv) - - mock_conv_var_class.from_variable.side_effect = mock_conv_vars - - # Mock GraphRuntimeState to accept the variable pool - mock_graph_runtime_state_class.return_value = MagicMock() - - # Mock graph initialization - mock_init_graph.return_value = MagicMock() - - # Mock workflow entry - mock_workflow_entry = MagicMock() - mock_workflow_entry.run.return_value = iter([]) # Empty generator - mock_workflow_entry_class.return_value = mock_workflow_entry - - # Run the method - runner.run() - - # Verify that all variables were created - assert len(added_items) == 2, "Should have added both variables" - assert mock_session.add_all.called, "Session add_all should have been called" - - def test_all_variables_exist_no_changes(self): - """Test that no changes are made when all variables already exist in DB.""" - # Setup - app_id = str(uuid4()) - conversation_id = str(uuid4()) - workflow_id = str(uuid4()) - - # Create workflow with conversation variables - workflow_vars = [ - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var1", - "name": "var1", - "value_type": SegmentType.STRING, - "value": "default1", - } - ), - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var2", - "name": "var2", - "value_type": SegmentType.STRING, - "value": "default2", - } - ), - ] - - # Mock workflow - mock_workflow = MagicMock(spec=Workflow) - mock_workflow.conversation_variables = workflow_vars - mock_workflow.tenant_id = str(uuid4()) - mock_workflow.app_id = app_id - mock_workflow.id = workflow_id - mock_workflow.type = "chat" - mock_workflow.graph_dict = MINIMAL_GRAPH - mock_workflow.environment_variables = [] - - # Create existing conversation variables (both exist in DB) - existing_db_vars = [] - for var in workflow_vars: - db_var = MagicMock(spec=ConversationVariable) - db_var.id = var.id - db_var.app_id = app_id - db_var.conversation_id = conversation_id - db_var.to_variable = MagicMock(return_value=var) - existing_db_vars.append(db_var) - - # Mock conversation and message - mock_conversation = MagicMock() - mock_conversation.app_id = app_id - mock_conversation.id = conversation_id - - mock_message = MagicMock() - mock_message.id = str(uuid4()) - - # Mock app config - mock_app_config = MagicMock() - mock_app_config.app_id = app_id - mock_app_config.workflow_id = workflow_id - mock_app_config.tenant_id = str(uuid4()) - - # Mock app generate entity - mock_app_generate_entity = MagicMock(spec=AdvancedChatAppGenerateEntity) - mock_app_generate_entity.app_config = mock_app_config - mock_app_generate_entity.inputs = {} - mock_app_generate_entity.query = "test query" - mock_app_generate_entity.files = [] - mock_app_generate_entity.user_id = str(uuid4()) - mock_app_generate_entity.invoke_from = InvokeFrom.SERVICE_API - mock_app_generate_entity.workflow_run_id = str(uuid4()) - mock_app_generate_entity.task_id = str(uuid4()) - mock_app_generate_entity.call_depth = 0 - mock_app_generate_entity.single_iteration_run = None - mock_app_generate_entity.single_loop_run = None - mock_app_generate_entity.extras = {} - mock_app_generate_entity.trace_manager = None - - # Create runner - runner = AdvancedChatAppRunner( - application_generate_entity=mock_app_generate_entity, - queue_manager=MagicMock(), - conversation=mock_conversation, - message=mock_message, - dialogue_count=1, - variable_loader=MagicMock(), - workflow=mock_workflow, - system_user_id=str(uuid4()), - app=MagicMock(), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), - ) - - # Mock database session - mock_session = MagicMock(spec=Session) - - # Query returns all existing variables - mock_scalars_result = MagicMock() - mock_scalars_result.all.return_value = existing_db_vars - mock_session.scalars.return_value = mock_scalars_result - - # Patch the necessary components - with ( - _patch_create_session(mock_session), - patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, - patch.object(runner, "_init_graph") as mock_init_graph, - patch.object( - runner, - "handle_input_moderation", - return_value=(False, mock_app_generate_entity.inputs, mock_app_generate_entity.query), - ), - patch.object(runner, "handle_annotation_reply", return_value=False), - patch("core.app.apps.advanced_chat.app_runner.WorkflowEntry") as mock_workflow_entry_class, - patch("core.app.apps.advanced_chat.app_runner.GraphRuntimeState") as mock_graph_runtime_state_class, - patch("core.app.apps.advanced_chat.app_runner.redis_client") as mock_redis_client, - patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, - ): - # Mock GraphRuntimeState to accept the variable pool - mock_graph_runtime_state_class.return_value = MagicMock() - - # Mock graph initialization - mock_init_graph.return_value = MagicMock() - - # Mock workflow entry - mock_workflow_entry = MagicMock() - mock_workflow_entry.run.return_value = iter([]) # Empty generator - mock_workflow_entry_class.return_value = mock_workflow_entry - - # Run the method - runner.run() - - # Verify that no variables were added - assert not mock_session.add_all.called, "Session add_all should not have been called" + assert [variable.id for variable in variables] == [VAR_1_ID, VAR_2_ID] + persisted = sqlite_session.scalars(select(ConversationVariable)).all() + assert len(persisted) == 2 diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py index 3fd3eee1b87..f65770665e4 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py @@ -14,7 +14,8 @@ import pytest from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError from core.app.entities.app_invoke_entities import InvokeFrom -from models.agent import AgentConfigDraftType, AgentSource +from models.agent import AgentConfigDraft, AgentConfigDraftType, AgentScope, AgentSource +from models.agent_config_entities import AgentSoulConfig _SOUL_DICT = { "model": { @@ -95,7 +96,7 @@ class TestResolveDebugDraft: created_by="creator-1", updated_by="updater-1", ) - session = _FakeScalarSession([None, SimpleNamespace(id="agent-1"), _snapshot()]) + session = _FakeScalarSession([None, _snapshot()]) draft = AgentAppGenerator._resolve_debug_draft( tenant_id="t1", @@ -110,6 +111,77 @@ class TestResolveDebugDraft: assert session.added == [draft] assert session.flush_count == 1 + def test_stale_workflow_only_shared_draft_is_rebased_to_active_snapshot(self): + agent = SimpleNamespace( + id="agent-1", + scope=AgentScope.WORKFLOW_ONLY, + active_config_snapshot_id="snap-2", + created_by="creator-1", + updated_by="updater-1", + ) + draft = AgentConfigDraft( + id="draft-1", + tenant_id="t1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id="snap-1", + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "old"}}), + ) + active_snapshot = SimpleNamespace( + id="snap-2", + config_snapshot_dict={"prompt": {"system_prompt": "new"}}, + ) + session = _FakeScalarSession([draft, active_snapshot]) + + resolved = AgentAppGenerator._resolve_debug_draft( + tenant_id="t1", + agent=agent, + draft_type=None, + account_id=None, + session=session, + ) + + assert resolved is draft + assert resolved.id == "draft-1" + assert resolved.base_snapshot_id == "snap-2" + assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "new" + assert session.flush_count == 1 + + def test_build_draft_is_not_rebased_to_active_snapshot(self): + agent = SimpleNamespace( + id="agent-1", + scope=AgentScope.WORKFLOW_ONLY, + active_config_snapshot_id="snap-2", + created_by="creator-1", + updated_by="updater-1", + ) + draft = AgentConfigDraft( + id="build-draft-1", + tenant_id="t1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id="account-1", + draft_owner_key="account-1", + base_snapshot_id="snap-1", + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "build edit"}}), + ) + session = _FakeScalarSession([draft]) + + resolved = AgentAppGenerator._resolve_debug_draft( + tenant_id="t1", + agent=agent, + draft_type=AgentConfigDraftType.DEBUG_BUILD.value, + account_id="account-1", + session=session, + ) + + assert resolved is draft + assert resolved.base_snapshot_id == "snap-1" + assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "build edit" + assert session.flush_count == 0 + class TestResolveAgent: def test_success_chains_to_resolve_by_id(self): @@ -185,9 +257,12 @@ class TestResolveAgent: def test_unpublished_imported_agent_remains_available_to_debugger(self): bound_agent = SimpleNamespace( id="agent-1", + scope=AgentScope.ROSTER, source=AgentSource.IMPORTED, active_config_snapshot_id="snap-1", active_config_is_published=False, + created_by="creator-1", + updated_by="updater-1", ) draft = SimpleNamespace(id="draft-1", draft_type="draft", config_snapshot_dict=_SOUL_DICT) session = _FakeScalarSession([bound_agent, draft]) diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py index deb9ab4d2af..dcd9c2b76af 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py @@ -2,10 +2,10 @@ from __future__ import annotations import logging from contextlib import nullcontext -from types import SimpleNamespace -from unittest.mock import MagicMock import pytest +from sqlalchemy import func, select +from sqlalchemy.orm import Session from core.app.app_config.entities import ( AdvancedChatMessageEntity, @@ -15,7 +15,13 @@ from core.app.app_config.entities import ( ) from core.app.apps.base_app_runner import AppRunner from core.app.apps.exc import GenerateTaskStoppedError -from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager +from core.app.entities.app_invoke_entities import ( + AppGenerateEntity, + EasyUIBasedAppGenerateEntity, + InvokeFrom, + ModelConfigWithCredentialsEntity, +) from core.app.entities.queue_entities import ( QueueAgentMessageEvent, QueueLLMChunkEvent, @@ -29,9 +35,9 @@ from graphon.model_runtime.entities.message_entities import ( PromptMessageRole, TextPromptMessageContent, ) -from graphon.model_runtime.entities.model_entities import ModelPropertyKey +from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelPropertyKey from graphon.model_runtime.errors.invoke import InvokeBadRequestError -from models.model import AppMode +from models.model import App, AppMode, Message, MessageFile class _DummyParameterRule: @@ -40,13 +46,29 @@ class _DummyParameterRule: self.use_template = use_template -class _QueueRecorder: - def __init__(self) -> None: - self.events: list[object] = [] +class _TokenCountingModel: + token_count: int - def publish(self, event, pub_from): - _ = pub_from - self.events.append(event) + def __init__(self, token_count: int) -> None: + self.token_count = token_count + + def get_llm_num_tokens(self, messages: list[AssistantPromptMessage]) -> int: + return self.token_count + + +def _queue_manager() -> MessageBasedAppQueueManager: + return MessageBasedAppQueueManager( + task_id="task-id", + user_id="user-id", + invoke_from=InvokeFrom.SERVICE_API, + conversation_id="conversation-id", + app_mode=AppMode.CHAT.value, + message_id="message-id", + ) + + +def _published_events(queue_manager: MessageBasedAppQueueManager) -> list[object]: + return [message.event for message in queue_manager.listen()] class _ClosableStream: @@ -70,11 +92,11 @@ class TestAppRunner: def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_schema = SimpleNamespace( + model_schema = AIModelEntity.model_construct( model_properties={ModelPropertyKey.CONTEXT_SIZE: 100}, parameter_rules=[_DummyParameterRule("max_tokens")], ) - model_config = SimpleNamespace( + model_config = ModelConfigWithCredentialsEntity.model_construct( provider_model_bundle=object(), model="mock", model_schema=model_schema, @@ -83,7 +105,7 @@ class TestAppRunner: monkeypatch.setattr( "core.app.apps.base_app_runner.ModelInstance", - lambda provider_model_bundle, model: SimpleNamespace(get_llm_num_tokens=lambda messages: 80), + lambda provider_model_bundle, model: _TokenCountingModel(80), ) runner.recalc_llm_max_tokens(model_config, prompt_messages=[AssistantPromptMessage(content="hi")]) @@ -93,11 +115,11 @@ class TestAppRunner: def test_recalc_llm_max_tokens_returns_minus_one_when_no_context(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_schema = SimpleNamespace( + model_schema = AIModelEntity.model_construct( model_properties={}, parameter_rules=[_DummyParameterRule("max_tokens")], ) - model_config = SimpleNamespace( + model_config = ModelConfigWithCredentialsEntity.model_construct( provider_model_bundle=object(), model="mock", model_schema=model_schema, @@ -106,17 +128,16 @@ class TestAppRunner: monkeypatch.setattr( "core.app.apps.base_app_runner.ModelInstance", - lambda provider_model_bundle, model: SimpleNamespace(get_llm_num_tokens=lambda messages: 10), + lambda provider_model_bundle, model: _TokenCountingModel(10), ) assert runner.recalc_llm_max_tokens(model_config, prompt_messages=[]) == -1 - def test_direct_output_streaming_publishes_chunks_and_end(self, monkeypatch: pytest.MonkeyPatch): + def test_direct_output_streaming_publishes_chunks_and_end(self): runner = AppRunner() - queue = _QueueRecorder() - app_generate_entity = SimpleNamespace(model_conf=SimpleNamespace(model="mock"), stream=True) - - monkeypatch.setattr("core.app.apps.base_app_runner.time.sleep", lambda _: None) + queue = _queue_manager() + model_config = ModelConfigWithCredentialsEntity.model_construct(model="mock") + app_generate_entity = EasyUIBasedAppGenerateEntity.model_construct(model_conf=model_config, stream=True) runner.direct_output( queue_manager=queue, @@ -126,12 +147,13 @@ class TestAppRunner: stream=True, ) - assert any(isinstance(event, QueueLLMChunkEvent) for event in queue.events) - assert isinstance(queue.events[-1], QueueMessageEndEvent) + events = _published_events(queue) + assert any(isinstance(event, QueueLLMChunkEvent) for event in events) + assert isinstance(events[-1], QueueMessageEndEvent) def test_handle_invoke_result_direct_publishes_end_event(self): runner = AppRunner() - queue = _QueueRecorder() + queue = _queue_manager() llm_result = LLMResult( model="mock", prompt_messages=[], @@ -145,11 +167,11 @@ class TestAppRunner: stream=False, ) - assert isinstance(queue.events[-1], QueueMessageEndEvent) + assert isinstance(_published_events(queue)[-1], QueueMessageEndEvent) def test_handle_invoke_result_invalid_type_raises(self): runner = AppRunner() - queue = _QueueRecorder() + queue = _queue_manager() with pytest.raises(NotImplementedError): runner._handle_invoke_result( @@ -160,7 +182,7 @@ class TestAppRunner: def test_organize_prompt_messages_simple_template(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_config = SimpleNamespace(mode="chat", stop=["STOP"]) + model_config = ModelConfigWithCredentialsEntity.model_construct(mode="chat", stop=["STOP"]) prompt_template_entity = PromptTemplateEntity( prompt_type=PromptTemplateEntity.PromptType.SIMPLE, simple_prompt_template="hello", @@ -172,7 +194,7 @@ class TestAppRunner: ) prompt_messages, stop = runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), + app_record=App(mode=AppMode.CHAT.value), model_config=model_config, prompt_template_entity=prompt_template_entity, inputs={}, @@ -185,7 +207,7 @@ class TestAppRunner: def test_organize_prompt_messages_advanced_completion_template(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_config = SimpleNamespace(mode="completion", stop=[""]) + model_config = ModelConfigWithCredentialsEntity.model_construct(mode="completion", stop=[""]) captured: dict[str, object] = {} prompt_template_entity = PromptTemplateEntity( prompt_type=PromptTemplateEntity.PromptType.ADVANCED, @@ -202,7 +224,7 @@ class TestAppRunner: monkeypatch.setattr("core.app.apps.base_app_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt) prompt_messages, stop = runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), + app_record=App(mode=AppMode.CHAT.value), model_config=model_config, prompt_template_entity=prompt_template_entity, inputs={}, @@ -218,7 +240,7 @@ class TestAppRunner: def test_organize_prompt_messages_advanced_chat_template(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_config = SimpleNamespace(mode="chat", stop=[""]) + model_config = ModelConfigWithCredentialsEntity.model_construct(mode="chat", stop=[""]) captured: dict[str, object] = {} prompt_template_entity = PromptTemplateEntity( prompt_type=PromptTemplateEntity.PromptType.ADVANCED, @@ -237,7 +259,7 @@ class TestAppRunner: monkeypatch.setattr("core.app.apps.base_app_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt) prompt_messages, stop = runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), + app_record=App(mode=AppMode.CHAT.value), model_config=model_config, prompt_template_entity=prompt_template_entity, inputs={}, @@ -254,8 +276,8 @@ class TestAppRunner: with pytest.raises(InvokeBadRequestError, match="Advanced completion prompt template is required"): runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), - model_config=SimpleNamespace(mode="completion", stop=[]), + app_record=App(mode=AppMode.CHAT.value), + model_config=ModelConfigWithCredentialsEntity.model_construct(mode="completion", stop=[]), prompt_template_entity=PromptTemplateEntity(prompt_type=PromptTemplateEntity.PromptType.ADVANCED), inputs={}, files=[], @@ -263,18 +285,16 @@ class TestAppRunner: with pytest.raises(InvokeBadRequestError, match="Advanced chat prompt template is required"): runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), - model_config=SimpleNamespace(mode="chat", stop=[]), + app_record=App(mode=AppMode.CHAT.value), + model_config=ModelConfigWithCredentialsEntity.model_construct(mode="chat", stop=[]), prompt_template_entity=PromptTemplateEntity(prompt_type=PromptTemplateEntity.PromptType.ADVANCED), inputs={}, files=[], ) - def test_handle_invoke_result_stream_routes_chunks_and_builds_message( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ): + def test_handle_invoke_result_stream_routes_chunks_and_builds_message(self, caplog: pytest.LogCaptureFixture): runner = AppRunner() - queue = _QueueRecorder() + queue = _queue_manager() image_content = ImagePromptMessageContent( url="https://example.com/image.png", format="png", mime_type="image/png" @@ -286,11 +306,9 @@ class TestAppRunner: prompt_messages=[AssistantPromptMessage(content="prompt")], delta=LLMResultChunkDelta( index=0, - message=AssistantPromptMessage.model_construct( + message=AssistantPromptMessage( content=[ - "a", - TextPromptMessageContent(data="b"), - SimpleNamespace(data="c"), + TextPromptMessageContent(data="abc"), image_content, ] ), @@ -305,21 +323,25 @@ class TestAppRunner: agent=False, ) - assert isinstance(queue.events[0], QueueLLMChunkEvent) - assert isinstance(queue.events[-1], QueueMessageEndEvent) - assert queue.events[-1].llm_result.message.content == "abc" + events = _published_events(queue) + assert isinstance(events[0], QueueLLMChunkEvent) + assert isinstance(events[-1], QueueMessageEndEvent) + assert events[-1].llm_result.message.content == "abc" assert "Received multimodal output but missing required parameters" in caplog.messages def test_handle_invoke_result_stream_agent_mode_handles_multimodal_errors( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ): runner = AppRunner() - queue = _QueueRecorder() + queue = _queue_manager() + + def raise_multimodal_error(**kwargs): + raise RuntimeError("failed to save image") monkeypatch.setattr( runner, "_handle_multimodal_image_content", - MagicMock(side_effect=RuntimeError("failed to save image")), + raise_multimodal_error, ) usage = LLMUsage.empty_usage() @@ -353,22 +375,37 @@ class TestAppRunner: tenant_id="tenant-id", ) - assert isinstance(queue.events[0], QueueAgentMessageEvent) - assert isinstance(queue.events[-1], QueueMessageEndEvent) - assert queue.events[-1].llm_result.usage == usage + events = _published_events(queue) + assert isinstance(events[0], QueueAgentMessageEvent) + assert isinstance(events[-1], QueueMessageEndEvent) + assert events[-1].llm_result.usage == usage assert "Failed to handle multimodal image output" in caplog.messages - def test_handle_invoke_result_stream_commits_message_file_before_publish(self, monkeypatch: pytest.MonkeyPatch): + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_handle_invoke_result_stream_commits_message_file_before_publish( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + ): runner = AppRunner() - runner._handle_multimodal_image_content = MagicMock(return_value="message-file-1") - session = MagicMock() + monkeypatch.setattr( + runner, + "_handle_multimodal_image_content", + lambda **kwargs: "message-file-1", + ) events: list[str] = [] - session.commit.side_effect = lambda: events.append("commit") + original_commit = sqlite_session.commit + + def commit(): + events.append("commit") + original_commit() + + monkeypatch.setattr(sqlite_session, "commit", commit) monkeypatch.setattr( "core.app.apps.base_app_runner.session_factory.create_session", - lambda: nullcontext(session), + lambda: nullcontext(sqlite_session), ) - queue = _QueueRecorder() + queue = _queue_manager() original_publish = queue.publish def publish(event, pub_from): @@ -407,7 +444,7 @@ class TestAppRunner: assert events == ["commit", "publish"] - def test_handle_invoke_result_stream_closes_generator_when_stopped(self): + def test_handle_invoke_result_stream_closes_generator_when_stopped(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() chunk = LLMResultChunk( model="stream-model", @@ -416,9 +453,8 @@ class TestAppRunner: ) stream = _ClosableStream([chunk]) - queue_manager = SimpleNamespace( - publish=MagicMock(side_effect=GenerateTaskStoppedError("stopped")), - ) + queue_manager = _queue_manager() + monkeypatch.setattr(queue_manager, "_is_stopped", lambda: True) with pytest.raises(GenerateTaskStoppedError): runner._handle_invoke_result_stream( @@ -429,7 +465,11 @@ class TestAppRunner: assert stream.closed is True - def test_handle_multimodal_image_content_fallback_return_branch(self, monkeypatch: pytest.MonkeyPatch): + @pytest.mark.parametrize("sqlite_session", [(MessageFile,)], indirect=True) + def test_handle_multimodal_image_content_fallback_return_branch( + self, + sqlite_session: Session, + ): runner = AppRunner() class _ToggleBool: @@ -442,19 +482,17 @@ class TestAppRunner: self._index += 1 return value - content = SimpleNamespace( + # The fallback is reachable only when the fields change truthiness between the guard and branch checks. + content = ImagePromptMessageContent.model_construct( url=_ToggleBool([False, False]), base64_data=_ToggleBool([True, False]), mime_type="image/png", ) - db_session = SimpleNamespace(add=MagicMock(), flush=MagicMock(), refresh=MagicMock()) - monkeypatch.setattr("core.app.apps.base_app_runner.ToolFileManager", lambda: MagicMock()) - - queue_manager = SimpleNamespace(invoke_from=InvokeFrom.SERVICE_API, publish=MagicMock()) + queue_manager = _queue_manager() runner._handle_multimodal_image_content( - session=db_session, + session=sqlite_session, content=content, message_id="message-id", user_id="user-id", @@ -462,20 +500,20 @@ class TestAppRunner: queue_manager=queue_manager, ) - db_session.add.assert_not_called() - queue_manager.publish.assert_not_called() + message_file_count = sqlite_session.scalar(select(func.count()).select_from(MessageFile)) + assert message_file_count == 0 def test_check_hosting_moderation_direct_output_called(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - queue = _QueueRecorder() - app_generate_entity = SimpleNamespace(stream=False) + queue = _queue_manager() + app_generate_entity = EasyUIBasedAppGenerateEntity.model_construct(stream=False) + direct_output_calls: list[dict[str, object]] = [] monkeypatch.setattr( "core.app.apps.base_app_runner.HostingModerationFeature.check", lambda self, application_generate_entity, prompt_messages: True, ) - direct_output = MagicMock() - monkeypatch.setattr(runner, "direct_output", direct_output) + monkeypatch.setattr(runner, "direct_output", lambda **kwargs: direct_output_calls.append(kwargs)) result = runner.check_hosting_moderation( application_generate_entity=app_generate_entity, @@ -484,7 +522,7 @@ class TestAppRunner: ) assert result is True - assert direct_output.called + assert len(direct_output_calls) == 1 def test_fill_in_inputs_from_external_data_tools(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() @@ -509,7 +547,7 @@ class TestAppRunner: "core.app.apps.base_app_runner.InputModeration.check", lambda self, app_id, tenant_id, app_config, inputs, query, message_id, trace_manager: (True, {}, ""), ) - app_generate_entity = SimpleNamespace(app_config=SimpleNamespace(), trace_manager=None) + app_generate_entity = AppGenerateEntity.model_construct(app_config=None, trace_manager=None) result = runner.moderation_for_inputs( app_id="app", @@ -522,7 +560,12 @@ class TestAppRunner: assert result == (True, {}, "") - def test_query_app_annotations_to_reply(self, monkeypatch: pytest.MonkeyPatch): + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_query_app_annotations_to_reply( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + ): runner = AppRunner() monkeypatch.setattr( "core.app.apps.base_app_runner.AnnotationReplyFeature.query", @@ -530,12 +573,12 @@ class TestAppRunner: ) response = runner.query_app_annotations_to_reply( - app_record=SimpleNamespace(), - message=SimpleNamespace(), + app_record=App(), + message=Message(), query="hello", user_id="user", invoke_from=InvokeFrom.WEB_APP, - session=MagicMock(), + session=sqlite_session, ) assert response == "reply" diff --git a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py index 613982f9c03..25b9ec4ef28 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py @@ -7,7 +7,7 @@ from pytest_mock import MockerFixture from core.plugin.endpoint.exc import EndpointSetupFailedError from core.plugin.entities.plugin_daemon import PluginDaemonInnerError from core.plugin.impl.base import PLUGIN_DAEMON_MAX_PATH_LENGTH, BasePluginClient -from core.plugin.impl.exc import PluginLLMPollingUnsupportedError +from core.plugin.impl.exc import PluginLLMPollingUnsupportedError, PluginRuntimeError from core.trigger.errors import ( EventIgnoreError, TriggerInvokeError, @@ -175,3 +175,25 @@ class TestBasePluginClientImpl: with pytest.raises(PluginLLMPollingUnsupportedError): client._handle_plugin_daemon_error("PluginInvokeError", message) + + def test_handle_plugin_daemon_error_maps_runtime_error_to_typed_exception(self): + client = BasePluginClient() + lambda_request_id = "45664803-3d3c-4d4f-93fe-e3b19e43092b" + message = json.dumps( + { + "error_type": PluginRuntimeError.__name__, + "message": ( + "Plugin runtime request failed: Runtime.ExitError: " + f"RequestId: {lambda_request_id} Error: Runtime exited with error: exit status 1" + ), + "args": {"request_id": lambda_request_id, "status_code": 200}, + } + ) + + with pytest.raises(PluginRuntimeError) as exc_info: + client._handle_plugin_daemon_error("PluginInvokeError", message) + + assert exc_info.value.description == ( + "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1" + ) + assert exc_info.value.lambda_request_id == lambda_request_id diff --git a/api/tests/unit_tests/core/plugin/test_plugin_entities.py b/api/tests/unit_tests/core/plugin/test_plugin_entities.py index deac0ba1da5..0a532646abb 100644 --- a/api/tests/unit_tests/core/plugin/test_plugin_entities.py +++ b/api/tests/unit_tests/core/plugin/test_plugin_entities.py @@ -125,6 +125,9 @@ class TestPluginParameterEntities: parameter = PluginParameter(name="p", label=self._label(), options="invalid") # type: ignore[arg-type] assert parameter.options == [] + def test_plugin_parameter_excludes_tool_specific_multiple_declaration(self): + assert "multiple" not in PluginParameter.model_fields + @pytest.mark.parametrize( ("parameter_type", "expected"), [ diff --git a/api/tests/unit_tests/core/tools/test_base_tool.py b/api/tests/unit_tests/core/tools/test_base_tool.py index 9e80e086472..f164e3fddea 100644 --- a/api/tests/unit_tests/core/tools/test_base_tool.py +++ b/api/tests/unit_tests/core/tools/test_base_tool.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from typing import Any, cast from unittest.mock import MagicMock +import pytest + from core.app.entities.app_invoke_entities import InvokeFrom from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime @@ -33,6 +35,7 @@ class DummyParameter: options: list[Any] | None = None llm_description: str | None = None input_schema: dict[str, Any] | None = None + multiple: bool = False class DummyTool(Tool): @@ -129,6 +132,26 @@ def test_invoke_supports_single_message_and_parameter_casting(): } +def test_invoke_preserves_multiple_select_values(): + tool = _build_tool() + parameter = ToolParameter.get_simple_instance( + name="choice", + llm_description="Choice", + typ=ToolParameter.ToolParameterType.SELECT, + required=True, + options=["a", "b"], + ) + parameter.multiple = True + tool.entity.parameters = [parameter] + + list(tool.invoke(session=MagicMock(), user_id="user-1", tool_parameters={"choice": ["a", "b"]})) + + assert tool.last_invocation is not None + assert tool.last_invocation["tool_parameters"] == {"choice": ["a", "b"]} + with pytest.raises(ValueError, match="must be a list"): + tool.invoke(session=MagicMock(), user_id="user-1", tool_parameters={"choice": "a"}) + + def test_invoke_supports_list_and_generator_results(): tool = _build_tool() tool.result = [tool.create_text_message("a"), tool.create_text_message("b")] @@ -214,6 +237,21 @@ def test_get_llm_parameters_json_schema_uses_effective_runtime_parameters(): required=False, options=["global", "cn"], ) + regions_parameter = ToolParameter.get_simple_instance( + name="regions", + llm_description="Search regions", + typ=ToolParameter.ToolParameterType.SELECT, + required=False, + options=["global", "cn"], + ) + regions_parameter.multiple = True + tags_parameter = ToolParameter.get_simple_instance( + name="tags", + llm_description="Search tags", + typ=ToolParameter.ToolParameterType.DYNAMIC_SELECT, + required=False, + ) + tags_parameter.multiple = True hidden_parameter = ToolParameter.get_simple_instance( name="api_key", llm_description="Hidden api key", @@ -241,7 +279,15 @@ def test_get_llm_parameters_json_schema_uses_effective_runtime_parameters(): "properties": {"nested": {"type": "string"}}, }, ) - tool.entity.parameters = [query_parameter, region_parameter, hidden_parameter, file_parameter, payload_parameter] + tool.entity.parameters = [ + query_parameter, + region_parameter, + regions_parameter, + tags_parameter, + hidden_parameter, + file_parameter, + payload_parameter, + ] query_override = ToolParameter.get_simple_instance( name="query", @@ -262,6 +308,16 @@ def test_get_llm_parameters_json_schema_uses_effective_runtime_parameters(): "description": "Search region", "enum": ["global", "cn"], }, + "regions": { + "type": "array", + "items": {"type": "string", "enum": ["global", "cn"]}, + "description": "Search regions", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Search tags", + }, "payload": { "type": "object", "properties": {"nested": {"type": "string"}}, diff --git a/api/tests/unit_tests/core/tools/test_tool_entities.py b/api/tests/unit_tests/core/tools/test_tool_entities.py index a5b7e8a9a34..82ab59752c2 100644 --- a/api/tests/unit_tests/core/tools/test_tool_entities.py +++ b/api/tests/unit_tests/core/tools/test_tool_entities.py @@ -1,5 +1,8 @@ +import pytest +from pydantic import ValidationError + from core.tools.entities.common_entities import I18nObject -from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage +from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolParameter def _make_identity() -> ToolIdentity: @@ -11,6 +14,65 @@ def _make_identity() -> ToolIdentity: ) +def _make_select_parameter(**updates: object) -> ToolParameter: + data = ToolParameter.get_simple_instance( + name="choice", + llm_description="Choice", + typ=ToolParameter.ToolParameterType.SELECT, + required=False, + options=["a", "b"], + ).model_dump() + data.update(updates) + return ToolParameter.model_validate(data) + + +@pytest.mark.parametrize( + ("updates", "message"), + [ + ({"type": ToolParameter.ToolParameterType.STRING, "multiple": True}, "multiple is only valid"), + ({"multiple": True, "default": "a"}, "default must be a list"), + ({"default": ["a"]}, "default must be a list"), + ], +) +def test_tool_parameter_rejects_invalid_multiple_declarations(updates: dict[str, object], message: str): + with pytest.raises(ValidationError, match=message): + _make_select_parameter(**updates) + + +@pytest.mark.parametrize( + "parameter_type", + [ToolParameter.ToolParameterType.SELECT, ToolParameter.ToolParameterType.DYNAMIC_SELECT], +) +def test_tool_parameter_accepts_multiple_select_declarations(parameter_type: ToolParameter.ToolParameterType): + parameter = _make_select_parameter(type=parameter_type, multiple=True, default=["a"]) + + assert parameter.multiple is True + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ("a", "must be a list"), + (["a", 1], "only strings"), + (["missing"], "not in options"), + ([], "not found in tool config"), + ], +) +def test_multiple_select_normalization_rejects_invalid_values(value: object, message: str): + parameter = _make_select_parameter(multiple=True, required=True) + + with pytest.raises(ValueError, match=message): + parameter.init_frontend_parameter(value) + + +def test_multiple_select_normalization_preserves_explicit_empty_list(): + parameter = _make_select_parameter(multiple=True, default=["a"]) + + assert parameter.init_frontend_parameter(None) == ["a"] + assert parameter.init_frontend_parameter([]) == [] + assert parameter.init_frontend_parameter(["a", "b"]) == ["a", "b"] + + def test_log_message_metadata_none_defaults_to_empty_dict(): log_message = ToolInvokeMessage.LogMessage( id="log-1", diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py index 501225fdbab..4b41e28ec8e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py @@ -6,6 +6,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from sqlalchemy import Engine +from sqlalchemy.orm import sessionmaker from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler from core.plugin.impl.exc import PluginDaemonClientSideError, PluginInvokeError @@ -26,7 +28,7 @@ from tests.workflow_test_utils import build_test_graph_init_params, build_test_v @pytest.fixture -def runtime(monkeypatch) -> DifyToolNodeRuntime: +def runtime(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> DifyToolNodeRuntime: module_name = "core.ops.ops_trace_manager" if module_name not in sys.modules: ops_stub = types.ModuleType(module_name) @@ -44,9 +46,7 @@ def runtime(monkeypatch) -> DifyToolNodeRuntime: invoke_from="debugger", call_depth=0, ) - session_maker = MagicMock() - session_maker.begin.return_value.__enter__.return_value = MagicMock(name="session") - session_maker.begin.return_value.__exit__.return_value = None + session_maker = sessionmaker(sqlite_engine, expire_on_commit=False) return DifyToolNodeRuntime(init_params.run_context, session_maker=session_maker) diff --git a/api/tests/unit_tests/core/workflow/test_human_input_forms.py b/api/tests/unit_tests/core/workflow/test_human_input_forms.py index c84c7d578be..8d8c7d4ea7b 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_forms.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_forms.py @@ -1,6 +1,7 @@ -from types import SimpleNamespace +from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from core.workflow.human_input_forms import ( load_form_dispositions_by_form_id, @@ -11,19 +12,24 @@ from core.workflow.human_input_policy import ( HumanInputSurface, disposition_for_surface, ) -from models.human_input import RecipientType +from models.human_input import HumanInputFormRecipient, RecipientType + +TABLES = (HumanInputFormRecipient,) -class _FakeSession: - def __init__(self, recipients: list[SimpleNamespace]) -> None: - self._recipients = recipients - - def scalars(self, _stmt): - return self._recipients +def _recipient(form_id: str, recipient_type: RecipientType, access_token: str) -> HumanInputFormRecipient: + return HumanInputFormRecipient( + form_id=form_id, + delivery_id=str(uuid4()), + recipient_type=recipient_type, + recipient_payload="{}", + access_token=access_token, + ) -def _recipient(form_id: str, recipient_type: RecipientType, access_token: str | None) -> SimpleNamespace: - return SimpleNamespace(form_id=form_id, recipient_type=recipient_type, access_token=access_token) +def _persist_recipients(session: Session, recipients: list[HumanInputFormRecipient]) -> None: + session.add_all(recipients) + session.commit() @pytest.mark.parametrize( @@ -35,60 +41,75 @@ def _recipient(form_id: str, recipient_type: RecipientType, access_token: str | (HumanInputSurface.SERVICE_API, "web-token"), ], ) -def test_load_form_tokens_picks_token_for_surface(surface, expected_token) -> None: - session = _FakeSession( +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_form_tokens_picks_token_for_surface(surface, expected_token, sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, [ _recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"), _recipient("form-1", RecipientType.CONSOLE, "console-token"), _recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"), - ] + _recipient("form-2", RecipientType.BACKSTAGE, "decoy-token"), + ], ) - assert load_form_tokens_by_form_id(["form-1"], session=session, surface=surface) == {"form-1": expected_token} + assert load_form_tokens_by_form_id(["form-1"], session=sqlite_session, surface=surface) == { + "form-1": expected_token + } -def test_load_form_tokens_drops_forms_without_actionable_token() -> None: - session = _FakeSession( +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_form_tokens_drops_forms_without_actionable_token(sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, [ _recipient("form-1", RecipientType.EMAIL_MEMBER, "email-token"), - _recipient("form-1", RecipientType.CONSOLE, None), - ] + _recipient("form-1", RecipientType.CONSOLE, ""), + ], ) - assert load_form_tokens_by_form_id(["form-1"], session=session) == {} + assert load_form_tokens_by_form_id(["form-1"], session=sqlite_session) == {} -def test_load_form_tokens_service_api_surface_uses_web_token() -> None: - session = _FakeSession( +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_form_tokens_service_api_surface_uses_web_token(sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, [ _recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"), _recipient("form-1", RecipientType.CONSOLE, "console-token"), _recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"), - ] + ], ) - assert load_form_tokens_by_form_id(["form-1"], session=session, surface=HumanInputSurface.SERVICE_API) == { + assert load_form_tokens_by_form_id(["form-1"], session=sqlite_session, surface=HumanInputSurface.SERVICE_API) == { "form-1": "web-token" } -def test_load_dispositions_openapi_webapp_form_is_resumable() -> None: - session = _FakeSession( +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_dispositions_openapi_webapp_form_is_resumable(sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, [ _recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"), _recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"), - ] + ], ) - assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == { + assert load_form_dispositions_by_form_id(["form-1"], session=sqlite_session, surface=HumanInputSurface.OPENAPI) == { "form-1": FormDisposition(form_token="web-token", approval_channels=["console"]) } -def test_load_dispositions_openapi_backstage_only_form_yields_channels_not_token() -> None: - session = _FakeSession([_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token")]) +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_dispositions_openapi_backstage_only_form_yields_channels_not_token(sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, + [_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token")], + ) - assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == { + assert load_form_dispositions_by_form_id(["form-1"], session=sqlite_session, surface=HumanInputSurface.OPENAPI) == { "form-1": FormDisposition(form_token=None, approval_channels=["console"]) } diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 6190c7fb91c..adfd7ed2c5f 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -1,8 +1,12 @@ +from collections.abc import Iterator +from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock, Mock, sentinel from uuid import uuid4 import pytest +from sqlalchemy import Engine, event +from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom from core.app.file_access import FileAccessScope, bind_file_access_scope, grant_retriever_segment_access @@ -44,9 +48,62 @@ from graphon.model_runtime.model_providers.base.large_language_model import Larg from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType from graphon.variables.segments import ArrayFileSegment, FileSegment +from models.base import TypeBase +from models.dataset import SegmentAttachmentBinding +from models.enums import CreatorUserRole +from models.model import StorageType, UploadFile from tests.workflow_test_utils import build_test_run_context +@pytest.fixture +def attachment_session(sqlite_engine: Engine) -> Iterator[Session]: + """Provide real attachment and upload-file persistence to node runtime tests.""" + + TypeBase.metadata.create_all(sqlite_engine, tables=[SegmentAttachmentBinding.__table__, UploadFile.__table__]) + with Session(sqlite_engine, expire_on_commit=False) as session: + yield session + + +def _persist_attachment( + session: Session, + *, + segment_id: str, + upload_file_id: str, + upload_file_tenant_id: str = "tenant-id", +) -> UploadFile: + """Persist an attachment binding for the test tenant and its referenced upload file.""" + + upload_file = UploadFile( + tenant_id=upload_file_tenant_id, + storage_type=StorageType.LOCAL, + key="storage-key", + name="diagram.png", + size=128, + extension="png", + mime_type="image/png", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="user-id", + created_at=datetime.now(UTC).replace(tzinfo=None), + used=False, + source_url="https://example.com/diagram.png", + ) + upload_file.id = upload_file_id + session.add_all( + [ + upload_file, + SegmentAttachmentBinding( + tenant_id="tenant-id", + dataset_id="dataset-id", + document_id="document-id", + segment_id=segment_id, + attachment_id=upload_file_id, + ), + ] + ) + session.commit() + return upload_file + + def _build_model_schema(*, features: list[ModelFeature] | None = None) -> AIModelEntity: return AIModelEntity( model="gpt-4o-mini", @@ -348,29 +405,12 @@ def test_dify_prompt_message_serializer_delegates(monkeypatch: pytest.MonkeyPatc ) -def test_dify_retriever_attachment_loader_builds_graph_files(monkeypatch: pytest.MonkeyPatch) -> None: - upload_file = SimpleNamespace( - id="upload-file-id", - name="diagram.png", - extension="png", - mime_type="image/png", - source_url="https://example.com/diagram.png", - key="storage-key", - size=128, - ) - session = MagicMock() - session.execute.return_value.all.return_value = [(None, upload_file)] - - class _SessionContext: - def __enter__(self): - return session - - def __exit__(self, exc_type, exc, tb): - return False - +def test_dify_retriever_attachment_loader_builds_graph_files( + monkeypatch: pytest.MonkeyPatch, attachment_session: Session +) -> None: + _persist_attachment(attachment_session, segment_id="segment-id", upload_file_id="upload-file-id") build_from_mapping = MagicMock(return_value=sentinel.file) - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(node_runtime, "Session", MagicMock(return_value=_SessionContext())) + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=attachment_session.get_bind())) loader = DifyRetrieverAttachmentLoader( file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) ) @@ -388,39 +428,18 @@ def test_dify_retriever_attachment_loader_builds_graph_files(monkeypatch: pytest def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segment( monkeypatch: pytest.MonkeyPatch, + attachment_session: Session, ) -> None: from factories.file_factory import builders as file_builders upload_file_id = str(uuid4()) segment_id = str(uuid4()) - upload_file = SimpleNamespace( - id=upload_file_id, - tenant_id="tenant-id", - name="diagram.png", - extension="png", - mime_type="image/png", - source_url="https://example.com/diagram.png", - key="storage-key", - size=128, - ) - attachment_session = MagicMock() - attachment_session.execute.return_value.all.return_value = [(None, upload_file)] - - class _AttachmentSessionContext: - def __enter__(self): - return attachment_session - - def __exit__(self, exc_type, exc, tb): - return False - - upload_session = MagicMock() - upload_session.__enter__.return_value = upload_session - upload_session.__exit__.return_value = False - upload_session.scalar.return_value = upload_file - - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(node_runtime, "Session", MagicMock(return_value=_AttachmentSessionContext())) - monkeypatch.setattr(file_builders, "session_factory", SimpleNamespace(create_session=lambda: upload_session)) + _persist_attachment(attachment_session, segment_id=segment_id, upload_file_id=upload_file_id) + engine = attachment_session.get_bind() + assert engine is not None + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + session_maker = sessionmaker(engine, expire_on_commit=False) + monkeypatch.setattr(file_builders.session_factory, "create_session", session_maker) loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context())) scope = FileAccessScope( @@ -435,18 +454,57 @@ def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segmen files = loader.load(segment_id=segment_id) assert files[0].related_id == upload_file_id - stmt = upload_session.scalar.call_args.args[0] - whereclause = str(stmt.whereclause) - assert "upload_files.tenant_id" in whereclause - assert "upload_files.id IN" in whereclause + assert files[0].filename == "diagram.png" + + +def test_dify_retriever_attachment_loader_rejects_granted_upload_file_from_another_tenant( + monkeypatch: pytest.MonkeyPatch, + attachment_session: Session, +) -> None: + from factories.file_factory import builders as file_builders + + upload_file_id = str(uuid4()) + segment_id = str(uuid4()) + _persist_attachment( + attachment_session, + segment_id=segment_id, + upload_file_id=upload_file_id, + upload_file_tenant_id="other-tenant-id", + ) + engine = attachment_session.get_bind() + assert engine is not None + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + monkeypatch.setattr(file_builders.session_factory, "create_session", sessionmaker(engine, expire_on_commit=False)) + + loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context())) + scope = FileAccessScope( + tenant_id="tenant-id", + user_id="end-user-id", + user_from=UserFrom.END_USER, + invoke_from=InvokeFrom.WEB_APP, + ) + + with bind_file_access_scope(scope): + grant_retriever_segment_access([segment_id]) + with pytest.raises(ValueError, match="Invalid upload file"): + loader.load(segment_id=segment_id) def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user( monkeypatch: pytest.MonkeyPatch, + attachment_session: Session, ) -> None: build_from_mapping = MagicMock() - session_factory = MagicMock() - monkeypatch.setattr(node_runtime, "Session", session_factory) + engine = attachment_session.get_bind() + assert engine is not None + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + statement_count = 0 + + def count_statements(*_args, **_kwargs) -> None: + nonlocal statement_count + statement_count += 1 + + event.listen(engine, "before_cursor_execute", count_statements) loader = DifyRetrieverAttachmentLoader( file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) ) @@ -460,19 +518,31 @@ def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user( with bind_file_access_scope(scope): files = loader.load(segment_id=str(uuid4())) - assert files == [] - session_factory.assert_not_called() - build_from_mapping.assert_not_called() + try: + assert files == [] + assert statement_count == 0 + build_from_mapping.assert_not_called() + finally: + event.remove(engine, "before_cursor_execute", count_statements) def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker( monkeypatch: pytest.MonkeyPatch, + attachment_session: Session, ) -> None: segment_id = str(uuid4()) build_from_mapping = MagicMock() - session_factory = MagicMock() segment_access_checker = MagicMock(return_value=False) - monkeypatch.setattr(node_runtime, "Session", session_factory) + engine = attachment_session.get_bind() + assert engine is not None + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + statement_count = 0 + + def count_statements(*_args, **_kwargs) -> None: + nonlocal statement_count + statement_count += 1 + + event.listen(engine, "before_cursor_execute", count_statements) loader = DifyRetrieverAttachmentLoader( file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping), segment_access_checker=segment_access_checker, @@ -488,10 +558,13 @@ def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker( grant_retriever_segment_access([segment_id]) files = loader.load(segment_id=segment_id) - assert files == [] - segment_access_checker.assert_called_once_with(segment_id) - session_factory.assert_not_called() - build_from_mapping.assert_not_called() + try: + assert files == [] + segment_access_checker.assert_called_once_with(segment_id) + assert statement_count == 0 + build_from_mapping.assert_not_called() + finally: + event.remove(engine, "before_cursor_execute", count_statements) def test_dify_tool_file_manager_resolves_conversation_id_for_tool_files(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/unit_tests/dev/test_generate_knowledge_fs_contract.py b/api/tests/unit_tests/dev/test_generate_knowledge_fs_contract.py index 10dfabbd90a..5123f45524c 100644 --- a/api/tests/unit_tests/dev/test_generate_knowledge_fs_contract.py +++ b/api/tests/unit_tests/dev/test_generate_knowledge_fs_contract.py @@ -2,6 +2,7 @@ import json import os +import re import subprocess import sys from pathlib import Path @@ -10,8 +11,12 @@ from typing import cast import pytest from dev import generate_knowledge_fs_contract as contract_validator -from dev.generate_knowledge_fs_contract import ContractDeclaration, validate_declarations -from services.knowledge_fs_proxy import KNOWLEDGE_FS_CONSOLE_OPERATIONS, KnowledgeFSOperation +from dev.generate_knowledge_fs_contract import ( + ContractDeclaration, + filter_openapi_document, + validate_declarations, +) +from services.knowledge_fs_operations import KNOWLEDGE_FS_CONSOLE_OPERATIONS, KnowledgeFSOperation def test_contract_cli_updates_checks_and_detects_openapi_drift(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -55,7 +60,8 @@ def test_contract_cli_updates_checks_and_detects_openapi_drift(tmp_path: Path, m ) ) monkeypatch.setattr(contract_validator, "LOCK_PATH", lock_path) - monkeypatch.setenv("PATH", f"{executable_directory}{os.pathsep}{os.environ['PATH']}") + current_path = os.environ.get("PATH", os.defpath) + monkeypatch.setenv("PATH", f"{executable_directory}{os.pathsep}{current_path}") monkeypatch.setattr( sys, @@ -106,7 +112,7 @@ def test_contract_script_loads_runtime_registry_outside_api_directory(tmp_path: text=True, ) - assert result.stdout.strip() == "2" + assert result.stdout.strip() == str(len(KNOWLEDGE_FS_CONSOLE_OPERATIONS)) def test_validate_declarations_accepts_matching_contract() -> None: @@ -131,43 +137,157 @@ def test_validate_declarations_accepts_matching_contract() -> None: ) -def test_console_operation_registry_matches_contract() -> None: +def test_filter_openapi_document_keeps_only_declared_operations_and_referenced_schemas() -> None: list_route = operation("knowledge-spaces:read", "listKnowledgeSpaces") - create_route = operation("knowledge-spaces:write", "createKnowledgeSpace") - for route in (list_route, create_route): - route["parameters"] = [{"in": "header", "name": "X-Trace-Id"}] - route["responses"] = { - "200": { - "content": {"application/json": {}}, - "headers": {"X-Trace-Id": {}}, - } - } - - validate_declarations( - { - "paths": { - "/knowledge-spaces": { - "get": list_route, - "post": create_route, + list_route["responses"] = { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/KnowledgeSpaceList"}, } } + } + } + document = { + "openapi": "3.1.0", + "paths": { + "/knowledge-spaces": { + "get": list_route, + "post": operation("knowledge-spaces:write", "createKnowledgeSpace"), + }, + "/health": {"get": operation(None, "getHealth", security=[])}, }, + "components": { + "schemas": { + "KnowledgeSpaceList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"$ref": "#/components/schemas/KnowledgeSpace"}, + } + }, + }, + "KnowledgeSpace": {"type": "object"}, + "Unused": {"type": "object"}, + }, + "securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer"}}, + }, + } + + filtered = filter_openapi_document(document, (declaration(),)) + + assert set(filtered["paths"]) == {"/knowledge-spaces"} + assert set(filtered["paths"]["/knowledge-spaces"]) == {"get"} + assert set(filtered["components"]["schemas"]) == { + "ConsoleProxyError", + "KnowledgeSpaceList", + "KnowledgeSpace", + } + assert filtered["components"]["securitySchemes"] == document["components"]["securitySchemes"] + + +def test_filter_openapi_document_keeps_sse_for_streaming_orpc_contracts() -> None: + json_declaration = declaration() + stream_declaration = declaration( + operation_id="streamTask", + path="tasks/{id}/events", + response_kind="stream", + response_media_types=("text/event-stream",), + ) + json_operation = operation("knowledge-spaces:read", "listKnowledgeSpaces") + stream_operation = operation( + "knowledge-spaces:read", + "streamTask", + responses={"200": {"content": {"text/event-stream": {"schema": {"$ref": "#/components/schemas/TaskEvent"}}}}}, + ) + + filtered = filter_openapi_document( + { + "paths": { + "/knowledge-spaces": {"get": json_operation}, + "/tasks/{id}/events": {"get": stream_operation}, + }, + "components": {"schemas": {"TaskEvent": {"type": "object"}}}, + }, + (json_declaration, stream_declaration), + ) + + assert set(filtered["paths"]) == {"/knowledge-spaces", "/tasks/{id}/events"} + assert filtered["components"]["schemas"]["TaskEvent"] == {"type": "object"} + + +def test_filter_openapi_document_rewrites_proxy_error_responses() -> None: + route = operation("knowledge-spaces:read", "listKnowledgeSpaces") + route["responses"] = { + "200": {"content": {"application/json": {}}}, + "401": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, + "403": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}}, + } + document = { + "paths": {"/knowledge-spaces": {"get": route}}, + "components": {"schemas": {"ErrorResponse": {"type": "object"}}}, + } + + filtered = filter_openapi_document( + document, + (declaration(error_status_map=((401, 502), (403, 403))),), + ) + + responses = filtered["paths"]["/knowledge-spaces"]["get"]["responses"] + assert "401" not in responses + assert responses["403"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ConsoleProxyError" + } + assert responses["502"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ConsoleProxyError" + } + assert filtered["components"]["schemas"]["ConsoleProxyError"]["required"] == ["code", "message", "status"] + + +def test_console_operation_registry_matches_contract() -> None: + validate_declarations( + console_registry_document(), tuple(_contract_declaration(operation) for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS), ) +def test_generated_contract_metadata_matches_current_pin_and_registry() -> None: + metadata = ( + contract_validator.WORKSPACE_ROOT / "packages/contracts/generated/knowledge-fs/metadata.gen.ts" + ).read_text() + lock = json.loads(contract_validator.LOCK_PATH.read_text()) + + assert _metadata_string(metadata, "knowledgeFsSourceOpenapiSha256") == lock["openapiSha256"] + assert _metadata_string( + metadata, + "knowledgeFsConsoleDeclarationsSha256", + ) == contract_validator.contract_declarations_sha256(contract_validator.console_contract_declarations()) + + +def _metadata_string(source: str, export_name: str) -> str: + match = re.search(rf"export const {export_name}\s*=\s*'([0-9a-f]{{64}})'", source) + assert match is not None, f"missing generated metadata export: {export_name}" + return match.group(1) + + def console_registry_document() -> dict[str, object]: - list_route = operation("knowledge-spaces:read", "listKnowledgeSpaces") - create_route = operation("knowledge-spaces:write", "createKnowledgeSpace") - for route in (list_route, create_route): - route["parameters"] = [{"in": "header", "name": "X-Trace-Id"}] - route["responses"] = { - "200": { - "content": {"application/json": {}}, - "headers": {"X-Trace-Id": {}}, - } - } - return {"paths": {"/knowledge-spaces": {"get": list_route, "post": create_route}}} + paths: dict[str, dict[str, object]] = {} + for console_operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS: + route = operation( + console_operation.required_scope, + console_operation.operation_id, + parameters=[{"in": "header", "name": name} for name in console_operation.request_headers], + responses={ + "200": { + "content": {media_type: {} for media_type in console_operation.response_media_types}, + "headers": {name: {} for name in console_operation.response_headers}, + } + }, + ) + route["x-knowledge-fs-max-response-bytes"] = console_operation.max_response_bytes + paths.setdefault(f"/{console_operation.path}", {})[console_operation.method.lower()] = route + return {"paths": paths} @pytest.mark.parametrize( @@ -326,6 +446,7 @@ def declaration(**overrides: object) -> ContractDeclaration: "request_headers": (), "response_headers": (), "response_media_types": ("application/json",), + "error_status_map": ((401, 502), (403, 403)), } value.update(overrides) return cast(ContractDeclaration, value) @@ -342,6 +463,7 @@ def _contract_declaration(operation: KnowledgeFSOperation) -> ContractDeclaratio "request_headers": operation.request_headers, "response_headers": operation.response_headers, "response_media_types": operation.response_media_types, + "error_status_map": operation.error_status_map, } diff --git a/api/tests/unit_tests/extensions/storage/test_aws_s3_storage.py b/api/tests/unit_tests/extensions/storage/test_aws_s3_storage.py new file mode 100644 index 00000000000..acb3c7c9449 --- /dev/null +++ b/api/tests/unit_tests/extensions/storage/test_aws_s3_storage.py @@ -0,0 +1,27 @@ +from unittest.mock import MagicMock + +from extensions.storage.aws_s3_storage import AwsS3Storage + + +def test_generate_presigned_url() -> None: + storage = AwsS3Storage.__new__(AwsS3Storage) + storage.bucket_name = "test-bucket" + storage.client = MagicMock() + storage.client.generate_presigned_url.return_value = "https://s3.example.com/icon.png?signature=test" + + result = storage.generate_presigned_url( + "upload_files/tenant/icon.png", + expires_in=300, + content_type="image/png", + ) + + assert result == "https://s3.example.com/icon.png?signature=test" + storage.client.generate_presigned_url.assert_called_once_with( + "get_object", + Params={ + "Bucket": "test-bucket", + "Key": "upload_files/tenant/icon.png", + "ResponseContentType": "image/png", + }, + ExpiresIn=300, + ) diff --git a/api/tests/unit_tests/libs/test_external_api.py b/api/tests/unit_tests/libs/test_external_api.py index 7ebdf5f60eb..77ebe6fe351 100644 --- a/api/tests/unit_tests/libs/test_external_api.py +++ b/api/tests/unit_tests/libs/test_external_api.py @@ -4,6 +4,7 @@ from werkzeug.exceptions import BadRequest, Unauthorized from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN from core.errors.error import AppInvokeQuotaExceededError +from core.plugin.impl.exc import PluginRuntimeError from libs.exception import BaseHTTPException from libs.external_api import ExternalApi from libs.rate_limit import _BearerRateLimited @@ -39,6 +40,14 @@ def _create_api_app(): def get(self): raise RuntimeError("oops") + @api.route("/plugin-runtime-error") + class PluginRuntime(Resource): + def get(self): + raise PluginRuntimeError( + "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1", + lambda_request_id="lambda-request-id", + ) + # Note: We avoid altering default_mediatype to keep normal error paths # Special 400 message rewrite @@ -107,6 +116,24 @@ def test_external_api_json_message_and_bad_request_rewrite(): assert res.get_json()["message"] == "Invalid JSON payload received or JSON payload is empty." +def test_external_api_plugin_runtime_error(mocker): + mocker.patch("libs.external_api.get_request_id", return_value="api-request-id") + app = _create_api_app() + + res = app.test_client().get("/api/plugin-runtime-error") + + assert res.status_code == 502 + assert res.get_json() == { + "code": "plugin_runtime_error", + "message": "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1", + "details": { + "request_id": "api-request-id", + "lambda_request_id": "lambda-request-id", + }, + "status": 502, + } + + def test_external_api_param_mapping_and_quota(): app = _create_api_app() client = app.test_client() diff --git a/api/tests/unit_tests/libs/test_helper.py b/api/tests/unit_tests/libs/test_helper.py index 1a93dbbca13..dbc2cd6cba8 100644 --- a/api/tests/unit_tests/libs/test_helper.py +++ b/api/tests/unit_tests/libs/test_helper.py @@ -2,7 +2,7 @@ from datetime import datetime import pytest -from libs.helper import OptionalTimestampField, escape_like_pattern, extract_tenant_id +from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id from models.account import Account from models.model import EndUser @@ -126,3 +126,30 @@ class TestEscapeLikePattern: result = escape_like_pattern("test\\%_value") # Should be: test\\\%\_value assert result == "test\\\\\\%\\_value" + + +class TestEmailValidator: + """Tests for the email() validator — regression for #39234.""" + + def test_valid_email_accepted(self): + assert email("user@example.com") == "user@example.com" + + def test_trailing_newline_rejected(self): + with pytest.raises(ValueError, match="not a valid email"): + email("user@example.com\n") + + def test_trailing_carriage_return_newline_rejected(self): + with pytest.raises(ValueError, match="not a valid email"): + email("user@example.com\r\n") + + def test_multiple_newlines_rejected(self): + with pytest.raises(ValueError, match="not a valid email"): + email("user@example.com\n\n") + + def test_empty_string_rejected(self): + with pytest.raises(ValueError, match="not a valid email"): + email("") + + def test_invalid_email_rejected(self): + with pytest.raises(ValueError, match="not a valid email"): + email("not-an-email") diff --git a/api/tests/unit_tests/models/test_agent_config_entities.py b/api/tests/unit_tests/models/test_agent_config_entities.py index 5538a1981de..d95b7130f5c 100644 --- a/api/tests/unit_tests/models/test_agent_config_entities.py +++ b/api/tests/unit_tests/models/test_agent_config_entities.py @@ -139,6 +139,23 @@ def test_declared_output_child_validates_shape_and_defaults() -> None: ) +def test_declared_output_child_schema_matches_nullable_serialization() -> None: + config = DeclaredOutputConfig( + name="response", + type=DeclaredOutputType.OBJECT, + children=[DeclaredOutputChildConfig(name="text", type=DeclaredOutputType.STRING)], + ) + child = config.model_dump(mode="json")["children"][0] + + assert child["file"] is None + assert child["array_item"] is None + + children_schema = DeclaredOutputConfig.model_json_schema(mode="serialization")["properties"]["children"] + child_properties = children_schema["items"]["properties"] + assert {"type": "null"} in child_properties["file"]["anyOf"] + assert {"type": "null"} in child_properties["array_item"]["anyOf"] + + def test_declared_output_validates_shape_and_defaults() -> None: file_output = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE) assert file_output.file is not None 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 eb8f87fc1a9..06f42b47542 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -1271,11 +1271,32 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch tenant_id="tenant-1", agent_id="inline-agent-1", version=2, + config_snapshot=AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai/openai", + "model_provider": "openai", + "model": "gpt-4o", + }, + "prompt": {"system_prompt": "new"}, + } + ), + ) + normal_draft = AgentConfigDraft( + id="draft-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id="inline-version-1", + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "old"}}), ) 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) + monkeypatch.setattr(AgentComposerService, "_get_agent_draft", lambda **kwargs: normal_draft) binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", @@ -1320,6 +1341,95 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch 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" + assert normal_draft.id == "draft-1" + assert normal_draft.base_snapshot_id == "inline-version-2" + assert normal_draft.config_snapshot_dict == next_snapshot.config_snapshot_dict + assert normal_draft.updated_by == "account-1" + + +def test_get_or_create_normal_agent_draft_rebases_stale_workflow_only_draft(): + agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Inline", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-version-2", + created_by="account-1", + updated_by="account-2", + ) + draft = AgentConfigDraft( + id="draft-1", + tenant_id="tenant-1", + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id="inline-version-1", + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "old"}}), + ) + active_snapshot = AgentConfigSnapshot( + id="inline-version-2", + tenant_id="tenant-1", + agent_id=agent.id, + version=2, + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "new"}}), + ) + session = FakeSession(scalar=[draft, active_snapshot]) + + resolved = AgentComposerService.get_or_create_normal_agent_draft( + session=session, + tenant_id="tenant-1", + agent=agent, + created_by="account-2", + ) + + assert resolved is draft + assert resolved.id == "draft-1" + assert resolved.base_snapshot_id == "inline-version-2" + assert resolved.config_snapshot_dict == active_snapshot.config_snapshot_dict + assert resolved.updated_by == "account-2" + assert session.flushes == 1 + + +def test_get_or_create_normal_agent_draft_keeps_roster_draft_edits(): + agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Roster", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-2", + ) + draft = AgentConfigDraft( + id="draft-1", + tenant_id="tenant-1", + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id="version-1", + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "local edit"}}), + ) + session = FakeSession(scalar=[draft]) + + resolved = AgentComposerService.get_or_create_normal_agent_draft( + session=session, + tenant_id="tenant-1", + agent=agent, + created_by="account-1", + ) + + assert resolved is draft + assert resolved.base_snapshot_id == "version-1" + assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "local edit" + assert session.flushes == 0 def test_node_job_only_switches_roster_binding_to_inline_agent(monkeypatch: pytest.MonkeyPatch): @@ -2624,7 +2734,7 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch monkeypatch.setattr( AgentRosterService, "_get_or_create_agent_app_debug_conversation", - lambda self, *, agent, account_id: "debug-conversation-1", + lambda self, *, agent, account_id, draft_type: "debug-conversation-1", ) payload = roster_service.RosterAgentCreatePayload( name="Analyst", @@ -2730,6 +2840,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): assert created_mapping.tenant_id == "tenant-1" assert created_mapping.agent_id == "agent-1" assert created_mapping.account_id == "account-1" + assert created_mapping.draft_type == AgentConfigDraftType.DEBUG_BUILD assert create_session.commits == 1 existing_mapping = AgentDebugConversation( @@ -2737,6 +2848,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): agent_id="agent-1", app_id="app-1", account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, conversation_id="existing-conversation", ) reuse_session = FakeSession(scalar=[agent, existing_mapping, "existing-conversation"]) @@ -2754,6 +2866,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): agent_id="agent-1", app_id="app-1", account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, conversation_id="deleted-conversation", ) recreate_session = FakeSession(scalar=[agent, stale_mapping, None]) @@ -2768,6 +2881,42 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): assert recreate_session.commits == 1 +def test_agent_app_debug_conversations_are_isolated_by_draft_type(): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + app_id="app-1", + name="Analyst", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + ) + session = FakeSession(scalar=[agent, None, agent, None]) + service = AgentRosterService(session) + + build_conversation_id = service.get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, + ) + preview_conversation_id = service.get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + draft_type=AgentConfigDraftType.DRAFT, + ) + + mappings = [value for value in session.added if isinstance(value, AgentDebugConversation)] + assert build_conversation_id != preview_conversation_id + assert {mapping.draft_type for mapping in mappings} == { + AgentConfigDraftType.DRAFT, + AgentConfigDraftType.DEBUG_BUILD, + } + + def test_agent_app_debug_conversation_message_count(): session = FakeSession(scalar=[3]) @@ -2794,6 +2943,7 @@ def test_agent_app_debug_conversation_requires_app_binding(): AgentRosterService(FakeSession())._get_or_create_agent_app_debug_conversation( agent=agent, account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) @@ -2840,7 +2990,9 @@ def test_load_or_create_agent_app_debug_conversations_supports_runtime_backed_ag assert result["agent-1"] assert result["agent-3"] assert fake_session.commits == 1 - assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 2 + mappings = [value for value in fake_session.added if isinstance(value, AgentDebugConversation)] + assert len(mappings) == 2 + assert all(mapping.draft_type == AgentConfigDraftType.DEBUG_BUILD for mapping in mappings) def test_agent_app_visible_versions_exclude_draft_saves(): @@ -3276,6 +3428,7 @@ class TestAgentAppBackingAgent: assert mappings[0].agent_id == "agent-1" assert mappings[0].app_id == "app-1" assert mappings[0].account_id == "account-1" + assert mappings[0].draft_type == AgentConfigDraftType.DEBUG_BUILD assert mappings[0].conversation_id == conversation_id assert session.deleted == [] assert session.commits == 1 @@ -3361,8 +3514,10 @@ class TestAgentAppBackingAgent: payload = cleanup_delay.call_args.args[0] assert payload["metadata"]["conversation_id"] == "old-conversation" assert payload["metadata"]["agent_id"] == "agent-9" + assert payload["metadata"]["draft_type"] == "debug_build" assert ( - payload["idempotency_key"] == "tenant-1:agent-1:account-1:old-conversation:debug-session-cleanup:" + payload["idempotency_key"] + == "tenant-1:agent-1:account-1:debug_build:old-conversation:debug-session-cleanup:" "agent-9:snap-9:run-old" ) cleanup_store.mark_cleaned.assert_called_once_with( diff --git a/api/tests/unit_tests/services/data_migration/test_export_service.py b/api/tests/unit_tests/services/data_migration/test_export_service.py index 5479de5ba22..dbb05386cb3 100644 --- a/api/tests/unit_tests/services/data_migration/test_export_service.py +++ b/api/tests/unit_tests/services/data_migration/test_export_service.py @@ -1,7 +1,8 @@ -from unittest.mock import MagicMock - import pytest +from sqlalchemy import event +from sqlalchemy.orm import Session +from models.tools import MCPToolProvider from services.data_migration.dependency_discovery_service import DiscoveredDependency from services.data_migration.entities import ( ConflictStrategy, @@ -12,6 +13,10 @@ from services.data_migration.entities import ( ) from services.data_migration.export_service import ExportConfigParser, MigrationExportService +_TENANT_ID = "11111111-1111-1111-1111-111111111111" +_OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222" +_USER_ID = "33333333-3333-3333-3333-333333333333" + def test_export_config_parser_accepts_new_scripted_shape(): selection = ExportConfigParser().parse( @@ -121,7 +126,8 @@ def test_secret_free_api_tool_export_uses_masking_and_omits_credentials(monkeypa assert report_items[0].resource_type == ResourceType.API_TOOL -def test_secret_free_mcp_dependencies_are_dependency_only(): +@pytest.mark.parametrize("sqlite_session", [()], indirect=True) +def test_secret_free_mcp_dependencies_are_dependency_only(sqlite_session: Session): service = MigrationExportService() dependencies: list[dict] = [] mcp_tools: list[dict] = [] @@ -134,9 +140,10 @@ def test_secret_free_mcp_dependencies_are_dependency_only(): exported_mcp_tools=mcp_tools, dependencies=dependencies, report_items=report_items, - session=MagicMock(), + session=sqlite_session, ) + assert not sqlite_session.in_transaction() assert mcp_tools == [] assert dependencies == [ { @@ -150,17 +157,36 @@ def test_secret_free_mcp_dependencies_are_dependency_only(): assert report_items[0].name == "mcp_tool mcp-1" -def test_get_mcp_provider_does_not_compare_non_uuid_identifier_to_uuid_id(): +@pytest.mark.parametrize("sqlite_session", [(MCPToolProvider,)], indirect=True) +def test_get_mcp_provider_does_not_compare_non_uuid_identifier_to_uuid_id(sqlite_session: Session): + sqlite_session.add( + MCPToolProvider( + name="Other tenant provider", + server_identifier="my-test-mcp", + server_url="https://example.com/mcp", + server_url_hash="other-tenant-provider", + icon=None, + tenant_id=_OTHER_TENANT_ID, + user_id=_USER_ID, + authed=False, + tools="[]", + ) + ) + sqlite_session.commit() + statements = [] - def capture_scalar(statement): - statements.append(str(statement)) + def capture_statement(_conn, _cursor, statement, _parameters, _context, _executemany): + statements.append(statement) - session = MagicMock() - session.scalar.side_effect = capture_scalar + bind = sqlite_session.get_bind() + event.listen(bind, "before_cursor_execute", capture_statement) - with pytest.raises(MigrationDataError, match="MCP provider not found"): - MigrationExportService()._get_mcp_provider("tenant-1", "my-test-mcp", session=session) + try: + with pytest.raises(MigrationDataError, match="MCP provider not found"): + MigrationExportService()._get_mcp_provider(_TENANT_ID, "my-test-mcp", session=sqlite_session) + finally: + event.remove(bind, "before_cursor_execute", capture_statement) assert len(statements) == 1 assert "tool_mcp_providers.id =" not in statements[0] diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py index 4c8b779491c..27f240797c1 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -1,11 +1,9 @@ """Unit tests for services.enterprise.rbac_service. -The enterprise RBAC client is almost pure glue: each method turns a single -``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response -model. Rather than spinning up an HTTP server we monkeypatch that helper and -assert on the arguments it received; that catches both routing regressions -(wrong method / wrong path / wrong params) and model-shape regressions in -one place. +Most enterprise RBAC methods turn a single ``EnterpriseRequest.send_inner_rbac_request`` +call into a pydantic response model. Rather than spinning up an HTTP server, these tests +monkeypatch that helper and assert on the request arguments and response shape. The legacy +fallbacks use SQLite to verify their database reads and committed role updates. """ from __future__ import annotations @@ -15,7 +13,10 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask +from sqlalchemy import select +from sqlalchemy.orm import Session +from models import TenantAccountJoin from services.enterprise import rbac_service as svc MODULE = "services.enterprise.rbac_service" @@ -533,8 +534,9 @@ class TestWorkspaceAccess: assert call.params == {"language": "en"} +@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True) class TestMyPermissions: - def test_resource_snapshot_maps_defaults_and_overrides(self): + def test_resource_snapshot_maps_defaults_and_overrides(self, sqlite_session: Session): snapshot = svc.ResourcePermissionSnapshot( default_permission_keys=["app.acl.view_layout"], overrides=[ @@ -550,7 +552,7 @@ class TestMyPermissions: "app-2": ["app.acl.view_layout", "app.acl.edit"], } - def test_get_without_payload_uses_get(self, mock_send: MagicMock): + def test_get_without_payload_uses_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "workspace": {"permission_keys": ["workspace.member.manage"]}, "app": {"default_permission_keys": ["app.acl.view_layout", "app.acl.test_and_run"], "overrides": []}, @@ -558,7 +560,7 @@ class TestMyPermissions: } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=MagicMock()) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) call = _call_args(mock_send) assert call.method == "GET" @@ -609,12 +611,14 @@ class TestMyPermissions: workspace_keys: list[str], app_keys: list[str], dataset_keys: list[str], + sqlite_session: Session, ): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = role + sqlite_session.add( + TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole(role)) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=mock_session) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) mock_send.assert_not_called() assert out.workspace.permission_keys == workspace_keys @@ -648,12 +652,14 @@ class TestMyPermissions: mock_send: MagicMock, role: str, expected_snippet_keys: set[str], + sqlite_session: Session, ): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = role + sqlite_session.add( + TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole(role)) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=mock_session) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) actual_snippet_keys = { permission_key for permission_key in out.workspace.permission_keys if permission_key.startswith("snippets.") @@ -662,19 +668,16 @@ class TestMyPermissions: mock_send.assert_not_called() assert actual_snippet_keys == expected_snippet_keys - def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = None + def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock, sqlite_session: Session): with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=mock_session) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) mock_send.assert_not_called() assert out.workspace.permission_keys == [] assert out.app.default_permission_keys == [] assert out.dataset.default_permission_keys == [] - def test_get_with_single_resource_filters(self, mock_send: MagicMock): + def test_get_with_single_resource_filters(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "workspace": {"permission_keys": []}, "app": { @@ -685,7 +688,7 @@ class TestMyPermissions: } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1", session=MagicMock()) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1", session=sqlite_session) call = _call_args(mock_send) assert call.method == "GET" @@ -694,8 +697,9 @@ class TestMyPermissions: assert out.app.overrides[0].resource_id == "app-1" +@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True) class TestMemberRoles: - def test_get(self, mock_send: MagicMock): + def test_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "account_id": "acct-2", "roles": [ @@ -707,7 +711,7 @@ class TestMemberRoles: ], } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): - out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=MagicMock()) + out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=sqlite_session) call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/members/rbac-roles" @@ -715,12 +719,14 @@ class TestMemberRoles: assert out.account_id == "acct-2" assert out.roles[0].name == "Member" - def test_get_legacy_role_includes_permission_keys(self, mock_send: MagicMock): - session = MagicMock() - session.scalar.return_value = svc.TenantAccountRole.EDITOR + def test_get_legacy_role_includes_permission_keys(self, mock_send: MagicMock, sqlite_session: Session): + sqlite_session.add( + TenantAccountJoin(tenant_id="tenant-1", account_id="acct-2", role=svc.TenantAccountRole.EDITOR) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): - out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=session) + out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=sqlite_session) mock_send.assert_not_called() assert out.account_id == "acct-2" @@ -738,7 +744,7 @@ class TestMemberRoles: assert "app.acl.preview" in out.roles[0].permission_keys assert "dataset.acl.preview" in out.roles[0].permission_keys - def test_replace(self, mock_send: MagicMock): + def test_replace(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = {"account_id": "acct-2", "roles": []} with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): svc.RBACService.MemberRoles.replace( @@ -746,7 +752,7 @@ class TestMemberRoles: "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"], - session=MagicMock(), + session=sqlite_session, ) call = _call_args(mock_send) assert call.method == "PUT" @@ -754,43 +760,59 @@ class TestMemberRoles: assert call.params == {"account_id": "acct-2"} assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]} - def test_replace_updates_legacy_join_role_when_rbac_disabled(self, mock_send: MagicMock): - session = MagicMock() - session.__enter__.return_value = session - target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2") - session.scalar.return_value = target_join + def test_replace_commits_legacy_join_role_when_rbac_disabled(self, mock_send: MagicMock, sqlite_session: Session): + target_join = TenantAccountJoin(tenant_id="tenant-1", account_id="acct-2", role=svc.TenantAccountRole.NORMAL) + sqlite_session.add(target_join) + sqlite_session.commit() + target_join_id = target_join.id + engine = sqlite_session.get_bind() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MemberRoles.replace( - "tenant-1", "acct-1", "acct-2", role_ids=["editor"], session=session + "tenant-1", "acct-1", "acct-2", role_ids=["editor"], session=sqlite_session ) mock_send.assert_not_called() - session.commit.assert_called_once() - assert target_join.role == svc.TenantAccountRole.EDITOR + # Closing the writer rolls back any uncommitted update and prevents its identity map + # from satisfying the verification query. + sqlite_session.close() + with Session(engine) as verification_session: + persisted_join = verification_session.scalar( + select(TenantAccountJoin).where(TenantAccountJoin.id == target_join_id) + ) + assert persisted_join is not None + assert persisted_join.role == svc.TenantAccountRole.EDITOR assert out.account_id == "acct-2" assert out.roles[0].id == "editor" assert "app.acl.preview" in out.roles[0].permission_keys - def test_replace_legacy_owner_demotes_current_owner_when_rbac_disabled(self, mock_send: MagicMock): - session = MagicMock() - session.__enter__.return_value = session - target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2") - owner_join = SimpleNamespace(role=svc.TenantAccountRole.OWNER, account_id="acct-owner") - session.scalar.side_effect = [target_join, owner_join] + def test_replace_legacy_owner_demotes_current_owner_when_rbac_disabled( + self, mock_send: MagicMock, sqlite_session: Session + ): + target_join = TenantAccountJoin(tenant_id="tenant-1", account_id="acct-2", role=svc.TenantAccountRole.NORMAL) + owner_join = TenantAccountJoin(tenant_id="tenant-1", account_id="acct-owner", role=svc.TenantAccountRole.OWNER) + sqlite_session.add_all([target_join, owner_join]) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MemberRoles.replace( - "tenant-1", "acct-1", "acct-2", role_ids=["owner"], session=session + "tenant-1", "acct-1", "acct-2", role_ids=["owner"], session=sqlite_session ) mock_send.assert_not_called() - session.commit.assert_called_once() - assert target_join.role == svc.TenantAccountRole.OWNER - assert owner_join.role == svc.TenantAccountRole.ADMIN + persisted_joins = { + join.account_id: join.role + for join in sqlite_session.scalars( + select(TenantAccountJoin).where(TenantAccountJoin.tenant_id == "tenant-1") + ) + } + assert persisted_joins == { + "acct-2": svc.TenantAccountRole.OWNER, + "acct-owner": svc.TenantAccountRole.ADMIN, + } assert out.roles[0].id == "owner" - def test_batch_get(self, mock_send: MagicMock): + def test_batch_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "acct-2": [ {"id": "role-1", "name": "Admin", "type": "workspace"}, @@ -811,8 +833,9 @@ class TestMemberRoles: assert out[1].roles == [] +@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True) class TestResourcePermissions: - def test_app_permissions_batch_get(self, mock_send: MagicMock): + def test_app_permissions_batch_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "data": [ {"resource_id": "app-1", "permission_keys": ["app.acl.view_layout", "app.acl.edit"]}, @@ -822,7 +845,7 @@ class TestResourcePermissions: with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): out = svc.RBACService.AppPermissions.batch_get( - "tenant-1", "acct-1", ["app-1", "app-2"], session=MagicMock() + "tenant-1", "acct-1", ["app-1", "app-2"], session=sqlite_session ) call = _call_args(mock_send) @@ -834,13 +857,16 @@ class TestResourcePermissions: "app-2": [], } - def test_app_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled(self, mock_send: MagicMock): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = "editor" + def test_app_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled( + self, mock_send: MagicMock, sqlite_session: Session + ): + sqlite_session.add( + TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole.EDITOR) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.AppPermissions.batch_get( - "tenant-1", "acct-1", ["app-1", "app-2"], session=mock_session + "tenant-1", "acct-1", ["app-1", "app-2"], session=sqlite_session ) mock_send.assert_not_called() @@ -849,7 +875,7 @@ class TestResourcePermissions: "app-2": svc._LEGACY_APP_EDITOR_KEYS, } - def test_dataset_permissions_batch_get(self, mock_send: MagicMock): + def test_dataset_permissions_batch_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "data": [ {"resource_id": "ds-1", "permission_keys": ["dataset.acl.readonly"]}, @@ -859,7 +885,7 @@ class TestResourcePermissions: with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): out = svc.RBACService.DatasetPermissions.batch_get( - "tenant-1", "acct-1", ["ds-1", "ds-2"], session=MagicMock() + "tenant-1", "acct-1", ["ds-1", "ds-2"], session=sqlite_session ) call = _call_args(mock_send) @@ -871,13 +897,20 @@ class TestResourcePermissions: "ds-2": ["dataset.acl.edit"], } - def test_dataset_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled(self, mock_send: MagicMock): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = "dataset_operator" + def test_dataset_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled( + self, mock_send: MagicMock, sqlite_session: Session + ): + sqlite_session.add( + TenantAccountJoin( + tenant_id="tenant-1", + account_id="acct-1", + role=svc.TenantAccountRole.DATASET_OPERATOR, + ) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.DatasetPermissions.batch_get( - "tenant-1", "acct-1", ["ds-1", "ds-2"], session=mock_session + "tenant-1", "acct-1", ["ds-1", "ds-2"], session=sqlite_session ) mock_send.assert_not_called() diff --git a/api/tests/unit_tests/services/hit_service.py b/api/tests/unit_tests/services/hit_service.py index 0257fd43676..2a456dc4b9d 100644 --- a/api/tests/unit_tests/services/hit_service.py +++ b/api/tests/unit_tests/services/hit_service.py @@ -6,17 +6,25 @@ which handles retrieval testing operations for datasets, including internal dataset retrieval and external knowledge base retrieval. """ +import json from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session from core.rag.models.document import Document from core.rag.retrieval.retrieval_methods import RetrievalMethod from models import Account -from models.dataset import Dataset +from models.dataset import Dataset, DatasetQuery from services.hit_testing_service import HitTestingService +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize("sqlite_session", [(DatasetQuery,)], indirect=True), +] + class HitTestingTestDataFactory: """ @@ -139,17 +147,7 @@ class TestHitTestingServiceRetrieve: various retrieval model configurations, metadata filtering, and query logging. """ - @pytest.fixture - def mock_db_session(self): - """ - Mock database session. - - Provides a mocked database session for testing database operations - like adding and committing DatasetQuery records. - """ - return MagicMock() - - def test_retrieve_success_with_default_retrieval_model(self, mock_db_session): + def test_retrieve_success_with_default_retrieval_model(self, sqlite_session: Session): """ Test successful retrieval with default retrieval model. @@ -186,17 +184,20 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert assert result["query"]["content"] == query assert len(result["records"]) == 2 mock_retrieve.assert_called_once() - mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() + query_log = sqlite_session.scalar(select(DatasetQuery)) + assert query_log is not None + assert query_log.dataset_id == dataset.id + assert query_log.created_by == account.id + assert json.loads(query_log.content) == [{"content_type": "text_query", "content": query}] - def test_retrieve_success_with_custom_retrieval_model(self, mock_db_session): + def test_retrieve_success_with_custom_retrieval_model(self, sqlite_session: Session): """ Test successful retrieval with custom retrieval model. @@ -234,7 +235,7 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert @@ -246,7 +247,7 @@ class TestHitTestingServiceRetrieve: assert call_kwargs["score_threshold"] == 0.7 assert call_kwargs["reranking_model"] == retrieval_model["reranking_model"] - def test_retrieve_with_metadata_filtering(self, mock_db_session): + def test_retrieve_with_metadata_filtering(self, sqlite_session: Session): """ Test retrieval with metadata filtering conditions. @@ -292,7 +293,7 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert @@ -301,7 +302,7 @@ class TestHitTestingServiceRetrieve: call_kwargs = mock_retrieve.call_args[1] assert call_kwargs["document_ids_filter"] == ["doc-1", "doc-2"] - def test_retrieve_with_metadata_filtering_no_documents(self, mock_db_session): + def test_retrieve_with_metadata_filtering_no_documents(self, sqlite_session: Session): """ Test retrieval with metadata filtering that returns no documents. @@ -337,14 +338,14 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert assert result["query"]["content"] == query assert result["records"] == [] - def test_retrieve_with_dataset_retrieval_model(self, mock_db_session): + def test_retrieve_with_dataset_retrieval_model(self, sqlite_session: Session): """ Test retrieval using dataset's retrieval model when not provided. @@ -380,7 +381,7 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert @@ -398,17 +399,7 @@ class TestHitTestingServiceExternalRetrieve: including query escaping, response formatting, and provider validation. """ - @pytest.fixture - def mock_db_session(self): - """ - Mock database session. - - Provides a mocked database session for testing database operations - like adding and committing DatasetQuery records. - """ - return MagicMock() - - def test_external_retrieve_success(self, mock_db_session): + def test_external_retrieve_success(self, sqlite_session: Session): """ Test successful external retrieval. @@ -443,7 +434,7 @@ class TestHitTestingServiceExternalRetrieve: account, external_retrieval_model, metadata_filtering_conditions, - session=mock_db_session, + session=sqlite_session, ) # Assert @@ -455,10 +446,13 @@ class TestHitTestingServiceExternalRetrieve: mock_external_retrieve.assert_called_once() # Verify query was escaped assert mock_external_retrieve.call_args[1]["query"] == 'test query with \\"quotes\\"' - mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() + query_log = sqlite_session.scalar(select(DatasetQuery)) + assert query_log is not None + assert query_log.dataset_id == dataset.id + assert query_log.content == query + assert query_log.created_by == account.id - def test_external_retrieve_non_external_provider(self, mock_db_session): + def test_external_retrieve_non_external_provider(self, sqlite_session: Session): """ Test external retrieval with non-external provider (should return empty). @@ -474,15 +468,15 @@ class TestHitTestingServiceExternalRetrieve: # Act result = HitTestingService.external_retrieve( - dataset, query, account, external_retrieval_model, metadata_filtering_conditions, session=mock_db_session + dataset, query, account, external_retrieval_model, metadata_filtering_conditions, session=sqlite_session ) # Assert assert result["query"]["content"] == query assert result["records"] == [] - mock_db_session.add.assert_not_called() + assert sqlite_session.scalar(select(DatasetQuery)) is None - def test_external_retrieve_with_metadata_filtering(self, mock_db_session): + def test_external_retrieve_with_metadata_filtering(self, sqlite_session: Session): """ Test external retrieval with metadata filtering conditions. @@ -514,7 +508,7 @@ class TestHitTestingServiceExternalRetrieve: account, external_retrieval_model, metadata_filtering_conditions, - session=mock_db_session, + session=sqlite_session, ) # Assert @@ -523,7 +517,7 @@ class TestHitTestingServiceExternalRetrieve: call_kwargs = mock_external_retrieve.call_args[1] assert call_kwargs["metadata_filtering_conditions"] == metadata_filtering_conditions - def test_external_retrieve_empty_documents(self, mock_db_session): + def test_external_retrieve_empty_documents(self, sqlite_session: Session): """ Test external retrieval with empty document list. @@ -553,7 +547,7 @@ class TestHitTestingServiceExternalRetrieve: account, external_retrieval_model, metadata_filtering_conditions, - session=mock_db_session, + session=sqlite_session, ) # Assert @@ -569,7 +563,7 @@ class TestHitTestingServiceCompactRetrieveResponse: ensuring documents are properly formatted into retrieval records. """ - def test_compact_retrieve_response_success(self): + def test_compact_retrieve_response_success(self, sqlite_session: Session): """ Test successful response formatting. @@ -587,7 +581,6 @@ class TestHitTestingServiceCompactRetrieveResponse: HitTestingTestDataFactory.create_retrieval_record_mock(content="Doc 1", score=0.95), HitTestingTestDataFactory.create_retrieval_record_mock(content="Doc 2", score=0.85), ] - session = MagicMock() with patch( "services.hit_testing_service.RetrievalService.format_retrieval_documents", autospec=True @@ -595,7 +588,7 @@ class TestHitTestingServiceCompactRetrieveResponse: mock_format.return_value = mock_records # Act - result = HitTestingService.compact_retrieve_response(query, documents, session=session) + result = HitTestingService.compact_retrieve_response(query, documents, session=sqlite_session) # Assert assert result["query"]["content"] == query @@ -603,10 +596,11 @@ class TestHitTestingServiceCompactRetrieveResponse: assert result["records"][0]["content"] == "Doc 1" assert result["records"][0]["score"] == 0.95 mock_format.assert_called_once() - assert mock_format.call_args.args[0] is not session + assert mock_format.call_args.args[0] is not sqlite_session + assert mock_format.call_args.args[0].get_bind() is sqlite_session.get_bind() assert mock_format.call_args.args[1] == documents - def test_compact_retrieve_response_empty_documents(self): + def test_compact_retrieve_response_empty_documents(self, sqlite_session: Session): """ Test response formatting with empty document list. @@ -616,7 +610,6 @@ class TestHitTestingServiceCompactRetrieveResponse: # Arrange query = "test query" documents = [] - session = MagicMock() with patch( "services.hit_testing_service.RetrievalService.format_retrieval_documents", autospec=True @@ -624,13 +617,14 @@ class TestHitTestingServiceCompactRetrieveResponse: mock_format.return_value = [] # Act - result = HitTestingService.compact_retrieve_response(query, documents, session=session) + result = HitTestingService.compact_retrieve_response(query, documents, session=sqlite_session) # Assert assert result["query"]["content"] == query assert result["records"] == [] mock_format.assert_called_once() - assert mock_format.call_args.args[0] is not session + assert mock_format.call_args.args[0] is not sqlite_session + assert mock_format.call_args.args[0].get_bind() is sqlite_session.get_bind() assert mock_format.call_args.args[1] == documents diff --git a/api/tests/unit_tests/services/test_agent_tool_inner_service.py b/api/tests/unit_tests/services/test_agent_tool_inner_service.py index 61049d29e9e..8f222b33093 100644 --- a/api/tests/unit_tests/services/test_agent_tool_inner_service.py +++ b/api/tests/unit_tests/services/test_agent_tool_inner_service.py @@ -1,9 +1,10 @@ -"""Unit tests for the Agent tool inner invoke service.""" +"""Unit tests for the Agent tool inner invoke service with SQLite-backed app lookup.""" from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from sqlalchemy.orm import Session from core.tools.entities.tool_entities import ToolInvokeMessage, ToolProviderType from core.tools.errors import ( @@ -12,19 +13,44 @@ from core.tools.errors import ( ToolProviderCredentialValidationError, ToolProviderNotFoundError, ) +from models.enums import AppStatus +from models.model import App, AppMode from services.agent_tool_inner_service import AgentToolInnerService from services.entities.agent_tool_inner import AgentToolInvokeRequest from services.errors.agent_tool_inner import AgentToolInnerServiceError +TENANT_ID = "11111111-1111-1111-1111-111111111111" +OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222" +USER_ID = "33333333-3333-3333-3333-333333333333" +APP_ID = "44444444-4444-4444-4444-444444444444" + + +def _persist_app(sqlite_session: Session, *, tenant_id: str = TENANT_ID) -> App: + app = App( + id=APP_ID, + tenant_id=tenant_id, + name="Test App", + description="", + mode=AppMode.CHAT, + status=AppStatus.NORMAL, + enable_site=False, + enable_api=False, + max_active_requests=None, + ) + sqlite_session.add(app) + sqlite_session.commit() + sqlite_session.expunge_all() + return app + def _request() -> AgentToolInvokeRequest: return AgentToolInvokeRequest.model_validate( { "caller": { - "tenant_id": "tenant-1", - "user_id": "user-1", + "tenant_id": TENANT_ID, + "user_id": USER_ID, "user_from": "account", - "app_id": "app-1", + "app_id": APP_ID, "invoke_from": "service-api", "conversation_id": "conversation-1", "workflow_id": "workflow-1", @@ -53,11 +79,10 @@ def _messages() -> Generator[ToolInvokeMessage, None, None]: ) -def test_invoke_uses_agent_tool_runtime_and_returns_observation() -> None: +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_uses_agent_tool_runtime_and_returns_observation(sqlite_session: Session) -> None: fake_tool = MagicMock() - fake_app = MagicMock(id="app-1", tenant_id="tenant-1") - session = MagicMock() - session.get.return_value = fake_app + _persist_app(sqlite_session) with ( patch( @@ -70,7 +95,7 @@ def test_invoke_uses_agent_tool_runtime_and_returns_observation() -> None: side_effect=lambda messages, **_kwargs: messages, ), ): - response = AgentToolInnerService().invoke(_request(), session=session) + response = AgentToolInnerService().invoke(_request(), session=sqlite_session) assert response.observation == "ok" assert response.metadata == { @@ -82,56 +107,58 @@ def test_invoke_uses_agent_tool_runtime_and_returns_observation() -> None: assert agent_tool.provider_type is ToolProviderType.PLUGIN assert agent_tool.tool_parameters == {"region": "us"} mock_invoke.assert_called_once() + assert mock_invoke.call_args.kwargs["session"] is sqlite_session + assert sqlite_session.in_transaction() -def test_invoke_raises_app_not_found_when_session_has_no_app() -> None: - session = MagicMock() - session.get.return_value = None - +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_raises_app_not_found_when_session_has_no_app(sqlite_session: Session) -> None: with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == "app_not_found" assert exc_info.value.status_code == 404 assert exc_info.value.description == "App not found." + assert sqlite_session.in_transaction() -def test_invoke_raises_app_tenant_mismatch_when_app_belongs_to_other_tenant() -> None: - fake_app = MagicMock(id="app-1", tenant_id="tenant-2") - session = MagicMock() - session.get.return_value = fake_app +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_raises_app_tenant_mismatch_when_app_belongs_to_other_tenant(sqlite_session: Session) -> None: + _persist_app(sqlite_session, tenant_id=OTHER_TENANT_ID) with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == "app_tenant_mismatch" assert exc_info.value.status_code == 403 assert exc_info.value.description == "App does not belong to the caller tenant." + assert sqlite_session.in_transaction() -def test_invoke_maps_tool_runtime_app_not_found_value_error_to_specific_error_code() -> None: +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_maps_tool_runtime_app_not_found_value_error_to_specific_error_code( + sqlite_session: Session, +) -> None: fake_tool = MagicMock() - fake_app = MagicMock(id="app-1", tenant_id="tenant-1") - session = MagicMock() - session.get.return_value = fake_app + _persist_app(sqlite_session) with ( patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", return_value=fake_tool), patch("services.agent_tool_inner_service.ToolEngine.generic_invoke", side_effect=ValueError("app not found")), ): with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == "app_not_found" assert exc_info.value.status_code == 404 assert exc_info.value.description == "App not found." + assert sqlite_session.in_transaction() -def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper() -> None: +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper(sqlite_session: Session) -> None: fake_tool = MagicMock() - fake_app = MagicMock(id="app-1", tenant_id="tenant-1") - session = MagicMock() - session.get.return_value = fake_app + _persist_app(sqlite_session) with ( patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", return_value=fake_tool), @@ -141,9 +168,10 @@ def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper() -> N ), ): with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == "agent_tool_invoke_failed" + assert sqlite_session.in_transaction() @pytest.mark.parametrize( @@ -154,13 +182,17 @@ def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper() -> N (ToolParameterValidationError("query is required"), "tool_parameters_invalid"), ], ) -def test_invoke_maps_runtime_lookup_errors_to_service_error_codes(error: Exception, expected_code: str) -> None: - fake_app = MagicMock(id="app-1", tenant_id="tenant-1") - session = MagicMock() - session.get.return_value = fake_app +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_maps_runtime_lookup_errors_to_service_error_codes( + error: Exception, + expected_code: str, + sqlite_session: Session, +) -> None: + _persist_app(sqlite_session) with patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", side_effect=error): with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == expected_code + assert sqlite_session.in_transaction() diff --git a/api/tests/unit_tests/services/test_async_workflow_service.py b/api/tests/unit_tests/services/test_async_workflow_service.py index 93599363c4f..07285569b7d 100644 --- a/api/tests/unit_tests/services/test_async_workflow_service.py +++ b/api/tests/unit_tests/services/test_async_workflow_service.py @@ -1,12 +1,18 @@ import json import logging +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from sqlalchemy import select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session import services.async_workflow_service as async_workflow_service_module from models.enums import AppTriggerType, CreatorUserRole, WorkflowRunTriggeredFrom, WorkflowTriggerStatus +from models.model import App, AppMode +from models.trigger import WorkflowTriggerLog from services.async_workflow_service import AsyncWorkflowService from services.errors.app import QuotaExceededError, WorkflowNotFoundError from services.workflow.entities import AsyncTriggerResponse, TriggerData @@ -37,25 +43,66 @@ class AsyncWorkflowServiceTestDataFactory: ) @staticmethod - def create_trigger_log_with_data(trigger_data: TriggerData, retry_count: int = 0) -> MagicMock: - """Create a mock trigger log with serialized trigger data.""" - trigger_log = MagicMock() - trigger_log.id = "trigger-log-123" - trigger_log.trigger_data = trigger_data.model_dump_json() - trigger_log.retry_count = retry_count - trigger_log.error = "previous-error" - trigger_log.status = WorkflowTriggerStatus.FAILED - trigger_log.to_dict.return_value = {"id": trigger_log.id} + def create_app(app_id: str = "app-123", tenant_id: str = "tenant-123") -> App: + """Create an app that can be persisted for trigger lookup tests.""" + return App( + id=app_id, + tenant_id=tenant_id, + name="Async workflow app", + description="", + mode=AppMode.WORKFLOW, + enable_site=True, + enable_api=True, + max_active_requests=0, + ) + + @staticmethod + def create_trigger_log_with_data( + trigger_data: TriggerData, + *, + trigger_log_id: str = "trigger-log-123", + retry_count: int = 0, + status: WorkflowTriggerStatus = WorkflowTriggerStatus.FAILED, + created_at: datetime | None = None, + ) -> WorkflowTriggerLog: + """Create a persistent trigger log with serialized trigger data.""" + trigger_log = WorkflowTriggerLog( + tenant_id=trigger_data.tenant_id, + app_id=trigger_data.app_id, + workflow_id=trigger_data.workflow_id or "workflow-123", + workflow_run_id=None, + root_node_id=trigger_data.root_node_id, + trigger_metadata="{}", + trigger_type=trigger_data.trigger_type, + trigger_data=trigger_data.model_dump_json(), + inputs=json.dumps(dict(trigger_data.inputs)), + outputs=None, + status=status, + error="previous-error", + queue_name=QueuePriority.SANDBOX, + celery_task_id=None, + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-123", + retry_count=retry_count, + elapsed_time=None, + total_tokens=None, + triggered_at=None, + finished_at=None, + ) + trigger_log.id = trigger_log_id + if created_at is not None: + trigger_log.created_at = created_at return trigger_log +@pytest.mark.usefixtures("sqlite_session") +@pytest.mark.parametrize("sqlite_session", [(App, WorkflowTriggerLog)], indirect=True) class TestAsyncWorkflowService: @pytest.fixture def async_workflow_trigger_mocks(self): """Shared fixture for async workflow trigger tests. Yields mocks for: - - repo: SQLAlchemyWorkflowTriggerLogRepository - dispatcher_manager_class: QueueDispatcherManager class - dispatcher: dispatcher instance - quota_service: QuotaService mock @@ -64,23 +111,10 @@ class TestAsyncWorkflowService: - team_task: execute_workflow_team - sandbox_task: execute_workflow_sandbox """ - mock_repo = MagicMock() - - def _create_side_effect(new_log): - new_log.id = "trigger-log-123" - return new_log - - mock_repo.create.side_effect = _create_side_effect - mock_dispatcher = MagicMock() mock_quota_service = MagicMock() with ( - patch.object( - async_workflow_service_module, - "SQLAlchemyWorkflowTriggerLogRepository", - return_value=mock_repo, - ), patch.object(async_workflow_service_module, "QueueDispatcherManager") as mock_dispatcher_manager_class, patch.object(async_workflow_service_module, "WorkflowService"), patch.object( @@ -100,7 +134,6 @@ class TestAsyncWorkflowService: mock_dispatcher_manager_class.return_value.get_dispatcher.return_value = mock_dispatcher yield { - "repo": mock_repo, "dispatcher_manager_class": mock_dispatcher_manager_class, "dispatcher": mock_dispatcher, "quota_service": mock_quota_service, @@ -119,15 +152,16 @@ class TestAsyncWorkflowService: ], ) def test_should_dispatch_to_matching_celery_task_when_triggering_workflow( - self, queue_name, selected_task_attr, async_workflow_trigger_mocks + self, + queue_name, + selected_task_attr, + async_workflow_trigger_mocks, + sqlite_session: Session, ): """Test queue-based task routing and successful async trigger response.""" # Arrange - session = MagicMock() - session.commit = MagicMock() - app_model = MagicMock() - app_model.id = "app-123" - session.scalar.return_value = app_model + sqlite_session.add(AsyncWorkflowServiceTestDataFactory.create_app()) + sqlite_session.commit() trigger_data = AsyncWorkflowServiceTestDataFactory.create_trigger_data() workflow = MagicMock() workflow.id = "workflow-123" @@ -153,20 +187,25 @@ class TestAsyncWorkflowService: user = DummyAccount("account-123") # Act - result = AsyncWorkflowService.trigger_workflow_async(session=session, user=user, trigger_data=trigger_data) + result = AsyncWorkflowService.trigger_workflow_async( + session=sqlite_session, user=user, trigger_data=trigger_data + ) # Assert assert isinstance(result, AsyncTriggerResponse) - assert result.workflow_trigger_log_id == "trigger-log-123" + assert result.workflow_trigger_log_id assert result.task_id == "task-123" assert result.status == "queued" assert result.queue == queue_name mocks["quota_service"].reserve.assert_called_once() quota_charge_mock.commit.assert_called_once() - assert session.commit.call_count == 3 + assert not sqlite_session.in_transaction() - created_log = mocks["repo"].create.call_args[0][0] + created_log = sqlite_session.scalar( + select(WorkflowTriggerLog).where(WorkflowTriggerLog.id == result.workflow_trigger_log_id) + ) + assert created_log is not None assert created_log.status == WorkflowTriggerStatus.QUEUED assert created_log.queue_name == queue_name assert created_log.created_by_role == CreatorUserRole.ACCOUNT @@ -182,18 +221,17 @@ class TestAsyncWorkflowService: } for task_attr, task_mock in task_mocks.items(): if task_attr == selected_task_attr: - task_mock.delay.assert_called_once_with({"workflow_trigger_log_id": "trigger-log-123"}) + task_mock.delay.assert_called_once_with({"workflow_trigger_log_id": result.workflow_trigger_log_id}) else: task_mock.delay.assert_not_called() - def test_should_set_end_user_role_when_triggered_by_end_user(self, async_workflow_trigger_mocks): + def test_should_set_end_user_role_when_triggered_by_end_user( + self, async_workflow_trigger_mocks, sqlite_session: Session + ): """Test that non-account users are tracked as END_USER in trigger logs.""" # Arrange - session = MagicMock() - session.commit = MagicMock() - app_model = MagicMock() - app_model.id = "app-123" - session.scalar.return_value = app_model + sqlite_session.add(AsyncWorkflowServiceTestDataFactory.create_app()) + sqlite_session.commit() trigger_data = AsyncWorkflowServiceTestDataFactory.create_trigger_data() workflow = MagicMock() workflow.id = "workflow-123" @@ -208,43 +246,43 @@ class TestAsyncWorkflowService: user = SimpleNamespace(id="end-user-123") # Act - AsyncWorkflowService.trigger_workflow_async(session=session, user=user, trigger_data=trigger_data) + response = AsyncWorkflowService.trigger_workflow_async( + session=sqlite_session, user=user, trigger_data=trigger_data + ) # Assert - created_log = mocks["repo"].create.call_args[0][0] + created_log = sqlite_session.get(WorkflowTriggerLog, response.workflow_trigger_log_id) + assert created_log is not None assert created_log.created_by_role == CreatorUserRole.END_USER assert created_log.created_by == "end-user-123" - def test_should_raise_workflow_not_found_when_app_does_not_exist(self): + def test_should_raise_workflow_not_found_when_app_does_not_exist(self, sqlite_session: Session): """Test trigger failure when app lookup returns no result.""" # Arrange - session = MagicMock() - session.scalar.return_value = None trigger_data = AsyncWorkflowServiceTestDataFactory.create_trigger_data(app_id="missing-app") with ( - patch.object(async_workflow_service_module, "SQLAlchemyWorkflowTriggerLogRepository"), patch.object(async_workflow_service_module, "QueueDispatcherManager"), patch.object(async_workflow_service_module, "WorkflowService"), ): # Act / Assert with pytest.raises(WorkflowNotFoundError, match="App not found: missing-app"): AsyncWorkflowService.trigger_workflow_async( - session=session, + session=sqlite_session, user=SimpleNamespace(id="user-123"), trigger_data=trigger_data, ) def test_should_mark_log_rate_limited_and_reraise_when_quota_exceeded( - self, async_workflow_trigger_mocks, caplog: pytest.LogCaptureFixture + self, + async_workflow_trigger_mocks, + caplog: pytest.LogCaptureFixture, + sqlite_session: Session, ): """Test quota-exceeded path updates trigger log and preserves the quota exception.""" # Arrange - session = MagicMock() - session.commit = MagicMock() - app_model = MagicMock() - app_model.id = "app-123" - session.scalar.return_value = app_model + sqlite_session.add(AsyncWorkflowServiceTestDataFactory.create_app()) + sqlite_session.commit() trigger_data = AsyncWorkflowServiceTestDataFactory.create_trigger_data() workflow = MagicMock() workflow.id = "workflow-123" @@ -262,7 +300,7 @@ class TestAsyncWorkflowService: # Act / Assert with pytest.raises(QuotaExceededError) as exc_info: AsyncWorkflowService.trigger_workflow_async( - session=session, + session=sqlite_session, user=SimpleNamespace(id="user-123"), trigger_data=trigger_data, ) @@ -270,42 +308,37 @@ class TestAsyncWorkflowService: assert exc_info.value.feature == "workflow" assert exc_info.value.tenant_id == "tenant-123" assert exc_info.value.required == 1 - assert session.commit.call_count == 3 - updated_log = mocks["repo"].update.call_args[0][0] + assert not sqlite_session.in_transaction() + updated_log = sqlite_session.scalar(select(WorkflowTriggerLog)) + assert updated_log is not None assert updated_log.status == WorkflowTriggerStatus.RATE_LIMITED assert "Quota limit reached" in updated_log.error assert ( "Workflow quota exceeded for tenant tenant-123, app app-123, workflow workflow-123, " - "trigger log trigger-log-123" + f"trigger log {updated_log.id}" ) in caplog.messages mocks["professional_task"].delay.assert_not_called() mocks["team_task"].delay.assert_not_called() mocks["sandbox_task"].delay.assert_not_called() - def test_should_raise_when_reinvoke_target_log_does_not_exist(self): + def test_should_raise_when_reinvoke_target_log_does_not_exist(self, sqlite_session: Session): """Test reinvoke_trigger error path when original trigger log is missing.""" # Arrange - session = MagicMock() - repo = MagicMock() - repo.get_by_id.return_value = None + # Act / Assert + with pytest.raises(ValueError, match="Trigger log not found: missing-log"): + AsyncWorkflowService.reinvoke_trigger( + session=sqlite_session, + user=SimpleNamespace(id="user-123"), + workflow_trigger_log_id="missing-log", + ) - with patch.object(async_workflow_service_module, "SQLAlchemyWorkflowTriggerLogRepository", return_value=repo): - # Act / Assert - with pytest.raises(ValueError, match="Trigger log not found: missing-log"): - AsyncWorkflowService.reinvoke_trigger( - session=session, - user=SimpleNamespace(id="user-123"), - workflow_trigger_log_id="missing-log", - ) - - def test_should_update_original_log_and_requeue_when_reinvoking(self): + def test_should_update_original_log_and_requeue_when_reinvoking(self, sqlite_session: Session): """Test reinvoke flow updates original log state and triggers a new async run.""" # Arrange - session = MagicMock() trigger_data = AsyncWorkflowServiceTestDataFactory.create_trigger_data() trigger_log = AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data(trigger_data, retry_count=1) - repo = MagicMock() - repo.get_by_id.return_value = trigger_log + sqlite_session.add(trigger_log) + sqlite_session.commit() expected_response = AsyncTriggerResponse( workflow_trigger_log_id="new-trigger-log-456", @@ -315,7 +348,6 @@ class TestAsyncWorkflowService: ) with ( - patch.object(async_workflow_service_module, "SQLAlchemyWorkflowTriggerLogRepository", return_value=repo), patch.object( async_workflow_service_module.AsyncWorkflowService, "trigger_workflow_async", @@ -326,145 +358,142 @@ class TestAsyncWorkflowService: # Act response = AsyncWorkflowService.reinvoke_trigger( - session=session, + session=sqlite_session, user=user, workflow_trigger_log_id="trigger-log-123", ) # Assert assert response == expected_response + assert not sqlite_session.in_transaction() + sqlite_session.refresh(trigger_log) assert trigger_log.status == WorkflowTriggerStatus.RETRYING assert trigger_log.retry_count == 2 assert trigger_log.error is None assert trigger_log.triggered_at is not None - repo.update.assert_called_once_with(trigger_log) - session.commit.assert_called_once() called_trigger_data = mock_trigger_workflow_async.call_args.args[1] assert isinstance(called_trigger_data, TriggerData) assert called_trigger_data.app_id == "app-123" @pytest.mark.parametrize( - ("repo_result", "expected"), + ("lookup_id", "tenant_id", "expected_id"), [ - (None, None), - (MagicMock(), {"id": "trigger-log-123"}), + ("missing-log", "tenant-123", None), + ("trigger-log-123", "tenant-123", "trigger-log-123"), + ("trigger-log-123", "other-tenant", None), ], ) - def test_should_return_trigger_log_dict_or_none(self, repo_result, expected): - """Test get_trigger_log returns serialized log data or None.""" + def test_should_return_trigger_log_dict_or_none( + self, + lookup_id: str, + tenant_id: str, + expected_id: str | None, + sqlite_session: Session, + sqlite_engine: Engine, + ): + """Test get_trigger_log returns persisted data with tenant isolation.""" # Arrange - mock_session = MagicMock() - mock_repo = MagicMock() - fake_engine = MagicMock() - mock_repo.get_by_id.return_value = repo_result - if repo_result: - repo_result.to_dict.return_value = expected + trigger_data = AsyncWorkflowServiceTestDataFactory.create_trigger_data() + sqlite_session.add(AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data(trigger_data)) + sqlite_session.commit() - mock_session_context = MagicMock() - mock_session_context.__enter__.return_value = mock_session - mock_session_context.__exit__.return_value = None - - mock_sessionmaker = MagicMock() - mock_sessionmaker.return_value.begin.return_value = mock_session_context - - with ( - patch.object(async_workflow_service_module, "db", new=SimpleNamespace(engine=fake_engine)), - patch.object(async_workflow_service_module, "sessionmaker", mock_sessionmaker), - patch.object( - async_workflow_service_module, - "SQLAlchemyWorkflowTriggerLogRepository", - return_value=mock_repo, - ), - ): + with patch.object(async_workflow_service_module, "db", SimpleNamespace(engine=sqlite_engine)): # Act - result = AsyncWorkflowService.get_trigger_log("trigger-log-123", tenant_id="tenant-123") + result = AsyncWorkflowService.get_trigger_log(lookup_id, tenant_id=tenant_id) # Assert - assert result == expected - mock_sessionmaker.assert_called_once_with(fake_engine) - mock_repo.get_by_id.assert_called_once_with("trigger-log-123", "tenant-123") + assert (result["id"] if result else None) == expected_id - def test_should_return_recent_logs_as_dict_list(self): - """Test get_recent_logs converts repository models into dictionaries.""" + def test_should_return_recent_logs_as_dict_list(self, sqlite_session: Session, sqlite_engine: Engine): + """Test recent logs are ordered, paginated, and tenant/app scoped.""" # Arrange - mock_session = MagicMock() - mock_repo = MagicMock() - log1 = MagicMock() - log1.to_dict.return_value = {"id": "log-1"} - log2 = MagicMock() - log2.to_dict.return_value = {"id": "log-2"} - mock_repo.get_recent_logs.return_value = [log1, log2] + now = datetime.now(UTC) + logs = [ + AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data( + AsyncWorkflowServiceTestDataFactory.create_trigger_data(), + trigger_log_id=f"log-{index}", + created_at=now - timedelta(minutes=index), + ) + for index in range(1, 4) + ] + logs.extend( + [ + AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data( + AsyncWorkflowServiceTestDataFactory.create_trigger_data(tenant_id="other-tenant"), + trigger_log_id="other-tenant-log", + created_at=now, + ), + AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data( + AsyncWorkflowServiceTestDataFactory.create_trigger_data(app_id="other-app"), + trigger_log_id="other-app-log", + created_at=now, + ), + ] + ) + sqlite_session.add_all(logs) + sqlite_session.commit() - mock_session_context = MagicMock() - mock_session_context.__enter__.return_value = mock_session - mock_session_context.__exit__.return_value = None - - mock_sessionmaker = MagicMock() - mock_sessionmaker.return_value.begin.return_value = mock_session_context - - with ( - patch.object(async_workflow_service_module, "db", new=SimpleNamespace(engine=MagicMock())), - patch.object(async_workflow_service_module, "sessionmaker", mock_sessionmaker), - patch.object( - async_workflow_service_module, - "SQLAlchemyWorkflowTriggerLogRepository", - return_value=mock_repo, - ), - ): + with patch.object(async_workflow_service_module, "db", SimpleNamespace(engine=sqlite_engine)): # Act result = AsyncWorkflowService.get_recent_logs( tenant_id="tenant-123", app_id="app-123", hours=12, - limit=50, - offset=10, + limit=2, + offset=1, ) # Assert - assert result == [{"id": "log-1"}, {"id": "log-2"}] - mock_repo.get_recent_logs.assert_called_once_with( - tenant_id="tenant-123", - app_id="app-123", - hours=12, - limit=50, - offset=10, - ) + assert [log["id"] for log in result] == ["log-2", "log-3"] - def test_should_return_failed_logs_for_retry_as_dict_list(self): - """Test get_failed_logs_for_retry serializes repository logs into dicts.""" + def test_should_return_failed_logs_for_retry_as_dict_list(self, sqlite_session: Session, sqlite_engine: Engine): + """Test retry candidates are status, retry-count, and tenant scoped.""" # Arrange - mock_session = MagicMock() - mock_repo = MagicMock() - log = MagicMock() - log.to_dict.return_value = {"id": "failed-log-1"} - mock_repo.get_failed_for_retry.return_value = [log] - - mock_session_context = MagicMock() - mock_session_context.__enter__.return_value = mock_session - mock_session_context.__exit__.return_value = None - - mock_sessionmaker = MagicMock() - mock_sessionmaker.return_value.begin.return_value = mock_session_context - - with ( - patch.object(async_workflow_service_module, "db", new=SimpleNamespace(engine=MagicMock())), - patch.object(async_workflow_service_module, "sessionmaker", mock_sessionmaker), - patch.object( - async_workflow_service_module, - "SQLAlchemyWorkflowTriggerLogRepository", - return_value=mock_repo, + now = datetime.now(UTC) + candidates = [ + AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data( + AsyncWorkflowServiceTestDataFactory.create_trigger_data(), + trigger_log_id="failed-log-1", + retry_count=1, + created_at=now - timedelta(minutes=2), ), - ): + AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data( + AsyncWorkflowServiceTestDataFactory.create_trigger_data(), + trigger_log_id="rate-limited-log", + retry_count=2, + status=WorkflowTriggerStatus.RATE_LIMITED, + created_at=now - timedelta(minutes=1), + ), + AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data( + AsyncWorkflowServiceTestDataFactory.create_trigger_data(), + trigger_log_id="retry-limit-log", + retry_count=4, + ), + AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data( + AsyncWorkflowServiceTestDataFactory.create_trigger_data(tenant_id="other-tenant"), + trigger_log_id="other-tenant-log", + ), + AsyncWorkflowServiceTestDataFactory.create_trigger_log_with_data( + AsyncWorkflowServiceTestDataFactory.create_trigger_data(), + trigger_log_id="queued-log", + status=WorkflowTriggerStatus.QUEUED, + ), + ] + sqlite_session.add_all(candidates) + sqlite_session.commit() + + with patch.object(async_workflow_service_module, "db", SimpleNamespace(engine=sqlite_engine)): # Act result = AsyncWorkflowService.get_failed_logs_for_retry(tenant_id="tenant-123", max_retry_count=4, limit=20) # Assert - assert result == [{"id": "failed-log-1"}] - mock_repo.get_failed_for_retry.assert_called_once_with(tenant_id="tenant-123", max_retry_count=4, limit=20) + assert [log["id"] for log in result] == ["failed-log-1", "rate-limited-log"] +@pytest.mark.usefixtures("sqlite_session") +@pytest.mark.parametrize("sqlite_session", [(App, WorkflowTriggerLog)], indirect=True) class TestAsyncWorkflowServiceGetWorkflow: - def test_should_return_specific_workflow_when_workflow_id_exists(self): + def test_should_return_specific_workflow_when_workflow_id_exists(self, sqlite_session: Session): """Test _get_workflow returns published workflow by id when provided.""" # Arrange workflow_service = MagicMock() @@ -473,19 +502,18 @@ class TestAsyncWorkflowServiceGetWorkflow: workflow_service.get_published_workflow_by_id.return_value = workflow # Act - session = MagicMock() result = AsyncWorkflowService._get_workflow( - workflow_service, app_model, workflow_id="workflow-123", session=session + workflow_service, app_model, workflow_id="workflow-123", session=sqlite_session ) # Assert assert result == workflow workflow_service.get_published_workflow_by_id.assert_called_once_with( - app_model, "workflow-123", session=session + app_model, "workflow-123", session=sqlite_session ) workflow_service.get_published_workflow.assert_not_called() - def test_should_raise_when_specific_workflow_id_not_found(self): + def test_should_raise_when_specific_workflow_id_not_found(self, sqlite_session: Session): """Test _get_workflow raises WorkflowNotFoundError for unknown workflow id.""" # Arrange workflow_service = MagicMock() @@ -495,10 +523,10 @@ class TestAsyncWorkflowServiceGetWorkflow: # Act / Assert with pytest.raises(WorkflowNotFoundError, match="Published workflow not found: workflow-404"): AsyncWorkflowService._get_workflow( - workflow_service, app_model, workflow_id="workflow-404", session=MagicMock() + workflow_service, app_model, workflow_id="workflow-404", session=sqlite_session ) - def test_should_return_default_published_workflow_when_workflow_id_not_provided(self): + def test_should_return_default_published_workflow_when_workflow_id_not_provided(self, sqlite_session: Session): """Test _get_workflow returns default published workflow when no id is provided.""" # Arrange workflow_service = MagicMock() @@ -508,15 +536,14 @@ class TestAsyncWorkflowServiceGetWorkflow: workflow_service.get_published_workflow.return_value = workflow # Act - session = MagicMock() - result = AsyncWorkflowService._get_workflow(workflow_service, app_model, session=session) + result = AsyncWorkflowService._get_workflow(workflow_service, app_model, session=sqlite_session) # Assert assert result == workflow - workflow_service.get_published_workflow.assert_called_once_with(app_model, session=session) + workflow_service.get_published_workflow.assert_called_once_with(app_model, session=sqlite_session) workflow_service.get_published_workflow_by_id.assert_not_called() - def test_should_raise_when_default_published_workflow_not_found(self): + def test_should_raise_when_default_published_workflow_not_found(self, sqlite_session: Session): """Test _get_workflow raises WorkflowNotFoundError when app has no published workflow.""" # Arrange workflow_service = MagicMock() @@ -526,4 +553,4 @@ class TestAsyncWorkflowServiceGetWorkflow: # Act / Assert with pytest.raises(WorkflowNotFoundError, match="No published workflow found for app: app-123"): - AsyncWorkflowService._get_workflow(workflow_service, app_model, session=MagicMock()) + AsyncWorkflowService._get_workflow(workflow_service, app_model, session=sqlite_session) diff --git a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py index 9be5af2b046..863d0b3aef6 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py @@ -1,335 +1,451 @@ import datetime +import json import logging +from collections.abc import Callable +from decimal import Decimal from types import SimpleNamespace -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock import pytest +from sqlalchemy import event +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from enums.cloud_plan import CloudPlan +from graphon.file import FileTransferMethod, FileType +from models.account import Tenant +from models.enums import ( + ConversationFromSource, + CreatorUserRole, + FeedbackFromSource, + FeedbackRating, + MessageChainType, +) +from models.model import ( + App, + AppAnnotationHitHistory, + AppMode, + Conversation, + Message, + MessageAgentThought, + MessageAnnotation, + MessageChain, + MessageFeedback, + MessageFile, +) +from models.web import SavedMessage +from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom from services import clear_free_plan_tenant_expired_logs as service_module from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs +REAL_DATETIME = datetime.datetime +SQLITE_MODELS = ( + Tenant, + App, + Conversation, + Message, + MessageFeedback, + MessageFile, + MessageAnnotation, + MessageChain, + MessageAgentThought, + AppAnnotationHitHistory, + SavedMessage, + WorkflowAppLog, +) + +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True), +] + + +def _create_tenant( + tenant_id: str, + *, + created_at: datetime.datetime | None = None, +) -> Tenant: + """Create a tenant with a stable ID and optional batch-selection timestamp.""" + tenant = Tenant(name=f"Tenant {tenant_id}") + tenant.id = tenant_id + if created_at is not None: + tenant.created_at = created_at + return tenant + + +def _create_app(app_id: str, tenant_id: str) -> App: + """Create a persisted app used to scope cleanup queries by tenant.""" + return App( + id=app_id, + tenant_id=tenant_id, + name=f"App {app_id}", + description="", + mode=AppMode.CHAT, + enable_site=True, + enable_api=True, + max_active_requests=0, + ) + + +def _create_conversation( + conversation_id: str, + app_id: str, + *, + updated_at: datetime.datetime, +) -> Conversation: + """Create a conversation with the fields required by backup serialization.""" + conversation = Conversation( + id=conversation_id, + app_id=app_id, + mode=AppMode.CHAT, + name=f"Conversation {conversation_id}", + status="normal", + from_source=ConversationFromSource.API, + from_end_user_id="end-user-1", + ) + conversation._inputs = {} + conversation.updated_at = updated_at + return conversation + + +def _create_message( + message_id: str, + app_id: str, + conversation_id: str, + *, + created_at: datetime.datetime, +) -> Message: + """Create a message with the fields required by backup serialization.""" + message = Message( + id=message_id, + app_id=app_id, + conversation_id=conversation_id, + query="question", + message={"role": "user", "content": "question"}, + answer="answer", + message_unit_price=Decimal("0.0001"), + answer_unit_price=Decimal("0.0002"), + currency="USD", + from_source=ConversationFromSource.API, + ) + message._inputs = {} + message.created_at = created_at + message.updated_at = created_at + return message + + +def _create_workflow_app_log( + log_id: str, + tenant_id: str, + app_id: str, + *, + created_at: datetime.datetime, +) -> WorkflowAppLog: + """Create a workflow app log eligible for retention cleanup.""" + log = WorkflowAppLog( + tenant_id=tenant_id, + app_id=app_id, + workflow_id="workflow-1", + workflow_run_id=f"run-{log_id}", + created_from=WorkflowAppLogCreatedFrom.SERVICE_API, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="account-1", + ) + log.id = log_id + log.created_at = created_at + return log + + +def _create_related_records(message_id: str) -> list[object]: + """Create one real row for every message-related table cleaned by the service.""" + return [ + MessageFeedback( + app_id="app-1", + conversation_id="conversation-1", + message_id=message_id, + rating=FeedbackRating.LIKE, + from_source=FeedbackFromSource.USER, + ), + MessageFile( + message_id=message_id, + type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + ), + MessageAnnotation( + app_id="app-1", + question="question", + content="answer", + account_id="account-1", + message_id=message_id, + ), + MessageChain(message_id=message_id, type=MessageChainType.SYSTEM, input="input", output="output"), + MessageAgentThought( + message_id=message_id, + position=1, + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + tool_labels_str="{}", + tool_meta_str="{}", + ), + AppAnnotationHitHistory( + app_id="app-1", + annotation_id="annotation-1", + source="annotation", + question="question", + account_id="account-1", + score=1.0, + message_id=message_id, + annotation_question="question", + annotation_content="answer", + ), + SavedMessage( + app_id="app-1", + message_id=message_id, + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + ), + ] + class TestClearFreePlanTenantExpiredLogs: - """Unit tests for ClearFreePlanTenantExpiredLogs._clear_message_related_tables method.""" + """Exercise message-related cleanup through a caller-owned SQLite transaction.""" - @pytest.fixture - def mock_session(self): - """Create a mock database session.""" - session = Mock(spec=Session) - session.scalars.return_value.all.return_value = [] - return session + def test_empty_message_ids_returns_without_touching_persisted_rows( + self, + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + record = MessageChain(message_id="msg-1", type=MessageChainType.SYSTEM, input="input", output="output") + sqlite_session.add(record) + sqlite_session.commit() + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) - @pytest.fixture - def mock_storage(self): - """Create a mock storage object.""" - storage = Mock() - storage.save.return_value = None - return storage + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", []) - @pytest.fixture - def sample_message_ids(self): - """Sample message IDs for testing.""" - return ["msg-1", "msg-2", "msg-3"] + assert sqlite_session.get(MessageChain, record.id) is not None + storage.save.assert_not_called() - @pytest.fixture - def sample_records(self): - """Sample records for testing.""" - records = [] - for i in range(3): - record = Mock() - record.id = f"record-{i}" - record.to_dict.return_value = { - "id": f"record-{i}", - "message_id": f"msg-{i}", - "created_at": datetime.datetime.now().isoformat(), - } - records.append(record) - return records + def test_no_related_records_skips_backup( + self, + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) - def test_clear_message_related_tables_empty_message_ids(self, mock_session): - """Test that method returns early when message_ids is empty.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", []) + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["missing-message"]) - # Should not call any database operations - mock_session.scalars.assert_not_called() - mock_storage.save.assert_not_called() + storage.save.assert_not_called() - def test_clear_message_related_tables_no_records_found(self, mock_session, sample_message_ids): - """Test when no related records are found.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.scalars.return_value.all.return_value = [] + def test_related_records_are_backed_up_and_deleted( + self, + sqlite_session: Session, + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + records = _create_related_records("msg-1") + sqlite_session.add_all(records) + sqlite_session.commit() + record_keys = [(type(record), record.id) for record in records] + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["msg-1"]) + sqlite_session.commit() - # Should call scalars for each related table but find no records - assert mock_session.scalars.call_count > 0 - mock_storage.save.assert_not_called() + assert storage.save.call_count == len(records) + backed_up_payloads = [json.loads(call.args[1]) for call in storage.save.call_args_list] + assert all(payload for payload in backed_up_payloads) + with Session(sqlite_engine) as verification_session: + assert all(verification_session.get(model, record_id) is None for model, record_id in record_keys) - def test_clear_message_related_tables_with_records_and_to_dict( - self, mock_session, sample_message_ids, sample_records - ): - """Test when records are found and have to_dict method.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.scalars.return_value.all.return_value = sample_records + def test_storage_failure_still_deletes_records( + self, + sqlite_session: Session, + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + record = MessageChain(message_id="msg-1", type=MessageChainType.SYSTEM, input="input", output="output") + sqlite_session.add(record) + sqlite_session.commit() + storage = MagicMock() + storage.save.side_effect = RuntimeError("storage error") + monkeypatch.setattr(service_module, "storage", storage) - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["msg-1"]) + sqlite_session.commit() - # Should call to_dict on each record (called once per table, so 7 times total) - for record in sample_records: - assert record.to_dict.call_count == 7 + with Session(sqlite_engine) as verification_session: + assert verification_session.get(MessageChain, record.id) is None - # Should save backup data - assert mock_storage.save.call_count > 0 + def test_serialization_failure_skips_backup_but_deletes_records( + self, + sqlite_session: Session, + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + record = SavedMessage( + app_id="app-1", + message_id="msg-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + ) + sqlite_session.add(record) + sqlite_session.commit() + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) + monkeypatch.setattr( + ClearFreePlanTenantExpiredLogs, + "_serialize_record", + MagicMock(side_effect=RuntimeError("serialization error")), + ) - def test_clear_message_related_tables_with_records_no_to_dict(self, mock_session, sample_message_ids): - """Test when records are found but don't have to_dict method.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - # Create records without to_dict method - records = [] - for i in range(2): - record = Mock() - mock_table = Mock() - mock_id_column = Mock() - mock_id_column.name = "id" - mock_message_id_column = Mock() - mock_message_id_column.name = "message_id" - mock_table.columns = [mock_id_column, mock_message_id_column] - record.__table__ = mock_table - record.id = f"record-{i}" - record.message_id = f"msg-{i}" - del record.to_dict - records.append(record) + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["msg-1"]) + sqlite_session.commit() - # Mock records for first table only, empty for others - mock_session.scalars.return_value.all.side_effect = [ - records, - [], - [], - [], - [], - [], - [], - ] + storage.save.assert_not_called() + with Session(sqlite_engine) as verification_session: + assert verification_session.get(SavedMessage, record.id) is None - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) + def test_deletion_is_scoped_to_requested_message_ids( + self, + sqlite_session: Session, + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + target = MessageChain(message_id="msg-1", type=MessageChainType.SYSTEM, input="input", output="output") + retained = MessageChain(message_id="msg-2", type=MessageChainType.SYSTEM, input="input", output="output") + sqlite_session.add_all([target, retained]) + sqlite_session.commit() + monkeypatch.setattr(service_module, "storage", MagicMock()) - # Should save backup data even without to_dict - assert mock_storage.save.call_count > 0 + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["msg-1"]) + sqlite_session.commit() - def test_clear_message_related_tables_storage_error_continues( - self, mock_session, sample_message_ids, sample_records - ): - """Test that method continues even when storage.save fails.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_storage.save.side_effect = Exception("Storage error") - - mock_session.scalars.return_value.all.return_value = sample_records - - # Should not raise exception - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - - # Should still delete records even if backup fails - assert mock_session.execute.called - - def test_clear_message_related_tables_serialization_error_continues(self, mock_session, sample_message_ids): - """Test that method continues even when record serialization fails.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - record = Mock() - record.id = "record-1" - record.to_dict.side_effect = Exception("Serialization error") - - mock_session.scalars.return_value.all.return_value = [record] - - # Should not raise exception - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - - # Should still delete records even if serialization fails - assert mock_session.execute.called - - def test_clear_message_related_tables_deletion_called(self, mock_session, sample_message_ids, sample_records): - """Test that deletion is called for found records.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.scalars.return_value.all.return_value = sample_records - - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - - # Should call execute(delete(...)) for each table that has records - assert mock_session.execute.called - - def test_clear_message_related_tables_all_serialization_fails_skips_backup_but_deletes( - self, mock_session, sample_message_ids - ): - record = Mock() - record.id = "record-1" - record.to_dict.side_effect = Exception("Serialization error") - - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.scalars.return_value.all.return_value = [record] - - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - - mock_storage.save.assert_not_called() - assert mock_session.execute.called + with Session(sqlite_engine) as verification_session: + assert verification_session.get(MessageChain, target.id) is None + assert verification_session.get(MessageChain, retained.id) is not None class _ImmediateFuture: - def __init__(self, fn, args, kwargs): + """Run submitted test work synchronously while preserving the Future interface.""" + + def __init__(self, fn: Callable[..., object], args: tuple[object, ...], kwargs: dict[str, object]) -> None: self._fn = fn self._args = args self._kwargs = kwargs - def result(self): + def result(self) -> object: return self._fn(*self._args, **self._kwargs) class _ImmediateExecutor: - def __init__(self, *args, **kwargs) -> None: - self.submitted: list[tuple[object, tuple[object, ...], dict[str, object]]] = [] + """Deterministic ThreadPoolExecutor replacement for orchestration tests.""" - def submit(self, fn, *args, **kwargs): + def __init__(self, *args: object, **kwargs: object) -> None: + self.submitted: list[tuple[Callable[..., object], tuple[object, ...], dict[str, object]]] = [] + + def submit(self, fn: Callable[..., object], *args: object, **kwargs: object) -> _ImmediateFuture: self.submitted.append((fn, args, kwargs)) return _ImmediateFuture(fn, args, kwargs) -def _session_wrapper_for_no_autoflush(session: Mock) -> Mock: - """ - Return an object with a no_autoflush context manager for legacy tests that need Session-like wrappers. - """ - cm = MagicMock() - cm.__enter__.return_value = session - cm.__exit__.return_value = None - - wrapper = MagicMock() - wrapper.no_autoflush = cm - return wrapper - - -def _sessionmaker_wrapper_for_begin(session: Mock) -> Mock: - """ - ClearFreePlanTenantExpiredLogs.process uses: with sessionmaker(db.engine).begin() as session: - so sessionmaker(db.engine) must return an object with a begin() method that returns a context manager. - """ - begin_cm = MagicMock() - begin_cm.__enter__.return_value = session - begin_cm.__exit__.return_value = None - - sessionmaker_result = MagicMock() - sessionmaker_result.begin.return_value = begin_cm - return sessionmaker_result - - -def _session_wrapper_for_direct(session: Mock) -> Mock: - """Return an object usable as a direct context manager for legacy Session-like test paths.""" - wrapper = MagicMock() - wrapper.__enter__.return_value = session - wrapper.__exit__.return_value = None - return wrapper - - -def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) -> None: +def _configure_process_boundaries(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> _ImmediateExecutor: + """Bind service-owned sessions to SQLite and make thread scheduling deterministic.""" + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=sqlite_engine)) flask_app = service_module.Flask("test-app") + monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) + executor = _ImmediateExecutor() + monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) + monkeypatch.setattr(service_module.click, "style", lambda message, **_kwargs: message) + return executor - app_session = MagicMock() - app_session.scalars.return_value.all.return_value = [SimpleNamespace(id="app-1"), SimpleNamespace(id="app-2")] - monkeypatch.setattr( - service_module, - "db", - SimpleNamespace(engine=object()), +def test_process_tenant_processes_and_persists_all_batches( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: + flask_app = service_module.Flask("test-app") + old = REAL_DATETIME.now() - datetime.timedelta(days=30) + recent = REAL_DATETIME.now() + sqlite_session.add_all( + [ + _create_app("app-1", "tenant-1"), + _create_app("app-2", "tenant-2"), + _create_conversation("conversation-old", "app-1", updated_at=old), + _create_conversation("conversation-recent", "app-1", updated_at=recent), + _create_conversation("conversation-other", "app-2", updated_at=old), + _create_message("message-old", "app-1", "conversation-old", created_at=old), + _create_message("message-recent", "app-1", "conversation-recent", created_at=recent), + _create_message("message-other", "app-2", "conversation-other", created_at=old), + _create_workflow_app_log("log-old", "tenant-1", "app-1", created_at=old), + _create_workflow_app_log("log-recent", "tenant-1", "app-1", created_at=recent), + _create_workflow_app_log("log-other", "tenant-2", "app-2", created_at=old), + ] ) - - mock_storage = MagicMock() - monkeypatch.setattr(service_module, "storage", mock_storage) + sqlite_session.commit() + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=sqlite_engine)) + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) - + monkeypatch.setattr(service_module.click, "style", lambda message, **_kwargs: message) clear_related = MagicMock() monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "_clear_message_related_tables", clear_related) - # Session sequence for messages, conversations, workflow_app_logs loops: - # - messages: one batch then empty - # - conversations: one batch then empty - # - workflow app logs: one batch then empty - msg1 = SimpleNamespace(id="m1", to_dict=lambda: {"id": "m1"}) - conv1 = SimpleNamespace(id="c1", to_dict=lambda: {"id": "c1"}) - log1 = SimpleNamespace(id="l1", to_dict=lambda: {"id": "l1"}) - - msg_session_1 = MagicMock() - msg_session_1.scalars.return_value.all.return_value = [msg1] - - msg_session_2 = MagicMock() - msg_session_2.scalars.return_value.all.return_value = [] - - conv_session_1 = MagicMock() - conv_session_1.scalars.return_value.all.return_value = [conv1] - - conv_session_2 = MagicMock() - conv_session_2.scalars.return_value.all.return_value = [] - - wal_session_1 = MagicMock() - wal_session_1.scalars.return_value.all.return_value = [log1] - - wal_session_2 = MagicMock() - wal_session_2.scalars.return_value.all.return_value = [] - - session_wrappers = [ - _sessionmaker_wrapper_for_begin(msg_session_1), - _sessionmaker_wrapper_for_begin(msg_session_2), - _sessionmaker_wrapper_for_begin(conv_session_1), - _sessionmaker_wrapper_for_begin(conv_session_2), - _sessionmaker_wrapper_for_begin(wal_session_1), - _sessionmaker_wrapper_for_begin(wal_session_2), - ] - - def fake_sessionmaker(*args, **kwargs): - if kwargs.get("autoflush") is False: - return session_wrappers.pop(0) - return object() - - monkeypatch.setattr(service_module, "sessionmaker", fake_sessionmaker) - - def fake_select(*_args, **_kwargs): - stmt = MagicMock() - stmt.where.return_value = stmt - return stmt - - monkeypatch.setattr(service_module, "select", fake_select) - - # Repositories for workflow node executions and workflow runs - node_execution = SimpleNamespace(id="ne-1") + node_execution = SimpleNamespace(id="node-execution-1") node_execution.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) - node_repo = MagicMock() node_repo.get_expired_executions_batch.side_effect = [[node_execution], []] node_repo.delete_executions_by_ids.return_value = 1 - run_repo = MagicMock() - run_repo.get_expired_runs_batch.side_effect = [[SimpleNamespace(id="wr-1", to_dict=lambda: {"id": "wr-1"})], []] + run_repo.get_expired_runs_batch.side_effect = [ + [SimpleNamespace(id="workflow-run-1", to_dict=lambda: {"id": "workflow-run-1"})], + [], + ] run_repo.delete_runs_by_ids.return_value = 1 monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_node_execution_repository", - lambda _sm: node_repo, + lambda _session_maker: node_repo, ) monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_run_repository", - lambda _sm: run_repo, + lambda _session_maker: run_repo, ) - ClearFreePlanTenantExpiredLogs.process_tenant(flask_app, "tenant-1", days=7, batch=10, session=app_session) + ClearFreePlanTenantExpiredLogs.process_tenant( + flask_app, + "tenant-1", + days=7, + batch=1, + session=sqlite_session, + ) - # messages backup, conversations backup, node executions backup, runs backup, workflow app logs backup - app_session.scalars.assert_called_once() - assert mock_storage.save.call_count >= 5 - clear_related.assert_called() + assert clear_related.call_count == 1 + related_session, related_tenant_id, message_ids = clear_related.call_args.args + assert isinstance(related_session, Session) + assert related_tenant_id == "tenant-1" + assert message_ids == ["message-old"] + assert storage.save.call_count == 5 + with Session(sqlite_engine) as verification_session: + assert verification_session.get(Message, "message-old") is None + assert verification_session.get(Conversation, "conversation-old") is None + assert verification_session.get(WorkflowAppLog, "log-old") is None + assert verification_session.get(Message, "message-recent") is not None + assert verification_session.get(Message, "message-other") is not None + assert verification_session.get(Conversation, "conversation-recent") is not None + assert verification_session.get(Conversation, "conversation-other") is not None + assert verification_session.get(WorkflowAppLog, "log-recent") is not None + assert verification_session.get(WorkflowAppLog, "log-other") is not None def test_serialize_record_falls_back_to_table_columns() -> None: - record = SimpleNamespace(id="ne-1", node_id="node-1") + record = SimpleNamespace(id="node-execution-1", node_id="node-1") record.__table__ = SimpleNamespace( columns=[ SimpleNamespace(name="id"), @@ -338,263 +454,240 @@ def test_serialize_record_falls_back_to_table_columns() -> None: ) assert ClearFreePlanTenantExpiredLogs._serialize_record(record) == { - "id": "ne-1", + "id": "node-execution-1", "node_id": "node-1", } def test_process_with_tenant_ids_filters_by_plan_and_logs_errors( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + sqlite_session: Session, + sqlite_engine: Engine, ) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) - - # Total tenant count query - count_session = MagicMock() - count_session.scalar.return_value = 2 - - monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: _sessionmaker_wrapper_for_begin(count_session)) - - # Avoid LocalProxy usage - flask_app = service_module.Flask("test-app") - monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) - - executor = _ImmediateExecutor() - monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) - - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) - echo_mock = MagicMock() - monkeypatch.setattr(service_module.click, "echo", echo_mock) - + sqlite_session.add_all( + [_create_tenant("tenant-sandbox"), _create_tenant("tenant-paid"), _create_tenant("tenant-fail")] + ) + sqlite_session.commit() + _configure_process_boundaries(monkeypatch, sqlite_engine) + monkeypatch.setattr(service_module.click, "echo", MagicMock()) monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", True) - def fake_get_info(tenant_id: str): - if tenant_id == "t_sandbox": + def fake_get_info(tenant_id: str) -> dict[str, dict[str, str]]: + if tenant_id == "tenant-sandbox": return {"subscription": {"plan": CloudPlan.SANDBOX}} - if tenant_id == "t_fail": - raise RuntimeError("boom") + if tenant_id == "tenant-fail": + raise RuntimeError("billing failure") return {"subscription": {"plan": "team"}} monkeypatch.setattr(service_module.BillingService, "get_info", staticmethod(fake_get_info)) - - process_tenant_mock = MagicMock(side_effect=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("err"))) - monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant_mock) + process_tenant = MagicMock(side_effect=RuntimeError("cleanup failure")) + monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) with caplog.at_level(logging.ERROR, logger=service_module.logger.name): - ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=["t_sandbox", "t_paid", "t_fail"]) + ClearFreePlanTenantExpiredLogs.process( + days=7, + batch=10, + tenant_ids=["tenant-sandbox", "tenant-paid", "tenant-fail"], + ) - # Only sandbox tenant should attempt processing, and its failure should be swallowed + logged. - assert process_tenant_mock.call_count == 1 - assert process_tenant_mock.call_args.args[4] is count_session - assert "Failed to process tenant t_sandbox" in caplog.messages - assert "Failed to process tenant t_fail" in caplog.messages + assert process_tenant.call_count == 1 + owned_session = process_tenant.call_args.args[4] + assert isinstance(owned_session, Session) + assert owned_session.get_bind() is sqlite_engine + assert "Failed to process tenant tenant-sandbox" in caplog.messages + assert "Failed to process tenant tenant-fail" in caplog.messages -def test_process_without_tenant_ids_batches_and_scales_interval(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) - - started_at = datetime.datetime(2023, 4, 3, 8, 59, 24) +def test_process_without_tenant_ids_batches_and_scales_interval( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: + started_at = REAL_DATETIME(2023, 4, 3, 8, 59, 24) fixed_now = started_at + datetime.timedelta(hours=2) + selected_tenants = [ + _create_tenant("tenant-a", created_at=started_at + datetime.timedelta(minutes=30)), + _create_tenant("tenant-b", created_at=started_at + datetime.timedelta(hours=1)), + ] + future_tenants = [ + _create_tenant(f"future-{index}", created_at=started_at + datetime.timedelta(hours=4)) for index in range(100) + ] + sqlite_session.add_all([*selected_tenants, *future_tenants]) + sqlite_session.commit() - class FixedDateTime(datetime.datetime): + class FixedDateTime(REAL_DATETIME): @classmethod - def now(cls, tz=None): + def now(cls, tz: datetime.tzinfo | None = None) -> REAL_DATETIME: return fixed_now monkeypatch.setattr(service_module.datetime, "datetime", FixedDateTime) - - # Avoid LocalProxy usage - flask_app = service_module.Flask("test-app") - monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) - - executor = _ImmediateExecutor() - monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) - - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) + _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - - # Sessions used: - # 1) total tenant count - # 2) per-batch tenant scan (interval counts + tenant list) - total_session = MagicMock() - total_session.scalar.return_value = 250 - - rows = [SimpleNamespace(id="tenant-a"), SimpleNamespace(id="tenant-b")] - batch_session = MagicMock() - # 4 test intervals queried: 200, 200, 200, 50 — breaks on 50 <= 100 (4th interval = 3h) - batch_session.scalar.side_effect = [200, 200, 200, 50] - batch_session.execute.return_value = rows - - tenant_session_a = MagicMock() - tenant_session_b = MagicMock() - sessions = [ - _sessionmaker_wrapper_for_begin(total_session), - _sessionmaker_wrapper_for_begin(batch_session), - _sessionmaker_wrapper_for_begin(tenant_session_a), - _sessionmaker_wrapper_for_begin(tenant_session_b), - ] - monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: sessions.pop(0)) - - process_tenant_mock = MagicMock() - monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant_mock) - - ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=[]) - - # Should submit/process tenants from the batch query - assert process_tenant_mock.call_count == 2 - - -def test_process_with_tenant_ids_emits_progress_every_100(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) - - count_session = MagicMock() - count_session.scalar.return_value = 100 - monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: _sessionmaker_wrapper_for_begin(count_session)) - - flask_app = service_module.Flask("test-app") - monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) + process_tenant = MagicMock() + monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) + statements: list[str] = [] - executor = _ImmediateExecutor() - monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) + def record_statement( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + statements.append(statement) - echo_mock = MagicMock() - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) - monkeypatch.setattr(service_module.click, "echo", echo_mock) + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + try: + ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=[]) + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) + assert {call.args[1] for call in process_tenant.call_args_list} == {"tenant-a", "tenant-b"} + interval_counts = [ + statement + for statement in statements + if "count(tenants.id)" in statement.lower() and "between" in statement.lower() + ] + assert len(interval_counts) == 4 + assert all(isinstance(call.args[4], Session) for call in process_tenant.call_args_list) + + +def test_process_with_tenant_ids_emits_progress_every_100( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: + tenant_ids = [f"tenant-{index}" for index in range(100)] + sqlite_session.add_all([_create_tenant(tenant_id) for tenant_id in tenant_ids]) + sqlite_session.commit() + _configure_process_boundaries(monkeypatch, sqlite_engine) + monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) + echo = MagicMock() + monkeypatch.setattr(service_module.click, "echo", echo) monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", MagicMock()) - tenant_ids = [f"t{i}" for i in range(100)] ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=tenant_ids) - assert any("Processed 100 tenants" in str(call.args[0]) for call in echo_mock.call_args_list) + assert any("Processed 100 tenants" in str(call.args[0]) for call in echo.call_args_list) -def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) - - started_at = datetime.datetime(2023, 4, 3, 8, 59, 24) - # Keep the total range smaller than the minimum interval (1 hour) so the loop runs once. +def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: + started_at = REAL_DATETIME(2023, 4, 3, 8, 59, 24) fixed_now = started_at + datetime.timedelta(minutes=30) + sqlite_session.add(_create_tenant("tenant-in-range", created_at=started_at + datetime.timedelta(minutes=15))) + sqlite_session.add_all( + [ + _create_tenant(f"later-{index}", created_at=started_at + datetime.timedelta(minutes=45)) + for index in range(100) + ] + ) + sqlite_session.commit() - class FixedDateTime(datetime.datetime): + class FixedDateTime(REAL_DATETIME): @classmethod - def now(cls, tz=None): + def now(cls, tz: datetime.tzinfo | None = None) -> REAL_DATETIME: return fixed_now monkeypatch.setattr(service_module.datetime, "datetime", FixedDateTime) - - flask_app = service_module.Flask("test-app") - monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) - - executor = _ImmediateExecutor() - monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) - - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) + _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) + process_tenant = MagicMock() + monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) + statements: list[str] = [] - total_session = MagicMock() - total_session.scalar.return_value = 250 + def record_statement( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + statements.append(statement) - rows = [SimpleNamespace(id="tenant-a")] - batch_session = MagicMock() - # All 5 intervals have > 100 tenants => for-else falls through to min interval (1h) - batch_session.scalar.side_effect = [200, 200, 200, 200, 200] - batch_session.execute.return_value = rows + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + try: + ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=[]) + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) - tenant_session = MagicMock() - sessions = [ - _sessionmaker_wrapper_for_begin(total_session), - _sessionmaker_wrapper_for_begin(batch_session), - _sessionmaker_wrapper_for_begin(tenant_session), + assert [call.args[1] for call in process_tenant.call_args_list] == ["tenant-in-range"] + interval_counts = [ + statement + for statement in statements + if "count(tenants.id)" in statement.lower() and "between" in statement.lower() ] - monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: sessions.pop(0)) - - process_tenant_mock = MagicMock() - monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant_mock) - - ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=[]) - - assert process_tenant_mock.call_count == 1 - assert batch_session.scalar.call_count == 5 + assert len(interval_counts) == 5 -def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pytest.MonkeyPatch) -> None: +def test_process_tenant_repo_loops_break_on_empty_second_batch( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: flask_app = service_module.Flask("test-app") - - app_session = MagicMock() - app_session.scalars.return_value.all.return_value = [SimpleNamespace(id="app-1")] - - monkeypatch.setattr( - service_module, - "db", - SimpleNamespace(engine=object()), - ) - mock_storage = MagicMock() - monkeypatch.setattr(service_module, "storage", mock_storage) + sqlite_session.add(_create_app("app-1", "tenant-1")) + sqlite_session.commit() + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=sqlite_engine)) + monkeypatch.setattr(service_module, "storage", MagicMock()) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) + monkeypatch.setattr(service_module.click, "style", lambda message, **_kwargs: message) monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "_clear_message_related_tables", MagicMock()) - # Make message/conversation/workflow_app_log loops no-op (empty immediately) - empty_session = MagicMock() - empty_session.scalars.return_value.all.return_value = [] - session_wrappers = [ - _sessionmaker_wrapper_for_begin(empty_session), - _sessionmaker_wrapper_for_begin(empty_session), - _sessionmaker_wrapper_for_begin(empty_session), - ] - - def fake_sessionmaker(*args, **kwargs): - if kwargs.get("autoflush") is False: - return session_wrappers.pop(0) - return object() - - monkeypatch.setattr(service_module, "sessionmaker", fake_sessionmaker) - - def fake_select(*_args, **_kwargs): - stmt = MagicMock() - stmt.where.return_value = stmt - return stmt - - monkeypatch.setattr(service_module, "select", fake_select) - - # Repos: first returns exactly batch items -> no "< batch" break, second returns [] -> hit the len==0 break. - node_execution_1 = SimpleNamespace(id="ne-1") - node_execution_1.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) - node_execution_2 = SimpleNamespace(id="ne-2") - node_execution_2.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) - + node_executions = [SimpleNamespace(id="node-1"), SimpleNamespace(id="node-2")] + for node_execution in node_executions: + node_execution.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) node_repo = MagicMock() - node_repo.get_expired_executions_batch.side_effect = [ - [node_execution_1, node_execution_2], - [], - ] + node_repo.get_expired_executions_batch.side_effect = [node_executions, []] node_repo.delete_executions_by_ids.return_value = 2 - run_repo = MagicMock() run_repo.get_expired_runs_batch.side_effect = [ [ - SimpleNamespace(id="wr-1", to_dict=lambda: {"id": "wr-1"}), - SimpleNamespace(id="wr-2", to_dict=lambda: {"id": "wr-2"}), + SimpleNamespace(id="run-1", to_dict=lambda: {"id": "run-1"}), + SimpleNamespace(id="run-2", to_dict=lambda: {"id": "run-2"}), ], [], ] run_repo.delete_runs_by_ids.return_value = 2 + node_session_makers: list[object] = [] + run_session_makers: list[object] = [] + + def create_node_repo(session_maker: object) -> MagicMock: + node_session_makers.append(session_maker) + return node_repo + + def create_run_repo(session_maker: object) -> MagicMock: + run_session_makers.append(session_maker) + return run_repo + monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_node_execution_repository", - lambda _sm: node_repo, + create_node_repo, ) monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_run_repository", - lambda _sm: run_repo, + create_run_repo, ) - ClearFreePlanTenantExpiredLogs.process_tenant(flask_app, "tenant-1", days=7, batch=2, session=app_session) + ClearFreePlanTenantExpiredLogs.process_tenant( + flask_app, + "tenant-1", + days=7, + batch=2, + session=sqlite_session, + ) - app_session.scalars.assert_called_once() assert node_repo.get_expired_executions_batch.call_count == 2 assert run_repo.get_expired_runs_batch.call_count == 2 + assert node_session_makers[0].kw["bind"] is sqlite_engine + assert run_session_makers[0].kw["bind"] is sqlite_engine 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 ffc52b5c369..c2b8b392573 100644 --- a/api/tests/unit_tests/services/test_external_dataset_service.py +++ b/api/tests/unit_tests/services/test_external_dataset_service.py @@ -613,6 +613,31 @@ class TestExternalDatasetServiceCheckEndpoint: # Act & Assert - should not raise ExternalDatasetService.check_endpoint_and_api_key(settings) + @patch("services.external_knowledge_service.ssrf_proxy") + def test_check_endpoint_sends_json_body(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): + """Regression for #39402: the validation probe must POST a JSON body matching the + External Knowledge API retrieval contract, not a body-less request that providers + such as RAGFlow reject (empty POST -> 502 ERR_ZERO_SIZE_OBJECT).""" + # Arrange + settings = {"endpoint": "https://api.example.com", "api_key": "test-key"} + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_proxy.post.return_value = mock_response + + # Act + ExternalDatasetService.check_endpoint_and_api_key(settings) + + # Assert - a non-empty JSON body is sent with the JSON content type + mock_proxy.post.assert_called_once() + _, call_kwargs = mock_proxy.post.call_args + assert call_kwargs["headers"]["Content-Type"] == "application/json" + assert call_kwargs["headers"]["Authorization"] == "Bearer test-key" + sent_body = json.loads(call_kwargs["data"]) + assert "knowledge_id" in sent_body + assert "query" in sent_body + assert sent_body["retrieval_setting"] == {"top_k": 1, "score_threshold": 0.0} + def test_check_endpoint_missing_endpoint_key(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when endpoint key is missing.""" # Arrange diff --git a/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py b/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py new file mode 100644 index 00000000000..63bfc739659 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py @@ -0,0 +1,19 @@ +import pytest + +from services.feature_service import FeatureService, SystemFeatureModel + + +def test_system_feature_model_disables_knowledge_fs_by_default() -> None: + assert SystemFeatureModel().knowledge_fs_enabled is False + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_get_system_features_reads_knowledge_fs_flag( + monkeypatch: pytest.MonkeyPatch, + enabled: bool, +) -> None: + monkeypatch.setattr("services.feature_service.dify_config.KNOWLEDGE_FS_ENABLED", enabled) + + result = FeatureService.get_system_features() + + assert result.knowledge_fs_enabled is enabled diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index 1954b4d8f0e..583509e20db 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -203,6 +203,33 @@ class TestFileService: with pytest.raises(NotFound, match="File not found"): file_service.get_file_base64("non_existent") + def test_get_file_presigned_url_success(self, file_service: FileService, mock_db_session): + upload_file = MagicMock(spec=UploadFile) + upload_file.key = "upload_files/tenant_id/icon.png" + upload_file.mime_type = "image/png" + mock_db_session.scalar.return_value = upload_file + + with ( + patch.object(dify_config, "FILES_ACCESS_TIMEOUT", 300), + patch("services.file_service.storage") as mock_storage, + ): + mock_storage.generate_presigned_url.return_value = "https://s3.example.com/icon.png?signature=test" + + result = file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id") + + assert result == "https://s3.example.com/icon.png?signature=test" + mock_storage.generate_presigned_url.assert_called_once_with( + "upload_files/tenant_id/icon.png", + expires_in=300, + content_type="image/png", + ) + + def test_get_file_presigned_url_not_found(self, file_service: FileService, mock_db_session): + mock_db_session.scalar.return_value = None + + with pytest.raises(NotFound, match="File not found"): + file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id") + def test_upload_text_success(self, file_service: FileService, mock_db_session): # Setup text = "sample text" diff --git a/api/tests/unit_tests/services/test_knowledge_fs_proxy.py b/api/tests/unit_tests/services/test_knowledge_fs_proxy.py index 8ffc18d0eef..1033c76f13d 100644 --- a/api/tests/unit_tests/services/test_knowledge_fs_proxy.py +++ b/api/tests/unit_tests/services/test_knowledge_fs_proxy.py @@ -10,16 +10,21 @@ from pydantic import SecretStr from core.helper import ssrf_proxy from core.rbac import RBACPermission from core.tools.errors import ToolSSRFError -from services.knowledge_fs_proxy import ( +from services.knowledge_fs_operations import ( KNOWLEDGE_FS_CONSOLE_OPERATIONS, - KnowledgeFSAccessDeniedError, - KnowledgeFSConfigurationError, KnowledgeFSMethod, + KnowledgeFSOperation, +) +from services.knowledge_fs_proxy import ( + KnowledgeFSAccessDeniedError, + KnowledgeFSAuthorization, + KnowledgeFSConfigurationError, KnowledgeFSRouteNotAllowedError, KnowledgeFSTimeoutError, KnowledgeFSTransportError, authorize_knowledge_fs_request, get_knowledge_fs_operation, + proxy_authorized_knowledge_fs_request, proxy_knowledge_fs_request, ) from services.knowledge_fs_proxy import ( @@ -28,16 +33,178 @@ from services.knowledge_fs_proxy import ( _JWT_SECRET = "production-secret-with-at-least-32-bytes" +_HAPPY_PATH_OPERATION_IDS = ( + "listKnowledgeSpaces", + "createKnowledgeSpace", + "getKnowledgeSpacesById", + "getKnowledgeSpacesByIdAccessPolicy", + "patchKnowledgeSpacesByIdAccessPolicy", + "getSourceProviders", + "getKnowledgeSpacesByIdSourceConnections", + "postKnowledgeSpacesByIdSourceConnections", + "postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh", + "getKnowledgeSpacesByIdSources", + "postKnowledgeSpacesByIdSources", + "postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview", + "getKnowledgeSpacesByIdSourceWorkflowsByRunId", + "getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages", + "postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel", + "postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry", + "postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection", + "getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy", + "putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy", + "getKnowledgeSpacesByIdLogicalDocuments", + "getKnowledgeSpacesByIdLogicalDocumentsByDocumentId", + "getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions", + "getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunks", + "getKnowledgeSpacesByIdProcessingTasks", + "getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents", + "deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId", + "postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry", +) + +_REQUIRED_EXPANDED_HAPPY_PATH_OPERATION_IDS = { + "patchKnowledgeSpacesById", + "deleteKnowledgeSpacesById", + "getKnowledgeSpacesByIdStats", + "postKnowledgeSpacesByIdSourceConnectionsOauth", + "postSourceOauthCallback", + "getKnowledgeSpacesByIdSourceConnectionsByConnectionId", + "deleteKnowledgeSpacesByIdSourceConnectionsByConnectionId", + "getKnowledgeSpacesByIdSourcesBySourceId", + "patchKnowledgeSpacesByIdSourcesBySourceId", + "deleteKnowledgeSpacesByIdSourcesBySourceId", + "putKnowledgeSpacesByIdSourcesBySourceIdCredentials", + "deleteKnowledgeSpacesByIdSourcesBySourceIdCredentials", + "postKnowledgeSpacesByIdSourcesBySourceIdSync", + "postKnowledgeSpacesByIdSourcesBySourceIdWorkflowImports", + "getKnowledgeSpacesByIdSourcesBySourceIdPages", + "getKnowledgeSpacesByIdSourcesBySourceIdFiles", + "postKnowledgeSpacesByIdSourcesBySourceIdCrawl", + "postKnowledgeSpacesByIdSourcesBySourceIdImport", + "postKnowledgeSpacesByIdSourcesBySourceIdTest", + "postKnowledgeSpacesByIdSourcesBySourceIdImportFiles", + "postKnowledgeSpacesByIdSourcesBulk", + "getKnowledgeSpacesByIdSourceWorkflows", + "getKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItems", + "getKnowledgeSpacesByIdDocuments", + "postKnowledgeSpacesByIdDocuments", + "deleteKnowledgeSpacesByIdDocumentsBulk", + "postKnowledgeSpacesByIdDocumentsBulk", + "postKnowledgeSpacesByIdDocumentsBulkReindex", + "getKnowledgeSpacesByIdDocumentsByDocumentId", + "deleteKnowledgeSpacesByIdDocumentsByDocumentId", + "deleteKnowledgeSpacesByIdLogicalDocumentsByDocumentId", + "getKnowledgeSpacesByIdDocumentsByDocumentIdOutline", + "postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollback", + "patchKnowledgeSpacesByIdDocumentsByDocumentIdMetadata", + "getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkId", + "postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdState", + "getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasks", + "getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId", + "getKnowledgeSpacesByIdDocumentsByDocumentIdSettings", + "putKnowledgeSpacesByIdDocumentsByDocumentIdSettings", + "getJobsById", + "deleteJobsById", + "postJobsByIdRetry", + "getDeletionJobsByJobId", + "postDeletionJobsByJobIdRetry", + "getBulkJobsById", +} + +_EXPANDED_EXTERNAL_SOURCE_OPERATION_IDS = { + operation_id + for operation_id in _REQUIRED_EXPANDED_HAPPY_PATH_OPERATION_IDS + if "Source" in operation_id or operation_id == "postSourceOauthCallback" +} + +_OPERATION_AUTHORIZATION_POLICIES = { + "listKnowledgeSpaces": (RBACPermission.DATASET_READONLY, "reader"), + "createKnowledgeSpace": (RBACPermission.DATASET_CREATE_AND_MANAGEMENT, "dataset_editor"), + "getKnowledgeSpacesById": (RBACPermission.DATASET_READONLY, "reader"), + "getKnowledgeSpacesByIdAccessPolicy": (RBACPermission.DATASET_READONLY, "reader"), + "patchKnowledgeSpacesByIdAccessPolicy": (RBACPermission.DATASET_ACCESS_CONFIG, "admin"), + "getSourceProviders": (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor"), + "getKnowledgeSpacesByIdSourceConnections": (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor"), + "postKnowledgeSpacesByIdSourceConnections": (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor"), + "postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh": ( + RBACPermission.DATASET_EXTERNAL_CONNECT, + "dataset_editor", + ), + "getKnowledgeSpacesByIdSources": (RBACPermission.DATASET_READONLY, "reader"), + "postKnowledgeSpacesByIdSources": (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor"), + "postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview": ( + RBACPermission.DATASET_EXTERNAL_CONNECT, + "dataset_editor", + ), + "getKnowledgeSpacesByIdSourceWorkflowsByRunId": ( + RBACPermission.DATASET_EXTERNAL_CONNECT, + "dataset_editor", + ), + "getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages": ( + RBACPermission.DATASET_EXTERNAL_CONNECT, + "dataset_editor", + ), + "postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel": ( + RBACPermission.DATASET_EXTERNAL_CONNECT, + "dataset_editor", + ), + "postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry": ( + RBACPermission.DATASET_EXTERNAL_CONNECT, + "dataset_editor", + ), + "postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection": ( + RBACPermission.DATASET_EXTERNAL_CONNECT, + "dataset_editor", + ), + "getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy": (RBACPermission.DATASET_READONLY, "reader"), + "putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy": (RBACPermission.DATASET_EDIT, "dataset_editor"), + "getKnowledgeSpacesByIdLogicalDocuments": (RBACPermission.DATASET_READONLY, "reader"), + "getKnowledgeSpacesByIdLogicalDocumentsByDocumentId": (RBACPermission.DATASET_READONLY, "reader"), + "getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions": (RBACPermission.DATASET_READONLY, "reader"), + "getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunks": ( + RBACPermission.DATASET_READONLY, + "reader", + ), + "getKnowledgeSpacesByIdProcessingTasks": (RBACPermission.DATASET_READONLY, "reader"), + "getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents": ( + RBACPermission.DATASET_READONLY, + "reader", + ), + "deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId": ( + RBACPermission.DATASET_EDIT, + "dataset_editor", + ), + "postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry": ( + RBACPermission.DATASET_EDIT, + "dataset_editor", + ), +} + + +def _materialized_path(operation: KnowledgeFSOperation) -> str: + segments = [] + for segment in operation.path.split("/"): + if segment == "{revision}": + segments.append("1") + elif segment.startswith("{"): + segments.append("00000000-0000-4000-8000-000000000001") + else: + segments.append(segment) + return "/".join(segments) + def _set_config( monkeypatch: pytest.MonkeyPatch, *, base_url: str | None = "http://knowledge-fs.test", + sse_read_timeout_seconds: float = 90.0, timeout_seconds: float = 7.5, jwt_secret: str | None = _JWT_SECRET, ) -> None: values = { "KNOWLEDGE_FS_BASE_URL": base_url, + "KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS": sse_read_timeout_seconds, "KNOWLEDGE_FS_TIMEOUT_SECONDS": timeout_seconds, "KNOWLEDGE_FS_JWT_SECRET": SecretStr(jwt_secret) if jwt_secret is not None else None, } @@ -45,43 +212,68 @@ def _set_config( monkeypatch.setattr(f"services.knowledge_fs_proxy.dify_config.{name}", value, raising=False) -def test_console_registry_starts_with_list_and_create_operations() -> None: - assert tuple(operation.operation_id for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS) == ( - "listKnowledgeSpaces", - "createKnowledgeSpace", +def _processing_task_events_path() -> str: + operation = next( + operation + for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS + if operation.operation_id == "getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents" + ) + return _materialized_path(operation) + + +def test_console_registry_exposes_only_the_new_rag_happy_path_operations() -> None: + operation_ids = {operation.operation_id for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS} + + assert operation_ids == set(_HAPPY_PATH_OPERATION_IDS) | _REQUIRED_EXPANDED_HAPPY_PATH_OPERATION_IDS + + +def test_console_registry_exposes_existing_upstream_contracts_needed_by_all_happy_path_pages() -> None: + operation_ids = {operation.operation_id for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS} + + assert operation_ids >= _REQUIRED_EXPANDED_HAPPY_PATH_OPERATION_IDS + + +def test_console_registry_preserves_explicit_scope_and_authorization_policies() -> None: + for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS: + is_read = operation.method == "GET" + assert operation.required_scope == f"knowledge-spaces:{'read' if is_read else 'write'}" + expected_policy = _OPERATION_AUTHORIZATION_POLICIES.get(operation.operation_id) + if expected_policy is None and operation.operation_id in _EXPANDED_EXTERNAL_SOURCE_OPERATION_IDS: + expected_policy = (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor") + if expected_policy is None: + expected_policy = ( + (RBACPermission.DATASET_READONLY, "reader") + if is_read + else (RBACPermission.DATASET_EDIT, "dataset_editor") + ) + assert (operation.rbac_permission, operation.legacy_role) == expected_policy + assert operation.response_headers == ("x-trace-id",) + + +def test_console_registry_preserves_special_transport_contracts() -> None: + crawl_preview = get_knowledge_fs_operation( + "POST", + "knowledge-spaces/00000000-0000-4000-8000-000000000001/sources/" + "00000000-0000-4000-8000-000000000002/crawl-preview", + ) + selection = get_knowledge_fs_operation( + "POST", + "knowledge-spaces/00000000-0000-4000-8000-000000000001/source-workflows/" + "00000000-0000-4000-8000-000000000002/selection", + ) + events = get_knowledge_fs_operation( + "GET", + "knowledge-spaces/00000000-0000-4000-8000-000000000001/documents/" + "00000000-0000-4000-8000-000000000002/processing-tasks/" + "00000000-0000-4000-8000-000000000003/events", ) - -@pytest.mark.parametrize( - ("method", "operation_id", "scope", "permission", "requires_dataset_editor"), - [ - ("GET", "listKnowledgeSpaces", "knowledge-spaces:read", RBACPermission.DATASET_READONLY, False), - ( - "POST", - "createKnowledgeSpace", - "knowledge-spaces:write", - RBACPermission.DATASET_CREATE_AND_MANAGEMENT, - True, - ), - ], -) -def test_console_registry_preserves_contract_and_policy( - method: KnowledgeFSMethod, - operation_id: str, - scope: str, - permission: RBACPermission, - requires_dataset_editor: bool, -) -> None: - operation = get_knowledge_fs_operation(method, "knowledge-spaces") - - assert operation.operation_id == operation_id - assert operation.required_scope == scope - assert operation.rbac_permission == permission - assert operation.requires_dataset_editor is requires_dataset_editor - assert operation.max_response_bytes == 1_048_576 - assert operation.request_headers == ("x-trace-id",) - assert operation.response_headers == ("x-trace-id",) - assert operation.response_media_types == ("application/json",) + assert crawl_preview.request_headers == ("idempotency-key", "x-trace-id") + assert selection.request_headers == ("idempotency-key", "x-trace-id") + assert events.response_kind == "stream" + assert events.max_response_bytes == 67_108_864 + assert events.request_headers == ("last-event-id", "x-trace-id") + assert events.response_media_types == ("text/event-stream",) def test_unconfigured_kfs_is_rejected_before_external_io(monkeypatch: pytest.MonkeyPatch) -> None: @@ -148,7 +340,112 @@ def test_proxy_forwards_only_registry_declared_headers(monkeypatch: pytest.Monke assert forward.call_args.kwargs["request_headers"] == {"x-trace-id": "trace-1"} -def test_authorization_rejects_workspace_rbac_denial(monkeypatch: pytest.MonkeyPatch) -> None: +def test_authorized_proxy_does_not_repeat_workspace_rbac(monkeypatch: pytest.MonkeyPatch) -> None: + account = MagicMock(id="account-1", is_dataset_editor=True) + check_access = MagicMock(return_value=True) + forward = MagicMock(return_value=MagicMock()) + monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access) + monkeypatch.setattr("services.knowledge_fs_proxy._forward_knowledge_fs_request", forward) + operation = get_knowledge_fs_operation("POST", "knowledge-spaces") + authorization = authorize_knowledge_fs_request( + account=account, + tenant_id="tenant-1", + method=operation.method, + path=_materialized_path(operation), + ) + + proxy_authorized_knowledge_fs_request(authorization=authorization) + + check_access.assert_called_once() + assert forward.call_args.kwargs["account_id"] == "account-1" + assert forward.call_args.kwargs["tenant_id"] == "tenant-1" + assert forward.call_args.kwargs["method"] == "POST" + assert forward.call_args.kwargs["path"] == "knowledge-spaces" + + +def test_authorization_capability_cannot_be_constructed_directly() -> None: + operation = get_knowledge_fs_operation("POST", "knowledge-spaces") + + with pytest.raises(KnowledgeFSAccessDeniedError, match="must be created by workspace authorization"): + KnowledgeFSAuthorization("account-1", "tenant-1", operation) + + +def test_authorization_resolves_the_canonical_operation_policy(monkeypatch: pytest.MonkeyPatch) -> None: + account = MagicMock(id="account-1", is_dataset_editor=False, is_admin_or_owner=False) + check_access = MagicMock(return_value=True) + monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access) + + with pytest.raises(KnowledgeFSAccessDeniedError, match="dataset edit access"): + authorize_knowledge_fs_request( + account=account, + tenant_id="tenant-1", + method="POST", + path="knowledge-spaces", + ) + + check_access.assert_not_called() + + +@pytest.mark.parametrize( + ("attribute", "value"), + [ + ("account_id", "account-2"), + ("tenant_id", "tenant-2"), + ("operation", get_knowledge_fs_operation("GET", "knowledge-spaces")), + ], +) +def test_authorization_capability_binding_cannot_be_mutated( + monkeypatch: pytest.MonkeyPatch, + attribute: str, + value: object, +) -> None: + account = MagicMock(id="account-1", is_dataset_editor=True) + monkeypatch.setattr( + "services.knowledge_fs_proxy.RBACService.CheckAccess.check", + MagicMock(return_value=True), + ) + authorization = authorize_knowledge_fs_request( + account=account, + tenant_id="tenant-1", + method="POST", + path="knowledge-spaces", + ) + + with pytest.raises(AttributeError): + setattr(authorization, attribute, value) + + assert authorization.account_id == "account-1" + assert authorization.tenant_id == "tenant-1" + assert authorization.operation == get_knowledge_fs_operation("POST", "knowledge-spaces") + + +def test_authorization_capability_cannot_be_reused(monkeypatch: pytest.MonkeyPatch) -> None: + account = MagicMock(id="account-1", is_dataset_editor=True) + forward = MagicMock(return_value=MagicMock()) + monkeypatch.setattr( + "services.knowledge_fs_proxy.RBACService.CheckAccess.check", + MagicMock(return_value=True), + ) + monkeypatch.setattr("services.knowledge_fs_proxy._forward_knowledge_fs_request", forward) + authorization = authorize_knowledge_fs_request( + account=account, + tenant_id="tenant-1", + method="POST", + path="knowledge-spaces", + ) + + proxy_authorized_knowledge_fs_request(authorization=authorization) + + with pytest.raises(KnowledgeFSAccessDeniedError, match="already been used"): + proxy_authorized_knowledge_fs_request(authorization=authorization) + forward.assert_called_once() + + +@pytest.mark.parametrize("operation", KNOWLEDGE_FS_CONSOLE_OPERATIONS, ids=lambda operation: operation.operation_id) +def test_authorization_rejects_workspace_rbac_denial( + monkeypatch: pytest.MonkeyPatch, + operation: KnowledgeFSOperation, +) -> None: account = MagicMock(id="account-1", is_dataset_editor=True) check_access = MagicMock(return_value=False) monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access) @@ -157,19 +454,28 @@ def test_authorization_rejects_workspace_rbac_denial(monkeypatch: pytest.MonkeyP authorize_knowledge_fs_request( account=account, tenant_id="tenant-1", - operation=get_knowledge_fs_operation("GET", "knowledge-spaces"), + method=operation.method, + path=_materialized_path(operation), ) check_access.assert_called_once_with( "tenant-1", "account-1", - scene="dataset_readonly", + scene=operation.rbac_permission.value, resource_type="dataset", ) -def test_create_rejects_non_dataset_editor_before_rbac(monkeypatch: pytest.MonkeyPatch) -> None: - account = MagicMock(id="account-1", is_dataset_editor=False) +@pytest.mark.parametrize( + "operation", + tuple(operation for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS if operation.legacy_role == "dataset_editor"), + ids=lambda operation: operation.operation_id, +) +def test_dataset_editor_operations_reject_legacy_viewers_before_rbac( + monkeypatch: pytest.MonkeyPatch, + operation: KnowledgeFSOperation, +) -> None: + account = MagicMock(id="account-1", is_dataset_editor=False, is_admin_or_owner=False) check_access = MagicMock(return_value=True) monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access) @@ -177,41 +483,66 @@ def test_create_rejects_non_dataset_editor_before_rbac(monkeypatch: pytest.Monke authorize_knowledge_fs_request( account=account, tenant_id="tenant-1", - operation=get_knowledge_fs_operation("POST", "knowledge-spaces"), + method=operation.method, + path=_materialized_path(operation), ) check_access.assert_not_called() -def test_authorization_uses_the_declared_editor_policy(monkeypatch: pytest.MonkeyPatch) -> None: - account = MagicMock(id="account-1", is_dataset_editor=False) +def test_admin_operation_rejects_legacy_editors_before_rbac(monkeypatch: pytest.MonkeyPatch) -> None: + account = MagicMock(id="account-1", is_dataset_editor=True, is_admin_or_owner=False) check_access = MagicMock(return_value=True) monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access) - operation = get_knowledge_fs_operation("POST", "knowledge-spaces")._replace(requires_dataset_editor=False) + operation = get_knowledge_fs_operation( + "PATCH", "knowledge-spaces/00000000-0000-4000-8000-000000000001/access-policy" + ) - authorize_knowledge_fs_request(account=account, tenant_id="tenant-1", operation=operation) + with pytest.raises(KnowledgeFSAccessDeniedError, match="administration access"): + authorize_knowledge_fs_request( + account=account, + tenant_id="tenant-1", + method=operation.method, + path=_materialized_path(operation), + ) + + check_access.assert_not_called() + + +def test_authorization_uses_the_declared_reader_policy(monkeypatch: pytest.MonkeyPatch) -> None: + account = MagicMock(id="account-1", is_dataset_editor=False, is_admin_or_owner=False) + check_access = MagicMock(return_value=True) + monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access) + operation = get_knowledge_fs_operation("GET", "knowledge-spaces") + + authorize_knowledge_fs_request( + account=account, + tenant_id="tenant-1", + method=operation.method, + path=_materialized_path(operation), + ) check_access.assert_called_once() -@pytest.mark.parametrize( - ("method", "expected_scope"), - [("GET", "knowledge-spaces:read"), ("POST", "knowledge-spaces:write")], -) +@pytest.mark.parametrize("operation", KNOWLEDGE_FS_CONSOLE_OPERATIONS, ids=lambda operation: operation.operation_id) def test_auth_signs_current_principals_and_declared_scope( monkeypatch: pytest.MonkeyPatch, - method: KnowledgeFSMethod, - expected_scope: str, + operation: KnowledgeFSOperation, ) -> None: _set_config(monkeypatch) - response = httpx.Response(200, content=b'{"items":[]}', headers={"Content-Type": "application/json"}) + response = httpx.Response( + 200, + content=b"data" if operation.response_kind == "stream" else b'{"items":[]}', + headers={"Content-Type": operation.response_media_types[0]}, + ) request = MagicMock(return_value=response) monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request) forward_knowledge_fs_request( account_id="account-1", - method=method, - path="knowledge-spaces", + method=operation.method, + path=_materialized_path(operation), tenant_id="tenant-1", ) @@ -226,7 +557,7 @@ def test_auth_signs_current_principals_and_declared_scope( assert claims["dify_account_id"] == "dify-account:account-1" assert claims["sub"] == "dify-workspace:tenant-1" assert claims["tenant_id"] == "tenant-1" - assert claims["scopes"] == [expected_scope] + assert claims["scopes"] == [operation.required_scope] assert claims["caller_kind"] == "interactive" assert claims["exp"] - claims["iat"] == 60 @@ -244,6 +575,69 @@ def test_buffered_response_rejects_non_empty_body_without_content_type(monkeypat assert response.is_closed +def test_sse_response_remains_streaming_and_uses_the_dedicated_read_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_config(monkeypatch, sse_read_timeout_seconds=120.0) + request = httpx.Request( + "GET", + "http://knowledge-fs.test/events", + extensions={"timeout": {"connect": 7.5, "read": 7.5}}, + ) + response = httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + request=request, + stream=httpx.ByteStream(b"event: progress\n\n"), + ) + monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", MagicMock(return_value=response)) + buffer_response = MagicMock() + monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.buffer_response", buffer_response) + + result = forward_knowledge_fs_request( + account_id="account-dev", + method="GET", + path=_processing_task_events_path(), + tenant_id="tenant-dev", + ) + + assert result.response is response + assert result.response_kind == "stream" + assert request.extensions["timeout"]["read"] == 120.0 + assert not response.is_closed + buffer_response.assert_not_called() + + +@pytest.mark.parametrize( + ("headers", "message"), + [ + ({"Content-Type": "application/json"}, "unsupported media type"), + ( + {"Content-Type": "text/event-stream", "Content-Encoding": "gzip"}, + "unsupported encoding", + ), + ], +) +def test_sse_response_rejects_invalid_stream_headers_and_closes_upstream( + monkeypatch: pytest.MonkeyPatch, + headers: dict[str, str], + message: str, +) -> None: + _set_config(monkeypatch) + response = httpx.Response(200, headers=headers, stream=httpx.ByteStream(b"data")) + monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", MagicMock(return_value=response)) + + with pytest.raises(KnowledgeFSTransportError, match=message): + forward_knowledge_fs_request( + account_id="account-dev", + method="GET", + path=_processing_task_events_path(), + tenant_id="tenant-dev", + ) + + assert response.is_closed + + @pytest.mark.parametrize( ("error", "expected_exception"), [ @@ -277,10 +671,10 @@ def test_transport_failures_are_normalized( ("method", "path"), [ ("GET", "openapi.json"), - ("GET", "knowledge-spaces/space-1"), + ("GET", "knowledge-spaces/space-1/manifest"), ("PATCH", "knowledge-spaces"), ("POST", "queries"), - ("POST", "knowledge-spaces/space-1/documents"), + ("POST", "knowledge-spaces/space-1/uploads"), ], ) def test_unregistered_route_is_rejected_before_external_io( diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 37450cb253a..b2e0e4129c9 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -1419,6 +1419,8 @@ class TestWorkflowService: # =========================================================================== +@pytest.mark.usefixtures("sqlite_session") +@pytest.mark.parametrize("sqlite_session", [(BuiltinToolProvider,)], indirect=True) class TestWorkflowServiceCredentialValidation: """ Tests for the private credential-validation helpers on WorkflowService. @@ -1444,7 +1446,7 @@ class TestWorkflowServiceCredentialValidation: # --- _validate_workflow_credentials: tool node (with credential_id) --- def test_validate_workflow_credentials_should_check_tool_credential_when_credential_id_present( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange nodes = [ @@ -1462,11 +1464,11 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert with patch("core.helper.credential_utils.check_credential_policy_compliance") as mock_check: # Should not raise; mock allows the call - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) mock_check.assert_called_once() def test_validate_workflow_credentials_should_check_default_credential_when_no_credential_id( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange nodes = [ @@ -1483,14 +1485,13 @@ class TestWorkflowServiceCredentialValidation: # Act with patch.object(service, "_check_default_tool_credential") as mock_default: - session = MagicMock() - service._validate_workflow_credentials(workflow, session=session) + service._validate_workflow_credentials(workflow, session=sqlite_session) # Assert - mock_default.assert_called_once_with("tenant-1", "my-provider", session=session) + mock_default.assert_called_once_with("tenant-1", "my-provider", session=sqlite_session) def test_validate_workflow_credentials_should_skip_tool_node_without_provider( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: """Tool nodes without a provider_id should be silently skipped.""" # Arrange @@ -1499,11 +1500,11 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert (no error raised) with patch.object(service, "_check_default_tool_credential") as mock_default: - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) mock_default.assert_not_called() def test_validate_workflow_credentials_should_validate_llm_node_with_model_config( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange nodes = [ @@ -1522,13 +1523,13 @@ class TestWorkflowServiceCredentialValidation: patch.object(service, "_validate_llm_model_config") as mock_llm, patch.object(service, "_validate_load_balancing_credentials"), ): - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) # Assert mock_llm.assert_called_once_with("tenant-1", "openai", "gpt-4") def test_validate_workflow_credentials_should_raise_for_llm_node_missing_model( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: """LLM nodes without provider AND name should raise ValueError.""" # Arrange @@ -1542,10 +1543,10 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert with pytest.raises(ValueError, match="Missing provider or model configuration"): - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) def test_validate_workflow_credentials_should_wrap_unexpected_exception_in_value_error( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: """Non-ValueError exceptions from validation must be re-raised as ValueError.""" # Arrange @@ -1563,9 +1564,11 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert with patch.object(service, "_validate_llm_model_config", side_effect=RuntimeError("boom")): with pytest.raises(ValueError, match="boom"): - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) - def test_validate_workflow_credentials_should_validate_agent_node_model(self, service: WorkflowService) -> None: + def test_validate_workflow_credentials_should_validate_agent_node_model( + self, service: WorkflowService, sqlite_session: Session + ) -> None: # Arrange nodes = [ { @@ -1586,12 +1589,14 @@ class TestWorkflowServiceCredentialValidation: patch.object(service, "_validate_llm_model_config") as mock_llm, patch.object(service, "_validate_load_balancing_credentials"), ): - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) # Assert mock_llm.assert_called_once_with("tenant-1", "openai", "gpt-4") - def test_validate_workflow_credentials_should_validate_agent_tools(self, service: WorkflowService) -> None: + def test_validate_workflow_credentials_should_validate_agent_tools( + self, service: WorkflowService, sqlite_session: Session + ) -> None: """Each agent tool with a provider should be checked for credential compliance.""" # Arrange nodes = [ @@ -1618,12 +1623,11 @@ class TestWorkflowServiceCredentialValidation: patch("core.helper.credential_utils.check_credential_policy_compliance") as mock_check, patch.object(service, "_check_default_tool_credential") as mock_default, ): - session = MagicMock() - service._validate_workflow_credentials(workflow, session=session) + service._validate_workflow_credentials(workflow, session=sqlite_session) # Assert mock_check.assert_called_once() # provider-a has credential_id - mock_default.assert_called_once_with("tenant-1", "provider-b", session=session) + mock_default.assert_called_once_with("tenant-1", "provider-b", session=sqlite_session) # --- _validate_llm_model_config --- @@ -1676,14 +1680,12 @@ class TestWorkflowServiceCredentialValidation: # --- _check_default_tool_credential --- - @pytest.mark.parametrize("sqlite_session", [(BuiltinToolProvider,)], indirect=True) def test_check_default_tool_credential_should_silently_pass_when_no_provider_found( self, service: WorkflowService, sqlite_session: Session ) -> None: """Missing BuiltinToolProvider → plugin requires no credentials → no error.""" service._check_default_tool_credential("tenant-1", "some-provider", session=sqlite_session) - @pytest.mark.parametrize("sqlite_session", [(BuiltinToolProvider,)], indirect=True) def test_check_default_tool_credential_should_raise_when_compliance_fails( self, service: WorkflowService, sqlite_session: Session ) -> None: @@ -1746,7 +1748,9 @@ class TestWorkflowServiceCredentialValidation: # --- _get_load_balancing_configs --- - def test_get_load_balancing_configs_should_return_empty_list_on_exception(self, service: WorkflowService) -> None: + def test_get_load_balancing_configs_should_return_empty_list_on_exception( + self, service: WorkflowService, sqlite_session: Session + ) -> None: """Any exception during LB config retrieval should return an empty list.""" # Arrange with patch( @@ -1754,12 +1758,14 @@ class TestWorkflowServiceCredentialValidation: side_effect=RuntimeError("fail"), ): # Act - result = service._get_load_balancing_configs("tenant-1", "openai", "gpt-4", session=MagicMock()) + result = service._get_load_balancing_configs("tenant-1", "openai", "gpt-4", session=sqlite_session) # Assert assert result == [] - def test_get_load_balancing_configs_should_merge_predefined_and_custom(self, service: WorkflowService) -> None: + def test_get_load_balancing_configs_should_merge_predefined_and_custom( + self, service: WorkflowService, sqlite_session: Session + ) -> None: # Arrange predefined = [{"credential_id": "cred-a"}, {"credential_id": None}] custom = [{"credential_id": "cred-b"}] @@ -1771,7 +1777,7 @@ class TestWorkflowServiceCredentialValidation: ], ): # Act - result = service._get_load_balancing_configs("tenant-1", "openai", "gpt-4", session=MagicMock()) + result = service._get_load_balancing_configs("tenant-1", "openai", "gpt-4", session=sqlite_session) # Assert — only entries with a credential_id should be returned assert len(result) == 2 @@ -1780,7 +1786,7 @@ class TestWorkflowServiceCredentialValidation: # --- _validate_load_balancing_credentials --- def test_validate_load_balancing_credentials_should_skip_when_no_model_config( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: """Missing provider or model in node_data should be a no-op.""" # Arrange @@ -1788,10 +1794,10 @@ class TestWorkflowServiceCredentialValidation: node_data: dict[str, Any] = {} # no model key # Act + Assert (no error expected) - service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=MagicMock()) + service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=sqlite_session) def test_validate_load_balancing_credentials_should_skip_when_lb_not_enabled( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange workflow = self._make_workflow([]) @@ -1799,10 +1805,10 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert (no error expected) with patch.object(service, "_is_load_balancing_enabled", return_value=False): - service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=MagicMock()) + service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=sqlite_session) def test_validate_load_balancing_credentials_should_raise_when_compliance_fails( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange workflow = self._make_workflow([]) @@ -1819,7 +1825,7 @@ class TestWorkflowServiceCredentialValidation: ), ): with pytest.raises(ValueError, match="Invalid load balancing credentials"): - service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=MagicMock()) + service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=sqlite_session) # =========================================================================== diff --git a/api/tests/unit_tests/services/tools/test_tools_transform_service.py b/api/tests/unit_tests/services/tools/test_tools_transform_service.py index 32c1a00d301..dcc02d0cf00 100644 --- a/api/tests/unit_tests/services/tools/test_tools_transform_service.py +++ b/api/tests/unit_tests/services/tools/test_tools_transform_service.py @@ -9,30 +9,29 @@ from services.tools.tools_transform_service import ToolTransformService MODULE = "services.tools.tools_transform_service" +def _parameter( + name: str, + label: str, + form: ToolParameter.ToolParameterForm = ToolParameter.ToolParameterForm.FORM, +) -> ToolParameter: + return ToolParameter( + name=name, + label=I18nObject(en_US=label), + human_description=I18nObject(en_US=label), + type=ToolParameter.ToolParameterType.STRING, + form=form, + ) + + class TestToolTransformService: """Test cases for ToolTransformService.convert_tool_entity_to_api_entity method""" def test_convert_tool_with_parameter_override(self): """Test that runtime parameters correctly override base parameters""" - # Create mock base parameters - base_param1 = Mock(spec=ToolParameter) - base_param1.name = "param1" - base_param1.form = ToolParameter.ToolParameterForm.FORM - base_param1.type = "string" - base_param1.label = "Base Param 1" + base_param1 = _parameter("param1", "Base Param 1") + base_param2 = _parameter("param2", "Base Param 2") - base_param2 = Mock(spec=ToolParameter) - base_param2.name = "param2" - base_param2.form = ToolParameter.ToolParameterForm.FORM - base_param2.type = "string" - base_param2.label = "Base Param 2" - - # Create mock runtime parameters that override base parameters - runtime_param1 = Mock(spec=ToolParameter) - runtime_param1.name = "param1" - runtime_param1.form = ToolParameter.ToolParameterForm.FORM - runtime_param1.type = "string" - runtime_param1.label = "Runtime Param 1" # Different label to verify override + runtime_param1 = _parameter("param1", "Runtime Param 1") # Create mock tool mock_tool = Mock(spec=Tool) @@ -63,34 +62,19 @@ class TestToolTransformService: # Find the overridden parameter overridden_param = next((p for p in result.parameters if p.name == "param1"), None) assert overridden_param is not None - assert overridden_param.label == "Runtime Param 1" # Should be runtime version + assert overridden_param.label.en_US == "Runtime Param 1" # Should be runtime version # Find the non-overridden parameter original_param = next((p for p in result.parameters if p.name == "param2"), None) assert original_param is not None - assert original_param.label == "Base Param 2" # Should be base version + assert original_param.label.en_US == "Base Param 2" # Should be base version def test_convert_tool_with_additional_runtime_parameters(self): """Test that additional runtime parameters are added to the final list""" - # Create mock base parameters - base_param1 = Mock(spec=ToolParameter) - base_param1.name = "param1" - base_param1.form = ToolParameter.ToolParameterForm.FORM - base_param1.type = "string" - base_param1.label = "Base Param 1" + base_param1 = _parameter("param1", "Base Param 1") - # Create mock runtime parameters - one that overrides and one that's new - runtime_param1 = Mock(spec=ToolParameter) - runtime_param1.name = "param1" - runtime_param1.form = ToolParameter.ToolParameterForm.FORM - runtime_param1.type = "string" - runtime_param1.label = "Runtime Param 1" - - runtime_param2 = Mock(spec=ToolParameter) - runtime_param2.name = "runtime_only" - runtime_param2.form = ToolParameter.ToolParameterForm.FORM - runtime_param2.type = "string" - runtime_param2.label = "Runtime Only Param" + runtime_param1 = _parameter("param1", "Runtime Param 1") + runtime_param2 = _parameter("runtime_only", "Runtime Only Param") # Create mock tool mock_tool = Mock(spec=Tool) @@ -124,34 +108,19 @@ class TestToolTransformService: # Verify the overridden parameter has runtime version overridden_param = next((p for p in result.parameters if p.name == "param1"), None) assert overridden_param is not None - assert overridden_param.label == "Runtime Param 1" + assert overridden_param.label.en_US == "Runtime Param 1" # Verify the new runtime parameter is included new_param = next((p for p in result.parameters if p.name == "runtime_only"), None) assert new_param is not None - assert new_param.label == "Runtime Only Param" + assert new_param.label.en_US == "Runtime Only Param" def test_convert_tool_with_non_form_runtime_parameters(self): """Test that non-FORM runtime parameters are not added as new parameters""" - # Create mock base parameters - base_param1 = Mock(spec=ToolParameter) - base_param1.name = "param1" - base_param1.form = ToolParameter.ToolParameterForm.FORM - base_param1.type = "string" - base_param1.label = "Base Param 1" + base_param1 = _parameter("param1", "Base Param 1") - # Create mock runtime parameters with different forms - runtime_param1 = Mock(spec=ToolParameter) - runtime_param1.name = "param1" - runtime_param1.form = ToolParameter.ToolParameterForm.FORM - runtime_param1.type = "string" - runtime_param1.label = "Runtime Param 1" - - runtime_param2 = Mock(spec=ToolParameter) - runtime_param2.name = "llm_param" - runtime_param2.form = ToolParameter.ToolParameterForm.LLM - runtime_param2.type = "string" - runtime_param2.label = "LLM Param" + runtime_param1 = _parameter("param1", "Runtime Param 1") + runtime_param2 = _parameter("llm_param", "LLM Param", ToolParameter.ToolParameterForm.LLM) # Create mock tool mock_tool = Mock(spec=Tool) @@ -236,38 +205,15 @@ class TestToolTransformService: def test_convert_tool_parameter_order_preserved(self): """Test that parameter order is preserved correctly""" - # Create mock base parameters in specific order - base_param1 = Mock(spec=ToolParameter) - base_param1.name = "param1" - base_param1.form = ToolParameter.ToolParameterForm.FORM - base_param1.type = "string" - base_param1.label = "Base Param 1" - - base_param2 = Mock(spec=ToolParameter) - base_param2.name = "param2" - base_param2.form = ToolParameter.ToolParameterForm.FORM - base_param2.type = "string" - base_param2.label = "Base Param 2" - - base_param3 = Mock(spec=ToolParameter) - base_param3.name = "param3" - base_param3.form = ToolParameter.ToolParameterForm.FORM - base_param3.type = "string" - base_param3.label = "Base Param 3" + base_param1 = _parameter("param1", "Base Param 1") + base_param2 = _parameter("param2", "Base Param 2") + base_param3 = _parameter("param3", "Base Param 3") # Create runtime parameter that overrides middle parameter - runtime_param2 = Mock(spec=ToolParameter) - runtime_param2.name = "param2" - runtime_param2.form = ToolParameter.ToolParameterForm.FORM - runtime_param2.type = "string" - runtime_param2.label = "Runtime Param 2" + runtime_param2 = _parameter("param2", "Runtime Param 2") # Create new runtime parameter - runtime_param4 = Mock(spec=ToolParameter) - runtime_param4.name = "param4" - runtime_param4.form = ToolParameter.ToolParameterForm.FORM - runtime_param4.type = "string" - runtime_param4.label = "Runtime Param 4" + runtime_param4 = _parameter("param4", "Runtime Param 4") # Create mock tool mock_tool = Mock(spec=Tool) @@ -300,7 +246,7 @@ class TestToolTransformService: # Verify that param2 was overridden with runtime version param2 = result.parameters[1] assert param2.name == "param2" - assert param2.label == "Runtime Param 2" + assert param2.label.en_US == "Runtime Param 2" class TestWorkflowProviderToUserProvider: diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py index be6f9ff1fc0..8efd7370a73 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py @@ -10,6 +10,8 @@ from typing import Any, cast from unittest.mock import MagicMock import pytest +from sqlalchemy import event +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import WorkflowUIBasedAppConfig @@ -21,8 +23,9 @@ from core.app.layers.pause_state_persist_layer import ( ) from graphon.enums import WorkflowExecutionStatus from graphon.runtime import GraphRuntimeState, VariablePool -from models.enums import CreatorUserRole -from models.model import AppMode +from models.base import TypeBase +from models.enums import CreatorUserRole, MessageStatus +from models.model import AppMode, Message from models.workflow import WorkflowRun from repositories.entities.workflow_pause import WorkflowPauseEntity from services import workflow_event_snapshot_service as service_module @@ -79,23 +82,47 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: ) -class _SessionContext: - def __init__(self, session: Any) -> None: - self._session = session - - def __enter__(self) -> Any: - return self._session - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: - return False +@pytest.fixture +def message_session_maker(sqlite_engine: Engine) -> sessionmaker[Session]: + """Create real sessions containing only workflow messages.""" + TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[Message.__tablename__]]) + return sessionmaker(bind=sqlite_engine, expire_on_commit=False) -class _SessionMaker: - def __init__(self, session: Any) -> None: - self._session = session - - def __call__(self) -> _SessionContext: - return _SessionContext(self._session) +def _persist_message(session_maker: sessionmaker[Session]) -> Message: + message = Message( + app_id="app-1", + model_provider="provider", + model_id="model", + override_model_configs=None, + conversation_id="conv-1", + inputs={}, + query="hello", + message="", + message_tokens=0, + message_unit_price=0, + message_price_unit=0, + answer="answer", + answer_tokens=0, + answer_unit_price=0, + answer_price_unit=0, + parent_message_id=None, + provider_response_latency=0, + total_price=0, + currency="USD", + invoke_from=InvokeFrom.WEB_APP, + from_source="api", + from_end_user_id="user-1", + from_account_id=None, + app_mode=AppMode.WORKFLOW, + status=MessageStatus.NORMAL, + workflow_run_id="run-1", + ) + message.id = "msg-1" + with session_maker() as session: + session.add(message) + session.commit() + return message class _SubscriptionContext: @@ -150,12 +177,11 @@ class _PauseEntity(WorkflowPauseEntity): class TestWorkflowEventSnapshotHelpers: - def test_get_message_context_by_conversation_should_return_none_when_no_message(self) -> None: - session = SimpleNamespace(scalar=MagicMock(return_value=None)) - session_maker = _SessionMaker(session) - + def test_get_message_context_by_conversation_should_return_none_when_no_message( + self, message_session_maker: sessionmaker[Session] + ) -> None: result = service_module._get_message_context_by_conversation( - cast(sessionmaker[Session], session_maker), + message_session_maker, conversation_id="conv-1", workflow_run_id="run-1", ) @@ -163,22 +189,22 @@ class TestWorkflowEventSnapshotHelpers: assert result is None def test_get_message_context_by_conversation_should_default_created_at_to_zero_when_message_has_no_timestamp( - self, + self, message_session_maker: sessionmaker[Session] ) -> None: - message = SimpleNamespace( - id="msg-1", - conversation_id="conv-1", - created_at=None, - answer="answer", - ) - session = SimpleNamespace(scalar=MagicMock(return_value=message)) - session_maker = _SessionMaker(session) + _persist_message(message_session_maker) - result = service_module._get_message_context_by_conversation( - cast(sessionmaker[Session], session_maker), - conversation_id="conv-1", - workflow_run_id="run-1", - ) + def clear_created_at(message: Message, _context: Any) -> None: + message.created_at = None # type: ignore[assignment] + + event.listen(Message, "load", clear_created_at) + try: + result = service_module._get_message_context_by_conversation( + message_session_maker, + conversation_id="conv-1", + workflow_run_id="run-1", + ) + finally: + event.remove(Message, "load", clear_created_at) assert result is not None assert result.created_at == 0 diff --git a/api/uv.lock b/api/uv.lock index eeaee9224fc..125cfca452b 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -2710,14 +2710,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.50" +version = "3.1.52" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/fd/df0bafa4eb5ea2f51e1adee9f7a94c8e62c5d180e65117045dfca3439c8a/gitpython-3.1.52.tar.gz", hash = "sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e", size = 223726, upload-time = "2026-07-16T03:15:59.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, + { url = "https://files.pythonhosted.org/packages/8d/90/04dff7c1e176bb1c3011ef1647393d368790da710d8dde1cdcfad301f45a/gitpython-3.1.52-py3-none-any.whl", hash = "sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a", size = 215366, upload-time = "2026-07-16T03:15:58.239Z" }, ] [[package]] @@ -5119,11 +5119,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] diff --git a/dify-agent-runtime/go.mod b/dify-agent-runtime/go.mod index 9cf551446c4..297b144dcd1 100644 --- a/dify-agent-runtime/go.mod +++ b/dify-agent-runtime/go.mod @@ -5,7 +5,7 @@ go 1.26 require ( github.com/landlock-lsm/go-landlock v0.9.0 github.com/spf13/cobra v1.10.2 - google.golang.org/grpc v1.82.0 + google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 modernc.org/sqlite v1.37.1 ) diff --git a/dify-agent-runtime/go.sum b/dify-agent-runtime/go.sum index 15705eabb41..b4fbc8b3322 100644 --- a/dify-agent-runtime/go.sum +++ b/dify-agent-runtime/go.sum @@ -62,8 +62,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index 840aca84d1a..a496d19cdd1 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -71,5 +71,5 @@ docs = [ ] [build-system] -requires = ["setuptools>=61"] +requires = ["setuptools>=83.0.0"] build-backend = "setuptools.build_meta" diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index 1ec1438ce51..c355ba99dca 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -2331,71 +2331,73 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" +version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] @@ -2505,11 +2507,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] diff --git a/docker/.env.example b/docker/.env.example index 2071618fba8..3b3de9cd976 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -140,6 +140,7 @@ API_SENTRY_TRACES_SAMPLE_RATE=1.0 API_SENTRY_PROFILES_SAMPLE_RATE=1.0 WEB_SENTRY_DSN= AMPLITUDE_API_KEY= +COOKIEYES_SITE_KEY= TEXT_GENERATION_TIMEOUT_MS=60000 WORKFLOW_GENERATION_TIMEOUT_MS=180000 CSP_WHITELIST= diff --git a/docker/README.md b/docker/README.md index c3b1011bd68..0dedf718ef8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -16,7 +16,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T ### How to Deploy Dify with `docker-compose.yaml` -1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system. +1. **Prerequisites**: Ensure Docker and Docker Compose v2.24.0 or later are installed on your system. 2. **Environment Setup**: - Navigate to the `docker` directory. - Copy `.env.example` to `.env`. diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 35d5a72ff6a..37d7185e4a6 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -391,6 +391,7 @@ services: SERVER_CONSOLE_API_URL: ${SERVER_CONSOLE_API_URL:-http://api:5001} APP_API_URL: ${APP_API_URL:-} AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-} + COOKIEYES_SITE_KEY: ${COOKIEYES_SITE_KEY:-} NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost} SENTRY_DSN: ${WEB_SENTRY_DSN:-} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index cadfbbb7ae7..908e979c8cf 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -397,6 +397,7 @@ services: SERVER_CONSOLE_API_URL: ${SERVER_CONSOLE_API_URL:-http://api:5001} APP_API_URL: ${APP_API_URL:-} AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-} + COOKIEYES_SITE_KEY: ${COOKIEYES_SITE_KEY:-} NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost} SENTRY_DSN: ${WEB_SENTRY_DSN:-} diff --git a/docker/envs/core-services/web.env.example b/docker/envs/core-services/web.env.example index c7053350a27..576e1673bc9 100644 --- a/docker/envs/core-services/web.env.example +++ b/docker/envs/core-services/web.env.example @@ -20,6 +20,7 @@ MARKETPLACE_API_URL=https://marketplace.dify.ai INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 ALLOW_EMBED=false AMPLITUDE_API_KEY= +COOKIEYES_SITE_KEY= ENABLE_WEBSITE_JINAREADER=true ENABLE_WEBSITE_FIRECRAWL=true ENABLE_WEBSITE_WATERCRAWL=true diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 8aae18c6e75..abbf2a27b4b 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -81,7 +81,7 @@ flowchart TD A["Start E2E run"] --> B["run-cucumber.ts orchestrates setup/API/frontend"] B --> C["support/web-server.ts starts or reuses frontend directly"] C --> D["Cucumber loads config, steps, and support modules"] - D --> E["BeforeAll bootstraps shared auth state via /install"] + D --> E["The first Before hook lazily bootstraps shared auth state"] E --> F{"Which command is running?"} F -->|`pnpm -C e2e e2e`| G["Run deterministic scenarios; exclude @prepared and external runtime"] F -->|`pnpm -C e2e e2e:full*`| H["Reset and run deterministic scenarios; exclude @prepared and external runtime"] @@ -96,7 +96,7 @@ Ownership is split like this: - `run-cucumber.ts` orchestrates the E2E run and Cucumber invocation - `support/web-server.ts` manages frontend reuse, startup, readiness, and shutdown - `features/support/hooks.ts` manages auth bootstrap, scenario lifecycle, and diagnostics -- `features/support/world.ts` owns per-scenario typed context +- `features/support/world.ts` owns the per-scenario behavior BrowserContext and authenticated setup/cleanup client; their identities remain separate so unauthenticated and logout journeys cannot invalidate fixture ownership - `features/step-definitions/` holds domain-oriented glue so the official VS Code Cucumber plugin works with default conventions when `e2e/` is opened as the workspace root Package layout: @@ -195,6 +195,24 @@ Open the HTML report locally with: open cucumber-report/report.html ``` +## Scenario admission and behavior ownership + +Add an E2E scenario only when it protects a critical user journey and a cross-boundary result that +cheaper owner-level tests do not already prove. A control changing its own label is not sufficient +E2E evidence when component or integration tests can own that contract. + +Start from product truth, including real defaults and actor roles. API fixtures may establish +preconditions, but they must not manufacture an opposite state merely to make the intended action +look meaningful. When a product default is part of the journey, make it explicit and observable. + +For cross-actor journeys, isolate each actor's browser state, keep their pages in typed `DifyWorld` +state, and include them in failure diagnostics and cleanup. Assert the downstream user-observable +effect, not only the initiating control's local state. + +When a run exposes behavior that conflicts with the intended product contract, identify the first +layer that misclassifies the business state. Fix that owner or report the mismatch explicitly; do +not make the E2E pass by encoding an accidental redirect, stale label, or misleading error state. + ## Writing new scenarios ### Workflow @@ -335,6 +353,18 @@ Keep package-level support limited to broadly reusable primitives such as API cl Use generated API contracts for Console/Web/Service API request, response, and payload shapes. Import the concrete type directly from `@dify/contracts/.../types.gen` when it exists, and do not hand-write duplicate response shapes or wrap generated types in local aliases just to preserve an older helper name. Keep local E2E types only for scenario state, fixture registries, helper input options, and intentionally narrowed test view models that are not complete API responses. +### Console API and protocol boundaries + +The action under test belongs to the browser. `When` steps must use Playwright to perform the user action; do not replace the action with an API request. `Given` setup, seed preparation, persistence polling, and `After` cleanup may use APIs when that makes the scenario faster and more deterministic. `Then` should prefer a user-observable browser result; an API read is appropriate only when persistence itself is the asserted contract and the endpoint owns that state. + +For ordinary Console JSON operations and multipart uploads represented by Console OpenAPI, use the generated oRPC router with generated request and response validation enabled. A scenario client belongs to its `DifyWorld` and uses a scenario-owned authenticated request context that is independent from the behavior browser; seed processes own a standalone client for their process lifetime. Do not create a mutable cross-scenario API client, add TanStack Query caching to Cucumber, hand-write Console endpoint URLs, cast response JSON to an API DTO, or duplicate a generated Zod schema. When a browser action's captured response must provide an ID for cleanup, parse it with the generated response schema. + +Do not add a helper that only renames or forwards one generated operation. Call the generated client directly from the owning step, hook, or fixture orchestration. Keep a helper only when it owns a real test concern such as constructing a valid domain fixture, coordinating multiple operations, maintaining an invariant or cleanup registry, polling eventual consistency, deriving a narrowed test view, or adapting a non-OpenAPI protocol. + +SSE/event streams, binary downloads, redirect-only flows, external services, and infrastructure health/readiness checks may use a dedicated protocol adapter. Keep each exception centralized under its real owner and continue to use generated payload types where the contract covers the request. Multipart is not an exception merely because it carries a file: fix the backend OpenAPI schema and regenerate when the operation can be represented. + +Request or response validation failures are contract failures. Do not suppress them with casts, permissive fallback schemas, disabled validation, swallowed cleanup errors, or a second handwritten request path. Trace the mismatch to the endpoint's backend schema owner, update it according to `api/controllers/API_SCHEMA_GUIDE.md`, regenerate `@dify/contracts`, and keep the E2E assertion aligned with the product's real state owner rather than an internal backing resource. + Use typed cleanup fields on `DifyWorld` for resource types created by scenarios, and use `DifyWorld.registerCleanup(...)` when a scenario creates any resource type that is not covered by typed cleanup fields. Typed cleanup should remove child or referencing resources before their owners, such as Agent files before Agents and workflow apps before Agents they reference. Cleanup failures should be attached to the report instead of being swallowed silently. Cleanup callbacks run after typed cleanup queues, even when the scenario fails. Scenario-owned setup may create disposable apps, Agents, files, credentials, drafts, or access toggles when the scenario owns their lifecycle and cleanup. Do not use scenario setup to silently fix a shared fixture; a missing or drifted fixed resource is a seed failure. diff --git a/e2e/features/agent-v2/AGENTS.md b/e2e/features/agent-v2/AGENTS.md index 013af12d1e0..ab8a20a53c2 100644 --- a/e2e/features/agent-v2/AGENTS.md +++ b/e2e/features/agent-v2/AGENTS.md @@ -25,16 +25,7 @@ Use `@external-model` and `@external-tool` only for runtime calls. A scenario th ## Step organization -Keep steps grouped by user capability: - -- `configure.steps.ts` — navigation, editing, autosave, and saved draft behavior. -- `build-draft.steps.ts` — checkout, apply, discard, and isolation. -- `files.steps.ts`, `knowledge.steps.ts`, `tools.steps.ts` — resource configuration behavior. -- `advanced-settings.steps.ts`, `env-editor.steps.ts` — supported Advanced Settings behavior. -- `agent-roster.steps.ts`, `agent-edit.steps.ts`, `publish.steps.ts` — Agent lifecycle surfaces. -- `access-point*.steps.ts` — Web app, service API, and Workflow access. -- `fixtures.steps.ts` — strict fixture resolution for behavior scenarios. -- `speech-to-text.steps.ts` — voice input and transcription behavior. +Keep steps grouped by Agent product capability, such as configuration, Build draft, resource configuration, lifecycle, Access Point, and runtime behavior. Group by the domain action that owns the wording instead of mechanically pairing a step file with each feature file. Fixture-resolution steps should remain separate from behavior steps because they validate environment readiness rather than perform a user journey. Cucumber step definitions are globally registered. Do not duplicate step text across files. @@ -66,20 +57,11 @@ pnpm -C e2e e2e:post-merge:prepare pnpm -C e2e e2e:post-merge ``` -The strict seed must finish without blocked tasks. It prepares the stable and decision models, Speech-to-Text default, marketplace plugins, JSON Replace and Tavily tools, ready knowledge base, Full Config Agent, Tool States Agent, Dual Retrieval Agent, and Workflow reference. +The strict seed must finish without blocked tasks. The concrete resource inventory and defaults belong to the seed profile and environment configuration rather than this guidance. -Fixture helpers live under `features/agent-v2/support/fixtures/`: +Organize fixture helpers by the product resource or infrastructure capability they own, not by the feature file that happens to consume them. Keep runtime readiness adapters separate from Console resource fixtures, and keep all fixture state in the current `SeedContext` or scenario `DifyWorld` rather than module globals. -- `models.ts` — stable, decision, and Speech-to-Text models. -- `agents.ts` — fixed Agent and configuration contracts. -- `datasets.ts` — indexed knowledge contract. -- `tools.ts` — installed built-in tool contract. -- `access.ts` — Workflow reference contract. -- `agent-backend.ts` — runtime server and shellctl readiness. - -The stable model selectors default to `openai` / `gpt-5-nano` / `llm`. The decision model defaults to `openai` / `gpt-5.5` / `llm`. The Speech-to-Text model defaults to `openai` / `gpt-4o-mini-transcribe`. Provider credentials belong to seed/admin setup through `E2E_MODEL_PROVIDER_CREDENTIALS_JSON`, never to Cucumber steps. - -The Full Config Agent contract includes the stable model, prompt marker, checked-in files, Summary Skill, JSON Replace tool, and indexed knowledge reference. Tool States includes Summary Skill, JSON Replace, Tavily, and its credential reference. Dual Retrieval includes generated-query and custom-query knowledge sets. Workflow reference verifies the same Console API used by the Access Point table. +Provider credentials belong to seed/admin setup, never to Cucumber steps. ## Runtime contract @@ -94,3 +76,5 @@ Build mode covers Configure and Build draft persistence. Preview/Test Run covers ## API contracts Import generated Console/Web/Service API types directly from `@dify/contracts/.../types.gen`. Keep local types only for E2E-owned state, fixture registry entries, helper inputs, and intentionally narrowed views. If the generated contract is incomplete, fix the backend schema and regenerate it instead of duplicating the response shape. + +Agent detail is the state owner for Agent scenarios. An Agent's backing app identifier may be used to route a shared app command, but it is not a substitute query model and must not become the final assertion source. Derive Agent Web app URLs and persisted Agent state from the generated Agent detail contract, then assert the user-visible Access Point or runtime result in the browser. diff --git a/e2e/features/agent-v2/support/access-point.ts b/e2e/features/agent-v2/support/access-point.ts index 96316028b3b..575cef3e662 100644 --- a/e2e/features/agent-v2/support/access-point.ts +++ b/e2e/features/agent-v2/support/access-point.ts @@ -1,17 +1,10 @@ -import type { - AgentApiAccessResponse, - ApiKeyItem, -} from '@dify/contracts/api/console/agent/types.gen' -import type { - ChatRequestPayloadWithUser, - PostChatMessagesResponse, -} from '@dify/contracts/api/service/types.gen' -import { createApiContext, expectApiResponseOK, setAppSiteEnabled } from '../../../support/api' -import { getTestAgent } from './agent' +import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' +import type { ChatRequestPayloadWithUser } from '@dify/contracts/api/service/types.gen' +import type { ConsoleClient } from '../../../support/api/console-client' import { consumeServiceApiSse, SERVICE_API_STREAM_TIMEOUT_MS } from './service-api-sse' export type AgentServiceApiChatResult = { - body: PostChatMessagesResponse | unknown + body: unknown ok: boolean status: number } @@ -38,49 +31,27 @@ async function parseServiceApiChatResponse(response: Response) { } } -export async function setAgentSiteAccessAndGetURL( - agentId: string, - enabled: boolean, -): Promise { - const agent = await getTestAgent(agentId) - const appId = agent.app_id ?? agent.backing_app_id - if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) +export function getAgentWebAppURL(agent: AgentAppDetailWithSite): string { + const token = agent.site?.access_token ?? agent.site?.code + if (!token) throw new Error(`Agent v2 ${agent.id} does not expose a Web app access token.`) - const appDetail = await setAppSiteEnabled(appId, enabled) - const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site.access_token - const baseURL = agent.site?.app_base_url ?? appDetail.site.app_base_url + const baseURL = agent.site?.app_base_url + if (!baseURL) throw new Error(`Agent v2 ${agent.id} does not expose a Web app base URL.`) return `${baseURL.replace(/\/$/, '')}/agent/${token}` } -export async function setAgentApiAccess( - agentId: string, - enabled: boolean, -): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/api-enable`, { - data: { enable_api: enabled }, - }) - await expectApiResponseOK( - response, - `${enabled ? 'Enable' : 'Disable'} Agent v2 API access for ${agentId}`, - ) - return (await response.json()) as AgentApiAccessResponse - } finally { - await ctx.dispose() - } -} +export async function enableAgentWebApp(client: ConsoleClient, agentId: string): Promise { + const agent = await client.agent.byAgentId.get({ params: { agent_id: agentId } }) + const appId = agent.app_id ?? agent.backing_app_id + if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) -export async function createAgentApiKey(agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/api-keys`) - await expectApiResponseOK(response, `Create Agent v2 API key for ${agentId}`) - return (await response.json()) as ApiKeyItem - } finally { - await ctx.dispose() - } + await client.apps.byAppId.siteEnable.post({ + body: { enable_site: true }, + params: { app_id: appId }, + }) + const updatedAgent = await client.agent.byAgentId.get({ params: { agent_id: agentId } }) + return getAgentWebAppURL(updatedAgent) } export async function sendAgentServiceApiChatMessage({ @@ -114,7 +85,7 @@ export async function sendAgentServiceApiChatMessage({ const responseBody = await parseServiceApiChatResponse(response) return { - body: responseBody as PostChatMessagesResponse | unknown, + body: responseBody, ok: response.ok, status: response.status, } diff --git a/e2e/features/agent-v2/support/agent-build-draft.ts b/e2e/features/agent-v2/support/agent-build-draft.ts index 7d897ef3e91..7946739ac53 100644 --- a/e2e/features/agent-v2/support/agent-build-draft.ts +++ b/e2e/features/agent-v2/support/agent-build-draft.ts @@ -2,71 +2,33 @@ import type { AgentBuildDraftResponse, AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' -import { createApiContext, expectApiResponseOK } from '../../../support/api' - -export async function checkoutAgentBuildDraft(agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/build-draft/checkout`, { - data: { force: true }, - }) - await expectApiResponseOK(response, `Checkout Agent v2 build draft for ${agentId}`) - return (await response.json()) as AgentBuildDraftResponse - } finally { - await ctx.dispose() - } -} +import type { ConsoleClient } from '../../../support/api/console-client' +import { ORPCError } from '@orpc/client' export async function saveAgentBuildDraft( + client: ConsoleClient, agentId: string, agentSoul: AgentSoulConfig, ): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.put(`/console/api/agent/${agentId}/build-draft`, { - data: { - agent_soul: agentSoul, - save_strategy: 'save_to_current_version', - variant: 'agent_app', - }, - }) - await expectApiResponseOK(response, `Save Agent v2 build draft for ${agentId}`) - return (await response.json()) as AgentBuildDraftResponse - } finally { - await ctx.dispose() - } + return client.agent.byAgentId.buildDraft.put({ + body: { + agent_soul: agentSoul, + save_strategy: 'save_to_current_version', + variant: 'agent_app', + }, + params: { agent_id: agentId }, + }) } -export async function agentBuildDraftExists(agentId: string): Promise { - const ctx = await createApiContext() +export async function agentBuildDraftExists( + client: ConsoleClient, + agentId: string, +): Promise { try { - const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) - if (response.status() === 404) return false - - await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) + await client.agent.byAgentId.buildDraft.get({ params: { agent_id: agentId } }) return true - } finally { - await ctx.dispose() - } -} - -export async function getAgentBuildDraft(agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) - await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) - return (await response.json()) as AgentBuildDraftResponse - } finally { - await ctx.dispose() - } -} - -export async function discardAgentBuildDraft(agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.delete(`/console/api/agent/${agentId}/build-draft`) - await expectApiResponseOK(response, `Discard Agent v2 build draft for ${agentId}`) - } finally { - await ctx.dispose() + } catch (error) { + if (error instanceof ORPCError && error.status === 404) return false + throw error } } diff --git a/e2e/features/agent-v2/support/agent-drive.ts b/e2e/features/agent-v2/support/agent-drive.ts index 28c4772f6dd..c76e7b75c9b 100644 --- a/e2e/features/agent-v2/support/agent-drive.ts +++ b/e2e/features/agent-v2/support/agent-drive.ts @@ -7,17 +7,10 @@ import type { AgentDriveSkillListResponse, AgentSkillUploadResponse, } from '@dify/contracts/api/console/agent/types.gen' +import type { ConsoleClient } from '../../../support/api/console-client' import { Buffer } from 'node:buffer' import { readFile } from 'node:fs/promises' import path from 'node:path' -import { createApiContext, expectApiResponseOK } from '../../../support/api' - -export type UploadedConsoleFile = { - id: string - mime_type?: string | null - name: string - size?: number | null -} const crc32Table = new Uint32Array(256) for (let i = 0; i < crc32Table.length; i++) { @@ -117,167 +110,98 @@ const toSkillArchiveUpload = async ({ } } -export async function uploadAgentDriveSkill({ - agentId, - fileName, - filePath, -}: { - agentId: string - fileName: string - filePath: string -}): Promise { - const ctx = await createApiContext() - try { - const upload = await toSkillArchiveUpload({ fileName, filePath }) - const response = await ctx.post(`/console/api/agent/${agentId}/skills/upload`, { - multipart: { - file: { - buffer: upload.buffer, - mimeType: 'application/zip', - name: upload.name, - }, - }, +const createUploadFile = (content: Buffer, name: string, type: string) => + new File([Uint8Array.from(content)], name, { type }) + +export async function uploadAgentDriveSkill( + client: ConsoleClient, + { + agentId, + fileName, + filePath, + }: { + agentId: string + fileName: string + filePath: string + }, +): Promise { + const upload = await toSkillArchiveUpload({ fileName, filePath }) + return client.agent.byAgentId.skills.upload.post({ + body: { file: createUploadFile(upload.buffer, upload.name, 'application/zip') }, + params: { agent_id: agentId }, + }) +} + +export async function uploadAgentConfigFileToDraft( + client: ConsoleClient, + { + agentId, + fileName, + filePath, + }: { + agentId: string + fileName: string + filePath: string + }, +): Promise { + const uploadedFile = await client.files.upload.post({ + body: { file: createUploadFile(await readFile(filePath), fileName, 'text/plain') }, + }) + const body: AgentConfigFileUploadResponse = await client.agent.byAgentId.config.files.post({ + body: { upload_file_id: uploadedFile.id }, + params: { agent_id: agentId }, + }) + const file = body.file + if (!file.file_id) throw new Error(`Agent v2 config file ${fileName} did not return a file_id.`) + + return { + file_id: file.file_id, + file_kind: 'upload_file', + hash: file.hash, + mime_type: file.mime_type, + name: file.name, + size: file.size, + } +} + +export async function uploadAgentConfigSkillToDraft( + client: ConsoleClient, + { + agentId, + fileName, + filePath, + }: { + agentId: string + fileName: string + filePath: string + }, +): Promise { + const upload = await toSkillArchiveUpload({ fileName, filePath }) + const body: AgentConfigSkillUploadResponse = + await client.agent.byAgentId.config.skills.upload.post({ + body: { file: createUploadFile(upload.buffer, upload.name, 'application/zip') }, + params: { agent_id: agentId }, }) - await expectApiResponseOK(response, `Upload Agent v2 drive skill ${fileName} for ${agentId}`) - return (await response.json()) as AgentSkillUploadResponse - } finally { - await ctx.dispose() + const skill = body.skill + if (!skill.file_id) throw new Error(`Agent v2 config skill ${fileName} did not return a file_id.`) + + return { + description: skill.description, + file_id: skill.file_id, + file_kind: 'tool_file', + hash: skill.hash, + mime_type: skill.mime_type, + name: skill.name, + size: skill.size, } } -export async function uploadAgentConfigFileToDraft({ - agentId, - fileName, - filePath, -}: { - agentId: string - fileName: string - filePath: string -}): Promise { - const ctx = await createApiContext() - try { - const uploadResponse = await ctx.post('/console/api/files/upload', { - multipart: { - file: { - buffer: await readFile(filePath), - mimeType: 'text/plain', - name: fileName, - }, - }, - }) - await expectApiResponseOK(uploadResponse, `Upload Agent v2 config source file ${fileName}`) - const uploadedFile = (await uploadResponse.json()) as UploadedConsoleFile - - const commitResponse = await ctx.post(`/console/api/agent/${agentId}/config/files`, { - data: { - upload_file_id: uploadedFile.id, - }, - }) - await expectApiResponseOK( - commitResponse, - `Commit Agent v2 config file ${fileName} for ${agentId}`, - ) - const body = (await commitResponse.json()) as AgentConfigFileUploadResponse - const file = body.file - if (!file.file_id) throw new Error(`Agent v2 config file ${fileName} did not return a file_id.`) - - return { - file_id: file.file_id, - file_kind: 'upload_file', - hash: file.hash, - mime_type: file.mime_type, - name: file.name, - size: file.size, - } - } finally { - await ctx.dispose() - } -} - -export async function uploadAgentConfigSkillToDraft({ - agentId, - fileName, - filePath, -}: { - agentId: string - fileName: string - filePath: string -}): Promise { - const ctx = await createApiContext() - try { - const upload = await toSkillArchiveUpload({ fileName, filePath }) - const response = await ctx.post(`/console/api/agent/${agentId}/config/skills/upload`, { - multipart: { - file: { - buffer: upload.buffer, - mimeType: 'application/zip', - name: upload.name, - }, - }, - }) - await expectApiResponseOK(response, `Upload Agent v2 config skill ${fileName} for ${agentId}`) - const body = (await response.json()) as AgentConfigSkillUploadResponse - const skill = body.skill - if (!skill.file_id) - throw new Error(`Agent v2 config skill ${fileName} did not return a file_id.`) - - return { - description: skill.description, - file_id: skill.file_id, - file_kind: 'tool_file', - hash: skill.hash, - mime_type: skill.mime_type, - name: skill.name, - size: skill.size, - } - } finally { - await ctx.dispose() - } -} - -export async function getAgentDriveSkills(agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/drive/skills`) - await expectApiResponseOK(response, `Get Agent v2 drive skills for ${agentId}`) - const body = (await response.json()) as AgentDriveSkillListResponse - return body.items ?? [] - } finally { - await ctx.dispose() - } -} - -export async function deleteAgentConfigFile(agentId: string, name: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.delete( - `/console/api/agent/${agentId}/config/files/${encodeURIComponent(name)}`, - ) - await expectApiResponseOK(response, `Delete Agent v2 config file ${name} for ${agentId}`) - } finally { - await ctx.dispose() - } -} - -export async function deleteAgentConfigSkill(agentId: string, name: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.delete( - `/console/api/agent/${agentId}/config/skills/${encodeURIComponent(name)}`, - ) - await expectApiResponseOK(response, `Delete Agent v2 config skill ${name} for ${agentId}`) - } finally { - await ctx.dispose() - } -} - -export async function deleteAgentDriveFile(agentId: string, key: string): Promise { - const ctx = await createApiContext() - try { - const searchParams = new URLSearchParams({ key }) - const response = await ctx.delete(`/console/api/agent/${agentId}/files?${searchParams}`) - await expectApiResponseOK(response, `Delete Agent v2 drive file ${key} for ${agentId}`) - } finally { - await ctx.dispose() - } +export async function getAgentDriveSkills( + client: ConsoleClient, + agentId: string, +): Promise { + const body: AgentDriveSkillListResponse = await client.agent.byAgentId.drive.skills.get({ + params: { agent_id: agentId }, + }) + return body.items ?? [] } diff --git a/e2e/features/agent-v2/support/agent.ts b/e2e/features/agent-v2/support/agent.ts index 167b964bfed..c2fa98ea081 100644 --- a/e2e/features/agent-v2/support/agent.ts +++ b/e2e/features/agent-v2/support/agent.ts @@ -1,11 +1,12 @@ import type { AgentAppComposerResponse, + AgentAppCreatePayload, AgentAppDetailWithSite, AgentReferencingWorkflowResponse, AgentReferencingWorkflowsResponse, AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' -import { createApiContext, expectApiResponseOK } from '../../../support/api' +import type { ConsoleClient } from '../../../support/api/console-client' import { assertE2EResourceName, createE2EResourceName } from '../../../support/naming' import { createPublishableAgentSoulConfig, @@ -13,21 +14,6 @@ import { normalAgentSoulConfig, } from './agent-soul' -export type AgentSeed = Pick< - AgentAppDetailWithSite, - | 'active_config_is_published' - | 'app_id' - | 'backing_app_id' - | 'description' - | 'enable_site' - | 'id' - | 'name' - | 'role' - | 'site' -> & { - active_config_snapshot_id?: string | null -} - export type CreateTestAgentOptions = { description?: string name?: string @@ -37,134 +23,87 @@ export type CreateTestAgentOptions = { export const getAgentConfigurePath = (agentId: string) => `/agents/${agentId}/configure` export const getAgentAccessPath = (agentId: string) => `/agents/${agentId}/access` -export async function createTestAgent({ - description = 'Created by Dify E2E.', - name = createE2EResourceName('Agent'), - role = 'E2E test assistant', -}: CreateTestAgentOptions = {}): Promise { +export async function createTestAgent( + client: ConsoleClient, + { + description = 'Created by Dify E2E.', + name = createE2EResourceName('Agent'), + role = 'E2E test assistant', + }: CreateTestAgentOptions = {}, +): Promise { assertE2EResourceName(name, 'Agent') - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/agent', { - data: { - description, - icon: '🤖', - icon_background: '#FFEAD5', - icon_type: 'emoji', - name, - role, - }, - }) - await expectApiResponseOK(response, 'Create Agent v2 test agent') - return (await response.json()) as AgentSeed - } finally { - await ctx.dispose() - } + const body = { + description, + icon: '🤖', + icon_background: '#FFEAD5', + icon_type: 'emoji', + name, + role, + } satisfies AgentAppCreatePayload + + return client.agent.post({ body }) } -export async function createConfiguredTestAgent({ - agentSoul = normalAgentSoulConfig, - seed, -}: { - agentSoul?: AgentSoulConfig - seed?: CreateTestAgentOptions -} = {}): Promise { - const agent = await createTestAgent(seed) - await saveAgentComposerDraft(agent.id, agentSoul) +export async function createConfiguredTestAgent( + client: ConsoleClient, + { + agentSoul = normalAgentSoulConfig, + seed, + }: { + agentSoul?: AgentSoulConfig + seed?: CreateTestAgentOptions + } = {}, +): Promise { + const agent = await createTestAgent(client, seed) + await saveAgentComposerDraft(client, agent.id, agentSoul) return agent } -export async function getTestAgent(agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}`) - await expectApiResponseOK(response, `Get Agent v2 test agent ${agentId}`) - return (await response.json()) as AgentSeed - } finally { - await ctx.dispose() - } -} - -export async function deleteTestAgent(agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.delete(`/console/api/agent/${agentId}`) - await expectApiResponseOK(response, `Delete Agent v2 test agent ${agentId}`) - } finally { - await ctx.dispose() - } -} - export async function saveAgentComposerDraft( + client: ConsoleClient, agentId: string, agentSoul: AgentSoulConfig = defaultAgentSoulConfig, ): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.put(`/console/api/agent/${agentId}/composer`, { - data: { - agent_soul: agentSoul, - save_strategy: 'save_to_current_version', - variant: 'agent_app', - }, - }) - await expectApiResponseOK(response, `Save Agent v2 composer draft for ${agentId}`) - return (await response.json()) as AgentAppComposerResponse - } finally { - await ctx.dispose() - } + return client.agent.byAgentId.composer.put({ + body: { + agent_soul: agentSoul, + save_strategy: 'save_to_current_version', + variant: 'agent_app', + }, + params: { agent_id: agentId }, + }) } export async function getAgentReferencingWorkflows( + client: ConsoleClient, agentId: string, ): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/referencing-workflows`) - await expectApiResponseOK(response, `Get Agent v2 referencing workflows for ${agentId}`) - const body = (await response.json()) as AgentReferencingWorkflowsResponse - return body.data ?? [] - } finally { - await ctx.dispose() - } + const body: AgentReferencingWorkflowsResponse = + await client.agent.byAgentId.referencingWorkflows.get({ params: { agent_id: agentId } }) + return body.data ?? [] } -export async function getAgentComposerDraft(agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/composer`) - await expectApiResponseOK(response, `Get Agent v2 composer draft for ${agentId}`) - return (await response.json()) as AgentAppComposerResponse - } finally { - await ctx.dispose() - } -} - -export async function ensureAgentComposerDraftIsPublishable(agentId: string): Promise { - const composer = await getAgentComposerDraft(agentId) +async function ensureAgentComposerDraftIsPublishable( + client: ConsoleClient, + agentId: string, +): Promise { + const composer = await client.agent.byAgentId.composer.get({ params: { agent_id: agentId } }) if (!composer.agent_soul?.model) await saveAgentComposerDraft( + client, agentId, createPublishableAgentSoulConfig(composer.agent_soul ?? defaultAgentSoulConfig), ) } -export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/publish`, { - data: { version_note: versionNote }, - }) - await expectApiResponseOK(response, `Publish Agent v2 test agent ${agentId}`) - } finally { - await ctx.dispose() - } -} - export async function publishAgentWithPublishableDraft( + client: ConsoleClient, agentId: string, versionNote = 'E2E publish', ): Promise { - await ensureAgentComposerDraftIsPublishable(agentId) - await publishAgent(agentId, versionNote) + await ensureAgentComposerDraftIsPublishable(client, agentId) + await client.agent.byAgentId.publish.post({ + body: { version_note: versionNote }, + params: { agent_id: agentId }, + }) } diff --git a/e2e/features/agent-v2/support/fixtures/access.ts b/e2e/features/agent-v2/support/fixtures/access.ts index 3dfb7ce414d..bb4ad8e7206 100644 --- a/e2e/features/agent-v2/support/fixtures/access.ts +++ b/e2e/features/agent-v2/support/fixtures/access.ts @@ -1,48 +1,43 @@ -import type { AgentReferencingWorkflowsResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' import { requirePreseededAgent, requirePreseededWorkflow } from './agents' import { failFixturePrerequisite } from './common' export async function requirePreseededAgentWorkflowReference( world: DifyWorld, + client: ConsoleClient, agentName: string, workflowName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) - const workflow = await requirePreseededWorkflow(world, workflowName) + const workflow = await requirePreseededWorkflow(world, client, workflowName) - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/referencing-workflows`) - await expectApiResponseOK(response, `Check preseeded Agent workflow reference ${agentName}`) - const references = (await response.json()) as AgentReferencingWorkflowsResponse - const reference = references.data?.find( - (item) => item.app_id === workflow.id || item.app_name === workflow.name, + const references = await client.agent.byAgentId.referencingWorkflows.get({ + params: { agent_id: agent.id }, + }) + const reference = references.data?.find( + (item) => item.app_id === workflow.id || item.app_name === workflow.name, + ) + + if (!reference) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is not referenced by workflow "${workflowName}".`, ) + } - if (!reference) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is not referenced by workflow "${workflowName}".`, - ) - } + if (!reference.node_ids || reference.node_ids.length < 1) { + return failFixturePrerequisite( + world, + `Preseeded workflow "${workflowName}" does not expose Agent reference nodes for "${agentName}".`, + ) + } - if (!reference.node_ids || reference.node_ids.length < 1) { - return failFixturePrerequisite( - world, - `Preseeded workflow "${workflowName}" does not expose Agent reference nodes for "${agentName}".`, - ) - } - - return { - id: workflow.id, - kind: 'workflow', - name: workflow.name, - } - } finally { - await ctx.dispose() + return { + id: workflow.id, + kind: 'workflow', + name: workflow.name, } } diff --git a/e2e/features/agent-v2/support/fixtures/agents.ts b/e2e/features/agent-v2/support/fixtures/agents.ts index 3173177f86c..e0ab6620d97 100644 --- a/e2e/features/agent-v2/support/fixtures/agents.ts +++ b/e2e/features/agent-v2/support/fixtures/agents.ts @@ -1,10 +1,6 @@ -import type { - AgentAppComposerResponse, - AgentDriveSkillListResponse, -} from '@dify/contracts/api/console/agent/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -15,9 +11,8 @@ import { asArray, asRecord, asString, - buildQuery, failFixturePrerequisite, - findConsoleResourceByName, + findResourceByName, hasNamedOrKeyedEntry, } from './common' import { requireReadyPreseededDataset } from './datasets' @@ -77,14 +72,13 @@ const hasKnowledgeSet = ( export async function requirePreseededAgent( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { - const query = buildQuery({ limit: '20', name: resourceName, page: '1' }) - const resource = await findConsoleResourceByName({ - action: `Check preseeded Agent ${resourceName}`, - path: `/console/api/agent?${query}`, - resourceName, + const response = await client.agent.get({ + query: { limit: 20, name: resourceName, page: 1 }, }) + const resource = findResourceByName(response.data, resourceName) if (!resource) return failFixturePrerequisite(world, `Preseeded Agent "${resourceName}" was not found.`) @@ -98,14 +92,13 @@ export async function requirePreseededAgent( export async function requirePreseededWorkflow( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { - const query = buildQuery({ limit: '20', mode: 'workflow', name: resourceName, page: '1' }) - const resource = await findConsoleResourceByName({ - action: `Check preseeded workflow ${resourceName}`, - path: `/console/api/apps?${query}`, - resourceName, + const response = await client.apps.get({ + query: { limit: 20, mode: 'workflow', name: resourceName, page: 1 }, }) + const resource = findResourceByName(response.data, resourceName) if (!resource) return failFixturePrerequisite(world, `Preseeded workflow "${resourceName}" was not found.`) @@ -119,236 +112,231 @@ export async function requirePreseededWorkflow( export async function requirePreseededAgentDriveSkill( world: DifyWorld, + client: ConsoleClient, agentName: string, skillName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/drive/skills`) - await expectApiResponseOK(response, `Check preseeded Agent skill ${skillName}`) - const body = (await response.json()) as AgentDriveSkillListResponse - const skill = body.items?.find((item) => item.name === skillName) + const response = await client.agent.byAgentId.drive.skills.get({ + params: { agent_id: agent.id }, + }) + const skill = response.items?.find((item) => item.name === skillName) - if (!skill) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" does not include drive skill "${skillName}".`, - ) - } + if (!skill) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" does not include drive skill "${skillName}".`, + ) + } - return { - id: skill.path, - kind: 'skill', - name: skill.name, - } - } finally { - await ctx.dispose() + return { + id: skill.path, + kind: 'skill', + name: skill.name, } } export async function requirePreseededFullConfigAgentCoreConfiguration( world: DifyWorld, + client: ConsoleClient, agentName: string, ): Promise { - const stableModel = await requireAgentBuilderStableChatModel(world) + const stableModel = await requireAgentBuilderStableChatModel(world, client) - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) await requirePreseededAgentDriveSkill( world, + client, agentName, agentBuilderPreseededResources.summarySkill, ) - const jsonTool = await requirePreseededTool(world, agentBuilderPreseededResources.jsonReplaceTool) + const jsonTool = await requirePreseededTool( + world, + client, + agentBuilderPreseededResources.jsonReplaceTool, + ) const knowledgeBase = await requireReadyPreseededDataset( world, + client, agentBuilderPreseededResources.agentKnowledgeBase, ) - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent core configuration ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const soul = body.agent_soul ?? {} - const missing: string[] = [] + const response = await client.agent.byAgentId.composer.get({ + params: { agent_id: agent.id }, + }) + const soul = response.agent_soul ?? {} + const missing: string[] = [] - const model = asRecord(soul.model) - if (model.model_provider !== stableModel.provider || model.model !== stableModel.name) - missing.push(`${agentBuilderPreseededResources.stableChatModel} model config`) + const model = asRecord(soul.model) + if (model.model_provider !== stableModel.provider || model.model !== stableModel.name) + missing.push(`${agentBuilderPreseededResources.stableChatModel} model config`) - const prompt = asString(asRecord(soul.prompt).system_prompt) - if (!prompt.includes(agentBuilderExpectedTokens.agentReply)) - missing.push(`Prompt token ${agentBuilderExpectedTokens.agentReply}`) + const prompt = asString(asRecord(soul.prompt).system_prompt) + if (!prompt.includes(agentBuilderExpectedTokens.agentReply)) + missing.push(`Prompt token ${agentBuilderExpectedTokens.agentReply}`) - const files = asArray(soul.config_files) - for (const fileName of [ - agentBuilderTestMaterials.smallFile, - agentBuilderTestMaterials.specialFilename, - ]) { - if (!hasNamedOrKeyedEntry(files, fileName)) missing.push(`file ${fileName}`) - } - - const skills = asArray(soul.config_skills) - if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) - missing.push(agentBuilderPreseededResources.summarySkill) - - const { providerName, toolName } = splitToolResourceId(jsonTool.id) - const parsedTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) - if ( - parsedTool.ok && - !hasToolEntry(asArray(asRecord(soul.tools).dify_tools), { - providerDisplayName: parsedTool.providerName, - providerName, - toolDisplayName: parsedTool.toolName, - toolName, - }) - ) { - missing.push(agentBuilderPreseededResources.jsonReplaceTool) - } - - if (!hasKnowledgeDataset(soul, knowledgeBase)) - missing.push(agentBuilderPreseededResources.agentKnowledgeBase) - - if (missing.length > 0) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is missing core fixture configuration: ${missing.join(', ')}.`, - ) - } - - return agent - } finally { - await ctx.dispose() + const files = asArray(soul.config_files) + for (const fileName of [ + agentBuilderTestMaterials.smallFile, + agentBuilderTestMaterials.specialFilename, + ]) { + if (!hasNamedOrKeyedEntry(files, fileName)) missing.push(`file ${fileName}`) } + + const skills = asArray(soul.config_skills) + if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) + missing.push(agentBuilderPreseededResources.summarySkill) + + const { providerName, toolName } = splitToolResourceId(jsonTool.id) + const parsedTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) + if ( + parsedTool.ok && + !hasToolEntry(asArray(asRecord(soul.tools).dify_tools), { + providerDisplayName: parsedTool.providerName, + providerName, + toolDisplayName: parsedTool.toolName, + toolName, + }) + ) { + missing.push(agentBuilderPreseededResources.jsonReplaceTool) + } + + if (!hasKnowledgeDataset(soul, knowledgeBase)) + missing.push(agentBuilderPreseededResources.agentKnowledgeBase) + + if (missing.length > 0) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is missing core fixture configuration: ${missing.join(', ')}.`, + ) + } + + return agent } export async function requirePreseededToolStatesAgentConfiguration( world: DifyWorld, + client: ConsoleClient, agentName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) await requirePreseededAgentDriveSkill( world, + client, agentName, agentBuilderPreseededResources.summarySkill, ) - const jsonTool = await requirePreseededTool(world, agentBuilderPreseededResources.jsonReplaceTool) + const jsonTool = await requirePreseededTool( + world, + client, + agentBuilderPreseededResources.jsonReplaceTool, + ) const tavilyTool = await requirePreseededTool( world, + client, agentBuilderPreseededResources.tavilySearchTool, ) - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent tool states ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const soul = body.agent_soul ?? {} - const toolItems = asArray(asRecord(soul.tools).dify_tools) - const missing: string[] = [] + const response = await client.agent.byAgentId.composer.get({ + params: { agent_id: agent.id }, + }) + const soul = response.agent_soul ?? {} + const toolItems = asArray(asRecord(soul.tools).dify_tools) + const missing: string[] = [] - const skills = asArray(soul.config_skills) - if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) - missing.push(agentBuilderPreseededResources.summarySkill) + const skills = asArray(soul.config_skills) + if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) + missing.push(agentBuilderPreseededResources.summarySkill) - const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId( - jsonTool.id, - ) - const parsedJsonTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) - if ( - parsedJsonTool.ok && - !findToolEntry(toolItems, { - providerDisplayName: parsedJsonTool.providerName, - providerName: jsonProviderName, - toolDisplayName: parsedJsonTool.toolName, - toolName: jsonToolName, - }) - ) { - missing.push(agentBuilderPreseededResources.jsonReplaceTool) - } - - const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId( - tavilyTool.id, - ) - const parsedTavilyTool = splitToolDisplayName(agentBuilderPreseededResources.tavilySearchTool) - const tavilyEntry = parsedTavilyTool.ok - ? findToolEntry(toolItems, { - providerDisplayName: parsedTavilyTool.providerName, - providerName: tavilyProviderName, - toolDisplayName: parsedTavilyTool.toolName, - toolName: tavilyToolName, - }) - : undefined - - if (!tavilyEntry) { - missing.push(agentBuilderPreseededResources.tavilySearchTool) - } else if (!hasUnauthorizedToolCredentialState(tavilyEntry)) { - missing.push( - `${agentBuilderPreseededResources.tavilySearchTool} unauthorized credential state`, - ) - } - - if (missing.length > 0) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is missing tool state fixture configuration: ${missing.join(', ')}.`, - ) - } - - return agent - } finally { - await ctx.dispose() + const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId( + jsonTool.id, + ) + const parsedJsonTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) + if ( + parsedJsonTool.ok && + !findToolEntry(toolItems, { + providerDisplayName: parsedJsonTool.providerName, + providerName: jsonProviderName, + toolDisplayName: parsedJsonTool.toolName, + toolName: jsonToolName, + }) + ) { + missing.push(agentBuilderPreseededResources.jsonReplaceTool) } + + const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId( + tavilyTool.id, + ) + const parsedTavilyTool = splitToolDisplayName(agentBuilderPreseededResources.tavilySearchTool) + const tavilyEntry = parsedTavilyTool.ok + ? findToolEntry(toolItems, { + providerDisplayName: parsedTavilyTool.providerName, + providerName: tavilyProviderName, + toolDisplayName: parsedTavilyTool.toolName, + toolName: tavilyToolName, + }) + : undefined + + if (!tavilyEntry) { + missing.push(agentBuilderPreseededResources.tavilySearchTool) + } else if (!hasUnauthorizedToolCredentialState(tavilyEntry)) { + missing.push(`${agentBuilderPreseededResources.tavilySearchTool} unauthorized credential state`) + } + + if (missing.length > 0) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is missing tool state fixture configuration: ${missing.join(', ')}.`, + ) + } + + return agent } export async function requirePreseededDualRetrievalAgentConfiguration( world: DifyWorld, + client: ConsoleClient, agentName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) const knowledgeBase = await requireReadyPreseededDataset( world, + client, agentBuilderPreseededResources.agentKnowledgeBase, ) - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent dual retrieval ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const soul = body.agent_soul ?? {} - const missing: string[] = [] + const response = await client.agent.byAgentId.composer.get({ + params: { agent_id: agent.id }, + }) + const soul = response.agent_soul ?? {} + const missing: string[] = [] - if (!hasKnowledgeSet(soul, knowledgeBase, { queryMode: 'generated_query' })) - missing.push('Agent decide Knowledge Retrieval') + if (!hasKnowledgeSet(soul, knowledgeBase, { queryMode: 'generated_query' })) + missing.push('Agent decide Knowledge Retrieval') - if ( - !hasKnowledgeSet(soul, knowledgeBase, { - queryMode: 'user_query', - queryValue: agentBuilderFixedInputs.customKnowledgeQuery, - }) - ) { - missing.push('Custom query Knowledge Retrieval') - } - - if (missing.length > 0) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is missing dual retrieval fixture configuration: ${missing.join(', ')}.`, - ) - } - - return agent - } finally { - await ctx.dispose() + if ( + !hasKnowledgeSet(soul, knowledgeBase, { + queryMode: 'user_query', + queryValue: agentBuilderFixedInputs.customKnowledgeQuery, + }) + ) { + missing.push('Custom query Knowledge Retrieval') } + + if (missing.length > 0) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is missing dual retrieval fixture configuration: ${missing.join(', ')}.`, + ) + } + + return agent } diff --git a/e2e/features/agent-v2/support/fixtures/common.ts b/e2e/features/agent-v2/support/fixtures/common.ts index a73bcf841e4..6e30a3e0871 100644 --- a/e2e/features/agent-v2/support/fixtures/common.ts +++ b/e2e/features/agent-v2/support/fixtures/common.ts @@ -1,5 +1,4 @@ import type { DifyWorld } from '../../../support/world' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' export type PreseededResource = NonNullable< DifyWorld['agentBuilder']['fixtures']['preseededResources'][string] @@ -10,15 +9,6 @@ export type NamedResource = { name: string } -export type NamedResourceCollection = { - data: T[] -} - -export type LocalizedLabel = { - en_US?: string - zh_Hans?: string -} - export function failFixturePrerequisite( world: DifyWorld, reason: string, @@ -36,31 +26,8 @@ export function failFixturePrerequisite( throw new Error(message) } -export const findConsoleResourceByName = async ({ - action, - path, - resourceName, -}: { - action: string - path: string - resourceName: string -}) => { - const ctx = await createApiContext() - try { - const response = await ctx.get(path) - await expectApiResponseOK(response, action) - const body = (await response.json()) as NamedResourceCollection - - return body.data.find((item) => item.name === resourceName) - } finally { - await ctx.dispose() - } -} - -export const buildQuery = (params: Record) => new URLSearchParams(params).toString() - -export const matchesNameOrLabel = (value: string, name: string, label?: LocalizedLabel) => - value === name || value === label?.en_US || value === label?.zh_Hans +export const findResourceByName = (resources: T[], resourceName: string) => + resources.find((item) => item.name === resourceName) export const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) @@ -71,6 +38,16 @@ export const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? va export const asString = (value: unknown) => (typeof value === 'string' ? value : '') +export const matchesNameOrLabel = (value: string, name: string, label?: unknown) => { + const localizedLabel = asRecord(label) + + return ( + value === name || + value === asString(localizedLabel.en_US) || + value === asString(localizedLabel.zh_Hans) + ) +} + export const hasNamedOrKeyedEntry = (items: unknown[], expectedName: string) => items.some((item) => { const record = asRecord(item) diff --git a/e2e/features/agent-v2/support/fixtures/datasets.ts b/e2e/features/agent-v2/support/fixtures/datasets.ts index c06aa24f9bf..bcfcc9369fb 100644 --- a/e2e/features/agent-v2/support/fixtures/datasets.ts +++ b/e2e/features/agent-v2/support/fixtures/datasets.ts @@ -1,18 +1,16 @@ import type { - ConsoleSegmentListResponse, DatasetListItemResponse, - DocumentStatusListResponse, DocumentWithSegmentsListResponse, } from '@dify/contracts/api/console/datasets/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../agent-builder-resources' -import { buildQuery, failFixturePrerequisite, findConsoleResourceByName } from './common' +import { failFixturePrerequisite, findResourceByName } from './common' type DocumentIndexingStatus = | 'cleaning' @@ -23,90 +21,58 @@ type DocumentIndexingStatus = | 'waiting' const completedDocumentIndexingStatus: DocumentIndexingStatus = 'completed' -export const getPreseededDataset = async (resourceName: string) => { - const query = buildQuery({ keyword: resourceName, limit: '20', page: '1' }) - - return findConsoleResourceByName({ - action: `Check preseeded dataset ${resourceName}`, - path: `/console/api/datasets?${query}`, - resourceName, - }) -} - -const getDatasetIndexingStatuses = async (datasetId: string, resourceName: string) => { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) - await expectApiResponseOK(response, `Check preseeded dataset indexing status ${resourceName}`) - const body = (await response.json()) as DocumentStatusListResponse - - return body.data - } finally { - await ctx.dispose() - } -} - -const getDatasetDocuments = async (datasetId: string, resourceName: string) => { +const getDatasetDocuments = async (client: ConsoleClient, datasetId: string) => { const documents: DocumentWithSegmentsListResponse['data'] = [] - const ctx = await createApiContext() - try { - let page = 1 - let hasMore = true + let page = 1 + let hasMore = true - while (hasMore) { - const query = buildQuery({ limit: '100', page: String(page) }) - const response = await ctx.get(`/console/api/datasets/${datasetId}/documents?${query}`) - await expectApiResponseOK(response, `List preseeded dataset documents ${resourceName}`) - const body = (await response.json()) as DocumentWithSegmentsListResponse + while (hasMore) { + const response = await client.datasets.byDatasetId.documents.get({ + params: { dataset_id: datasetId }, + query: { limit: '100', page: String(page) }, + }) - documents.push(...body.data) - hasMore = body.has_more - page += 1 - } - - return documents - } finally { - await ctx.dispose() + documents.push(...response.data) + hasMore = response.has_more + page += 1 } + + return documents } const datasetHasEnabledSegmentContainingTokens = async ( + client: ConsoleClient, datasetId: string, - resourceName: string, expectedTokens: string[], ) => { - const documents = await getDatasetDocuments(datasetId, resourceName) - const ctx = await createApiContext() - try { - for (const document of documents) { - const query = buildQuery({ + const documents = await getDatasetDocuments(client, datasetId) + for (const document of documents) { + const response = await client.datasets.byDatasetId.documents.byDocumentId.segments.get({ + params: { + dataset_id: datasetId, + document_id: document.id, + }, + query: { enabled: 'true', keyword: agentBuilderExpectedTokens.knowledgeReply, - limit: '20', - page: '1', - }) - const response = await ctx.get( - `/console/api/datasets/${datasetId}/documents/${document.id}/segments?${query}`, - ) - await expectApiResponseOK(response, `Check preseeded dataset segment content ${resourceName}`) - const body = (await response.json()) as ConsoleSegmentListResponse - const matchingSegment = body.data.find( - (segment) => - segment.enabled && - expectedTokens.every( - (expectedToken) => - segment.content.includes(expectedToken) || - segment.keywords?.some((keyword) => keyword.includes(expectedToken)), - ), - ) + limit: 20, + page: 1, + }, + }) + const matchingSegment = response.data.find( + (segment) => + segment.enabled && + expectedTokens.every( + (expectedToken) => + segment.content.includes(expectedToken) || + segment.keywords?.some((keyword) => keyword.includes(expectedToken)), + ), + ) - if (matchingSegment) return true - } - - return false - } finally { - await ctx.dispose() + if (matchingSegment) return true } + + return false } const toDatasetResource = (resource: DatasetListItemResponse): PreseededResource => ({ @@ -117,9 +83,13 @@ const toDatasetResource = (resource: DatasetListItemResponse): PreseededResource export async function requireReadyPreseededDataset( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { - const resource = await getPreseededDataset(resourceName) + const response = await client.datasets.get({ + query: { keyword: resourceName, limit: 20, page: 1 }, + }) + const resource = findResourceByName(response.data, resourceName) if (!resource) return failFixturePrerequisite(world, `Preseeded dataset "${resourceName}" was not found.`) @@ -135,7 +105,10 @@ export async function requireReadyPreseededDataset( ) } - const statuses = await getDatasetIndexingStatuses(resource.id, resourceName) + const indexingStatus = await client.datasets.byDatasetId.indexingStatus.get({ + params: { dataset_id: resource.id }, + }) + const statuses = indexingStatus.data if (statuses.length < 1) { return failFixturePrerequisite( world, @@ -160,8 +133,8 @@ export async function requireReadyPreseededDataset( agentBuilderExpectedTokens.knowledgeReply, ] const hasExpectedToken = await datasetHasEnabledSegmentContainingTokens( + client, resource.id, - resourceName, requiredTokens, ) diff --git a/e2e/features/agent-v2/support/fixtures/models.ts b/e2e/features/agent-v2/support/fixtures/models.ts index 1c4d210a423..b00d1448a95 100644 --- a/e2e/features/agent-v2/support/fixtures/models.ts +++ b/e2e/features/agent-v2/support/fixtures/models.ts @@ -1,9 +1,5 @@ -import type { - DefaultModelDataResponse, - ProviderWithModelsResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' import { agentBuilderPreseededResources } from '../agent-builder-resources' import { failFixturePrerequisite } from './common' @@ -73,6 +69,7 @@ export function readAgentBuilderAgentDecisionChatModelConfig(): ModelFixtureConf async function requireAgentBuilderModel( world: DifyWorld, + client: ConsoleClient, config: ModelFixtureConfig, { requireActive, @@ -82,83 +79,70 @@ async function requireAgentBuilderModel( ): Promise> { if (!config.ok) return failFixturePrerequisite(world, config.reason) - const ctx = await createApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/models/model-types/${config.type}`, + const response = await client.workspaces.current.models.modelTypes.byModelType.get({ + params: { model_type: config.type }, + }) + const provider = response.data.find((item) => matchesProvider(item.provider, config.provider)) + const model = provider?.models.find( + (item) => + item.model === config.value || + item.label?.en_US === config.value || + item.label?.zh_Hans === config.value, + ) + + if (!provider || !model) { + return failFixturePrerequisite( + world, + `${config.resourceName} was not found as ${config.provider}/${config.value} (${config.type}).`, ) - await expectApiResponseOK(response, `Check ${config.resourceName}`) - const body = (await response.json()) as { data: ProviderWithModelsResponse[] } - const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) - const model = provider?.models.find( - (item) => - item.model === config.value || - item.label?.en_US === config.value || - item.label?.zh_Hans === config.value, + } + + if (requireActive && model.status !== activeModelStatus) { + return failFixturePrerequisite( + world, + `${config.resourceName} is ${model.status ?? 'missing status'} instead of ${activeModelStatus}.`, ) + } - if (!provider || !model) { - return failFixturePrerequisite( - world, - `${config.resourceName} was not found as ${config.provider}/${config.value} (${config.type}).`, - ) - } - - if (requireActive && model.status !== activeModelStatus) { - return failFixturePrerequisite( - world, - `${config.resourceName} is ${model.status ?? 'missing status'} instead of ${activeModelStatus}.`, - ) - } - - return { - name: model.model, - provider: provider.provider, - type: config.type, - } - } finally { - await ctx.dispose() + return { + name: model.model, + provider: provider.provider, + type: config.type, } } export async function requireAgentBuilderStableChatModel( world: DifyWorld, + client: ConsoleClient, ): Promise> { - return requireAgentBuilderModel(world, readAgentBuilderStableChatModelConfig(), { + return requireAgentBuilderModel(world, client, readAgentBuilderStableChatModelConfig(), { requireActive: true, }) } export async function requireAgentBuilderSpeechToTextModel( world: DifyWorld, + client: ConsoleClient, ): Promise> { - const ctx = await createApiContext() - let defaultModel: NonNullable - - try { - const response = await ctx.get( - '/console/api/workspaces/current/default-model?model_type=speech2text', + const response = await client.workspaces.current.defaultModel.get({ + query: { model_type: 'speech2text' }, + }) + if (!response.data) { + return failFixturePrerequisite( + world, + `${agentBuilderPreseededResources.speechToTextModel} is not configured.`, + { + owner: 'model-provider/seed', + remediation: + 'Configure an active workspace default Speech-to-Text model before running the external scenario.', + }, ) - await expectApiResponseOK(response, `Check ${agentBuilderPreseededResources.speechToTextModel}`) - const body = (await response.json()) as DefaultModelDataResponse - if (!body.data) { - return failFixturePrerequisite( - world, - `${agentBuilderPreseededResources.speechToTextModel} is not configured.`, - { - owner: 'model-provider/seed', - remediation: - 'Configure an active workspace default Speech-to-Text model before running the external scenario.', - }, - ) - } - defaultModel = body.data - } finally { - await ctx.dispose() } + const defaultModel = response.data return requireAgentBuilderModel( world, + client, { ok: true, provider: defaultModel.provider.provider, @@ -174,8 +158,9 @@ export async function requireAgentBuilderSpeechToTextModel( export async function requireAgentBuilderAgentDecisionChatModel( world: DifyWorld, + client: ConsoleClient, ): Promise> { - return requireAgentBuilderModel(world, readAgentBuilderAgentDecisionChatModelConfig(), { + return requireAgentBuilderModel(world, client, readAgentBuilderAgentDecisionChatModelConfig(), { requireActive: true, }) } diff --git a/e2e/features/agent-v2/support/fixtures/tools.ts b/e2e/features/agent-v2/support/fixtures/tools.ts index e5639dcf40d..b380848920b 100644 --- a/e2e/features/agent-v2/support/fixtures/tools.ts +++ b/e2e/features/agent-v2/support/fixtures/tools.ts @@ -1,17 +1,8 @@ +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' -import type { LocalizedLabel, PreseededResource } from './common' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' +import type { PreseededResource } from './common' import { asRecord, asString, failFixturePrerequisite, matchesNameOrLabel } from './common' -type BuiltinToolProvider = { - label?: LocalizedLabel - name: string - tools: Array<{ - label?: LocalizedLabel - name: string - }> -} - export const splitToolDisplayName = (resourceName: string) => { const [providerName, toolName] = resourceName.split('/').map((item) => item.trim()) @@ -88,32 +79,26 @@ export const hasUnauthorizedToolCredentialState = (item: unknown) => { export async function requirePreseededTool( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { const parsed = splitToolDisplayName(resourceName) if (!parsed.ok) return failFixturePrerequisite(world, parsed.reason) - const ctx = await createApiContext() - try { - const response = await ctx.get('/console/api/workspaces/current/tools/builtin') - await expectApiResponseOK(response, `Check preseeded tool ${resourceName}`) - const providers = (await response.json()) as BuiltinToolProvider[] - const provider = providers.find((item) => - matchesNameOrLabel(parsed.providerName, item.name, item.label), - ) - const tool = provider?.tools.find((item) => - matchesNameOrLabel(parsed.toolName, item.name, item.label), - ) + const providers = await client.workspaces.current.tools.builtin.get() + const provider = providers.find((item) => + matchesNameOrLabel(parsed.providerName, item.name, item.label), + ) + const tool = provider?.tools?.find((item) => + matchesNameOrLabel(parsed.toolName, item.name, item.label), + ) - if (!provider || !tool) - return failFixturePrerequisite(world, `Preseeded tool "${resourceName}" was not found.`) + if (!provider || !tool) + return failFixturePrerequisite(world, `Preseeded tool "${resourceName}" was not found.`) - return { - id: `${provider.name}/${tool.name}`, - kind: 'tool', - name: resourceName, - } - } finally { - await ctx.dispose() + return { + id: `${provider.name}/${tool.name}`, + kind: 'tool', + name: resourceName, } } diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts index 45bca4df042..fd0ec8f8728 100644 --- a/e2e/features/agent-v2/support/seed.ts +++ b/e2e/features/agent-v2/support/seed.ts @@ -3,32 +3,15 @@ import type { AgentSoulConfig, AgentSoulDifyToolConfig, } from '@dify/contracts/api/console/agent/types.gen' -import type { - ConsoleSegmentListResponse, - DatasetListItemResponse, - DocumentStatusListResponse, - DocumentWithSegmentsListResponse, - KnowledgeConfig, -} from '@dify/contracts/api/console/datasets/types.gen' -import type { - AvailableModelListResponse, - DefaultModelDataResponse, - ModelProviderListResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' +import type { KnowledgeConfig } from '@dify/contracts/api/console/datasets/types.gen' +import type { ModelType } from '@dify/contracts/api/console/workspaces/types.gen' import type { SeedContext, SeedResource, SeedTask } from '../../../support/seed' -import type { UploadedConsoleFile } from './agent-drive' import { readFile } from 'node:fs/promises' -import { - createApiContext, - createTestApp, - expectApiResponseOK, - publishWorkflowApp, - syncAgentV2WorkflowDraft, -} from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' import { bootstrapMarketplacePlugins } from '../../../support/marketplace-plugins' import { sleep } from '../../../support/process' import { blocked, created, skipped, updated, verified } from '../../../support/seed' -import { createTestAgent, publishAgent, saveAgentComposerDraft } from './agent' +import { createTestAgent, saveAgentComposerDraft } from './agent' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -45,19 +28,15 @@ import { createAgentSoulConfigWithModel, normalAgentSoulConfig, } from './agent-soul' -import { - buildQuery, - findConsoleResourceByName, - isRecord, - matchesNameOrLabel, -} from './fixtures/common' +import { isRecord, matchesNameOrLabel } from './fixtures/common' import { splitToolDisplayName } from './fixtures/tools' import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from './test-materials' +import { syncAgentV2WorkflowDraft } from './workflow' type StableModel = { name: string provider: string - type: string + type: ModelType } type ToolResource = SeedResource & { @@ -91,16 +70,33 @@ const matchesProviderLabel = ( provider.label?.en_US === expected || provider.label?.zh_Hans === expected +const parseModelType = (value: string | undefined, fallback: ModelType): ModelType => { + const modelType = value?.trim() + if (!modelType) return fallback + + switch (modelType) { + case 'llm': + case 'moderation': + case 'rerank': + case 'speech2text': + case 'text-embedding': + case 'tts': + return modelType + default: + throw new Error(`Unsupported model type "${modelType}".`) + } +} + const stableModelConfig = (): StableModel => ({ name: process.env.E2E_STABLE_MODEL_NAME?.trim() || 'gpt-5-nano', provider: process.env.E2E_STABLE_MODEL_PROVIDER?.trim() || 'openai', - type: process.env.E2E_STABLE_MODEL_TYPE?.trim() || 'llm', + type: parseModelType(process.env.E2E_STABLE_MODEL_TYPE, 'llm'), }) const agentDecisionModelConfig = (): StableModel => ({ name: process.env.E2E_AGENT_DECISION_MODEL_NAME?.trim() || 'gpt-5.5', provider: process.env.E2E_AGENT_DECISION_MODEL_PROVIDER?.trim() || 'openai', - type: process.env.E2E_AGENT_DECISION_MODEL_TYPE?.trim() || 'llm', + type: parseModelType(process.env.E2E_AGENT_DECISION_MODEL_TYPE, 'llm'), }) const speechToTextModelConfig = (): StableModel => ({ @@ -126,120 +122,86 @@ const parseJsonEnv = (envName: string) => { } } -const findModel = async (config: StableModel, title: string) => { - const ctx = await createApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/models/model-types/${config.type}`, - ) - await expectApiResponseOK(response, `Check ${title}`) - const body = (await response.json()) as AvailableModelListResponse - const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) - const model = provider?.models.find( - (item) => - item.model === config.name || - item.label?.en_US === config.name || - item.label?.zh_Hans === config.name, - ) +const findModel = async (client: SeedContext['consoleClient'], config: StableModel) => { + const body = await client.workspaces.current.models.modelTypes.byModelType.get({ + params: { model_type: config.type }, + }) + const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) + const model = provider?.models.find( + (item) => + item.model === config.name || + item.label?.en_US === config.name || + item.label?.zh_Hans === config.name, + ) - if (!provider || !model) return undefined + if (!provider || !model) return undefined - return { - name: model.model, - provider: provider.provider, - status: model.status, - type: config.type, - } - } finally { - await ctx.dispose() + return { + name: model.model, + provider: provider.provider, + status: model.status, + type: config.type, } } -const resolveProvider = async (config: StableModel) => { - const ctx = await createApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/model-providers?${buildQuery({ model_type: config.type })}`, - ) - await expectApiResponseOK(response, `Resolve model provider ${config.provider}`) - const body = (await response.json()) as ModelProviderListResponse - const provider = body.data.find((item) => matchesProviderLabel(item, config.provider)) +const resolveProvider = async (client: SeedContext['consoleClient'], config: StableModel) => { + const body = await client.workspaces.current.modelProviders.get({ + query: { model_type: config.type }, + }) + const provider = body.data.find((item) => matchesProviderLabel(item, config.provider)) - return { - availableProviders: body.data.map((provider) => provider.provider), - credential: provider?.custom_configuration.available_credentials?.find( - (credential) => credential.credential_name === stableModelCredentialName, - ), - provider: provider?.provider, - } - } finally { - await ctx.dispose() + return { + availableProviders: body.data.map((provider) => provider.provider), + credential: provider?.custom_configuration.available_credentials?.find( + (credential) => credential.credential_name === stableModelCredentialName, + ), + provider: provider?.provider, } } -const selectCustomProviderCredential = async (provider: string, credentialId?: string) => { - const ctx = await createApiContext() - try { - if (credentialId) { - const switchResponse = await ctx.post( - `/console/api/workspaces/current/model-providers/${provider}/credentials/switch`, - { - data: { credential_id: credentialId }, - }, - ) - await expectApiResponseOK(switchResponse, `Switch model provider credential for ${provider}`) - } - - const preferredResponse = await ctx.post( - `/console/api/workspaces/current/model-providers/${provider}/preferred-provider-type`, - { - data: { preferred_provider_type: 'custom' }, - }, - ) - await expectApiResponseOK( - preferredResponse, - `Select custom provider credential for ${provider}`, - ) - } finally { - await ctx.dispose() +const selectCustomProviderCredential = async ( + client: SeedContext['consoleClient'], + provider: string, + credentialId?: string, +) => { + if (credentialId) { + await client.workspaces.current.modelProviders.byProvider.credentials.switch.post({ + body: { credential_id: credentialId }, + params: { provider }, + }) } + + await client.workspaces.current.modelProviders.byProvider.preferredProviderType.post({ + body: { preferred_provider_type: 'custom' }, + params: { provider }, + }) } const upsertStableProviderCredential = async ( + client: SeedContext['consoleClient'], provider: string, credentials: Record, credentialId?: string, ) => { - const ctx = await createApiContext() - try { - if (credentialId) { - const updateResponse = await ctx.put( - `/console/api/workspaces/current/model-providers/${provider}/credentials`, - { - data: { - credential_id: credentialId, - credentials, - name: stableModelCredentialName, - }, - }, - ) - await expectApiResponseOK(updateResponse, `Update model provider credential for ${provider}`) - return - } - - const createResponse = await ctx.post( - `/console/api/workspaces/current/model-providers/${provider}/credentials`, - { - data: { - credentials, - name: stableModelCredentialName, - }, + if (credentialId) { + await client.workspaces.current.modelProviders.byProvider.credentials.put({ + body: { + credential_id: credentialId, + credentials, + name: stableModelCredentialName, }, - ) - await expectApiResponseOK(createResponse, `Create model provider credential for ${provider}`) - } finally { - await ctx.dispose() + params: { provider }, + }) + return } + + await client.workspaces.current.modelProviders.byProvider.credentials.post({ + body: { + credentials, + name: stableModelCredentialName, + }, + params: { provider }, + }) } const seedModel = async ( @@ -252,7 +214,7 @@ const seedModel = async ( title: string }, ) => { - const existing = await findModel(config, title) + const existing = await findModel(context.consoleClient, config) const resource = { id: `${existing?.provider ?? config.provider}/${existing?.name ?? config.name}`, kind: 'model', @@ -271,7 +233,10 @@ const seedModel = async ( if (!credentials.ok) return blocked(title, `${config.provider}/${config.name} is not active; ${credentials.reason}`) - const { availableProviders, credential, provider } = await resolveProvider(config) + const { availableProviders, credential, provider } = await resolveProvider( + context.consoleClient, + config, + ) if (!provider) { const available = availableProviders.length > 0 ? availableProviders.join(', ') : 'none' return blocked( @@ -281,14 +246,19 @@ const seedModel = async ( } try { - await upsertStableProviderCredential(provider, credentials.value, credential?.credential_id) - await selectCustomProviderCredential(provider, credential?.credential_id) + await upsertStableProviderCredential( + context.consoleClient, + provider, + credentials.value, + credential?.credential_id, + ) + await selectCustomProviderCredential(context.consoleClient, provider, credential?.credential_id) } catch (error) { const message = error instanceof Error ? error.message : String(error) if (!message.includes(`Credential with name '${stableModelCredentialName}' already exists.`)) return blocked(title, message) - const refreshed = await resolveProvider(config) + const refreshed = await resolveProvider(context.consoleClient, config) if (!refreshed.provider || !refreshed.credential) { return blocked( title, @@ -298,17 +268,22 @@ const seedModel = async ( try { await upsertStableProviderCredential( + context.consoleClient, refreshed.provider, credentials.value, refreshed.credential.credential_id, ) - await selectCustomProviderCredential(refreshed.provider, refreshed.credential.credential_id) + await selectCustomProviderCredential( + context.consoleClient, + refreshed.provider, + refreshed.credential.credential_id, + ) } catch (retryError) { return blocked(title, retryError instanceof Error ? retryError.message : String(retryError)) } } - const seeded = await findModel(config, title) + const seeded = await findModel(context.consoleClient, config) if (seeded?.status !== activeModelStatus) { return blocked( title, @@ -335,47 +310,13 @@ const seedAgentDecisionModel = async (context: SeedContext) => title: agentBuilderPreseededResources.agentDecisionChatModel, }) -const getDefaultModel = async (modelType: string) => { - const ctx = await createApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/default-model?${buildQuery({ model_type: modelType })}`, - ) - await expectApiResponseOK(response, `Get default ${modelType} model`) - const body = (await response.json()) as DefaultModelDataResponse - return body.data - } finally { - await ctx.dispose() - } -} - -const setDefaultModel = async (model: StableModel) => { - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/default-model', { - data: { - model_settings: [ - { - model: model.name, - model_type: model.type, - provider: model.provider, - }, - ], - }, - }) - await expectApiResponseOK(response, `Set default ${model.type} model`) - } finally { - await ctx.dispose() - } -} - const seedSpeechToTextModel = async (context: SeedContext) => { const config = speechToTextModelConfig() const title = agentBuilderPreseededResources.speechToTextModel const modelResult = await seedModel(context, { config, title }) if (modelResult.status === 'blocked' || modelResult.status === 'skipped') return modelResult - const model = await findModel(config, title) + const model = await findModel(context.consoleClient, config) if (!model || model.status !== activeModelStatus) return blocked(title, `${config.provider}/${config.name} is not active after model setup.`) @@ -384,7 +325,10 @@ const seedSpeechToTextModel = async (context: SeedContext) => { kind: 'model', name: title, } - const defaultModel = await getDefaultModel(config.type) + const defaultModelResponse = await context.consoleClient.workspaces.current.defaultModel.get({ + query: { model_type: config.type }, + }) + const defaultModel = defaultModelResponse.data const isExpectedDefault = defaultModel?.model === model.name && matchesProvider(defaultModel.provider.provider, model.provider) @@ -398,13 +342,23 @@ const seedSpeechToTextModel = async (context: SeedContext) => { `Would set ${model.provider}/${model.name} as the workspace default Speech-to-Text model.`, ) - await setDefaultModel({ - name: model.name, - provider: model.provider, - type: config.type, + await context.consoleClient.workspaces.current.defaultModel.post({ + body: { + model_settings: [ + { + model: model.name, + model_type: config.type, + provider: model.provider, + }, + ], + }, }) - const updatedDefaultModel = await getDefaultModel(config.type) + const updatedDefaultModelResponse = + await context.consoleClient.workspaces.current.defaultModel.get({ + query: { model_type: config.type }, + }) + const updatedDefaultModel = updatedDefaultModelResponse.data if ( updatedDefaultModel?.model !== model.name || !matchesProvider(updatedDefaultModel.provider.provider, model.provider) @@ -418,103 +372,48 @@ const seedSpeechToTextModel = async (context: SeedContext) => { return updated(title, resource) } -type BuiltinToolProvider = { - label?: { en_US?: string; zh_Hans?: string } - name: string - tools: Array<{ - label?: { en_US?: string; zh_Hans?: string } - name: string - }> -} - -const findBuiltinTool = async (displayName: string) => { +const findBuiltinTool = async (client: SeedContext['consoleClient'], displayName: string) => { const parsed = splitToolDisplayName(displayName) if (!parsed.ok) return { ok: false as const, reason: parsed.reason } - const ctx = await createApiContext() - try { - const response = await ctx.get('/console/api/workspaces/current/tools/builtin') - await expectApiResponseOK(response, `Check built-in tool ${displayName}`) - const providers = (await response.json()) as BuiltinToolProvider[] - const provider = providers.find((item) => - matchesNameOrLabel(parsed.providerName, item.name, item.label), - ) - const tool = provider?.tools.find((item) => - matchesNameOrLabel(parsed.toolName, item.name, item.label), - ) + const providers = await client.workspaces.current.tools.builtin.get() + const provider = providers.find((item) => + matchesNameOrLabel(parsed.providerName, item.name, item.label), + ) + const tool = provider?.tools?.find((item) => + matchesNameOrLabel(parsed.toolName, item.name, item.label), + ) - if (!provider || !tool) - return { ok: false as const, reason: `Built-in tool "${displayName}" was not found.` } + if (!provider || !tool) + return { ok: false as const, reason: `Built-in tool "${displayName}" was not found.` } - return { - ok: true as const, - resource: { - id: `${provider.name}/${tool.name}`, - kind: 'tool', - name: displayName, - providerName: provider.name, - toolName: tool.name, - } satisfies ToolResource, - } - } finally { - await ctx.dispose() + return { + ok: true as const, + resource: { + id: `${provider.name}/${tool.name}`, + kind: 'tool', + name: displayName, + providerName: provider.name, + toolName: tool.name, + } satisfies ToolResource, } } const seedTool = (displayName: string): SeedTask => ({ id: `tool:${displayName}`, title: displayName, - async run() { - const result = await findBuiltinTool(displayName) + async run(context) { + const result = await findBuiltinTool(context.consoleClient, displayName) if (!result.ok) return blocked(displayName, result.reason) return verified(displayName, result.resource) }, }) -const uploadConsoleFile = async ( - fileName: string, - filePath: string, -): Promise => { - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/files/upload', { - multipart: { - file: { - buffer: await readFile(filePath), - mimeType: 'text/plain', - name: fileName, - }, - }, - }) - await expectApiResponseOK(response, `Upload seed file ${fileName}`) - return (await response.json()) as UploadedConsoleFile - } finally { - await ctx.dispose() - } -} - -const findDataset = (name: string) => { - const query = buildQuery({ keyword: name, limit: '20', page: '1' }) - return findConsoleResourceByName({ - action: `Find seed dataset ${name}`, - path: `/console/api/datasets?${query}`, - resourceName: name, - }) -} - -const getDatasetDocuments = async (datasetId: string) => { - const ctx = await createApiContext() - try { - const response = await ctx.get( - `/console/api/datasets/${datasetId}/documents?${buildQuery({ limit: '100', page: '1' })}`, - ) - await expectApiResponseOK(response, `List dataset documents ${datasetId}`) - const body = (await response.json()) as DocumentWithSegmentsListResponse - return body.data - } finally { - await ctx.dispose() - } +const findDataset = async (client: SeedContext['consoleClient'], name: string) => { + const body = await client.datasets.get({ query: { keyword: name, limit: 20, page: 1 } }) + const dataset = body.data.find((dataset) => dataset.name === name) + return dataset ? { id: dataset.id, name: dataset.name } : undefined } const requiredKnowledgeSegmentTokens = [ @@ -523,62 +422,54 @@ const requiredKnowledgeSegmentTokens = [ agentBuilderExpectedTokens.knowledgeReply, ] -const datasetHasKnowledgeSegment = async (datasetId: string) => { - const documents = await getDatasetDocuments(datasetId) - const ctx = await createApiContext() - try { - for (const document of documents) { - const response = await ctx.get( - `/console/api/datasets/${datasetId}/documents/${document.id}/segments?${buildQuery({ - enabled: 'true', - keyword: agentBuilderExpectedTokens.knowledgeReply, - limit: '20', - page: '1', - })}`, +const datasetHasKnowledgeSegment = async ( + client: SeedContext['consoleClient'], + datasetId: string, +) => { + const documents = await client.datasets.byDatasetId.documents.get({ + params: { dataset_id: datasetId }, + query: { limit: '100', page: '1' }, + }) + for (const document of documents.data) { + const body = await client.datasets.byDatasetId.documents.byDocumentId.segments.get({ + params: { dataset_id: datasetId, document_id: document.id }, + query: { + enabled: 'true', + keyword: agentBuilderExpectedTokens.knowledgeReply, + limit: 20, + page: 1, + }, + }) + if ( + body.data.some( + (segment) => + segment.enabled && + requiredKnowledgeSegmentTokens.every((token) => segment.content.includes(token)), ) - await expectApiResponseOK( - response, - `Check dataset knowledge segment ${agentBuilderExpectedTokens.knowledgeReply}`, - ) - const body = (await response.json()) as ConsoleSegmentListResponse - if ( - body.data.some( - (segment) => - segment.enabled && - requiredKnowledgeSegmentTokens.every((token) => segment.content.includes(token)), - ) - ) { - return true - } + ) { + return true } - - return false - } finally { - await ctx.dispose() } + + return false } -const waitForDatasetCompleted = async (datasetId: string) => { +const waitForDatasetCompleted = async (client: SeedContext['consoleClient'], datasetId: string) => { const deadline = Date.now() + 180_000 let status = 'missing' while (Date.now() < deadline) { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) - await expectApiResponseOK(response, `Check dataset indexing ${datasetId}`) - const body = (await response.json()) as DocumentStatusListResponse - status = - body.data.length < 1 - ? 'missing' - : body.data.every((item) => item.indexing_status === 'completed') - ? 'completed' - : body.data.map((item) => item.indexing_status ?? 'missing').join(',') + const body = await client.datasets.byDatasetId.indexingStatus.get({ + params: { dataset_id: datasetId }, + }) + status = + body.data.length < 1 + ? 'missing' + : body.data.every((item) => item.indexing_status === 'completed') + ? 'completed' + : body.data.map((item) => item.indexing_status ?? 'missing').join(',') - if (status === 'completed') return { ok: true as const } - } finally { - await ctx.dispose() - } + if (status === 'completed') return { ok: true as const } await sleep(1_000) } @@ -586,11 +477,17 @@ const waitForDatasetCompleted = async (datasetId: string) => { return { ok: false as const, status } } -const addKnowledgeDocument = async (datasetId: string) => { - const uploadedFile = await uploadConsoleFile( - agentBuilderTestMaterials.knowledgeSource, - getAgentBuilderTestMaterialPath('knowledgeSource'), - ) +const addKnowledgeDocument = async (client: SeedContext['consoleClient'], datasetId: string) => { + const fileName = agentBuilderTestMaterials.knowledgeSource + const uploadedFile = await client.files.upload.post({ + body: { + file: new File( + [Uint8Array.from(await readFile(getAgentBuilderTestMaterialPath('knowledgeSource')))], + fileName, + { type: 'text/plain' }, + ), + }, + }) const body = { data_source: { info_list: { @@ -614,36 +511,15 @@ const addKnowledgeDocument = async (datasetId: string) => { }, } satisfies KnowledgeConfig - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/datasets/${datasetId}/documents`, { data: body }) - await expectApiResponseOK(response, `Seed knowledge document ${datasetId}`) - } finally { - await ctx.dispose() - } -} - -const createDataset = async (name: string) => { - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/datasets', { - data: { - indexing_technique: 'economy', - name, - permission: 'only_me', - provider: 'vendor', - }, - }) - await expectApiResponseOK(response, `Create dataset ${name}`) - return (await response.json()) as DatasetListItemResponse - } finally { - await ctx.dispose() - } + await client.datasets.byDatasetId.documents.post({ + body, + params: { dataset_id: datasetId }, + }) } const seedReadyKnowledge = async (context: SeedContext) => { const title = agentBuilderPreseededResources.agentKnowledgeBase - let dataset = await findDataset(title) + let dataset = await findDataset(context.consoleClient, title) if (context.dryRun) { return dataset @@ -652,12 +528,22 @@ const seedReadyKnowledge = async (context: SeedContext) => { } const wasCreated = !dataset - dataset ??= await createDataset(title) + if (!dataset) { + const createdDataset = await context.consoleClient.datasets.post({ + body: { + indexing_technique: 'economy', + name: title, + permission: 'only_me', + provider: 'vendor', + }, + }) + dataset = { id: createdDataset.id, name: createdDataset.name } + } - const hasKnowledgeSegment = await datasetHasKnowledgeSegment(dataset.id) - if (!hasKnowledgeSegment) await addKnowledgeDocument(dataset.id) + const hasKnowledgeSegment = await datasetHasKnowledgeSegment(context.consoleClient, dataset.id) + if (!hasKnowledgeSegment) await addKnowledgeDocument(context.consoleClient, dataset.id) - const indexing = await waitForDatasetCompleted(dataset.id) + const indexing = await waitForDatasetCompleted(context.consoleClient, dataset.id) if (!indexing.ok) { return blocked( title, @@ -665,7 +551,7 @@ const seedReadyKnowledge = async (context: SeedContext) => { ) } - return datasetHasKnowledgeSegment(dataset.id).then((ready) => { + return datasetHasKnowledgeSegment(context.consoleClient, dataset.id).then((ready) => { if (!ready) { return blocked( title, @@ -678,17 +564,13 @@ const seedReadyKnowledge = async (context: SeedContext) => { }) } -const ensureAgent = async (name: string) => { - const query = buildQuery({ limit: '20', name, page: '1' }) - const existing = await findConsoleResourceByName({ - action: `Find seed Agent ${name}`, - path: `/console/api/agent?${query}`, - resourceName: name, - }) +const ensureAgent = async (client: SeedContext['consoleClient'], name: string) => { + const body = await client.agent.get({ query: { limit: 20, name, page: 1 } }) + const existing = body.data.find((agent) => agent.name === name) if (existing) return { agent: existing, created: false } - const agent = await createTestAgent({ + const agent = await createTestAgent(client, { description: 'Created by Dify E2E seed.', name, role: 'E2E seeded assistant', @@ -724,24 +606,32 @@ const toolConfig = (tool: ToolResource) => tool_name: tool.toolName, }) satisfies AgentSoulDifyToolConfig -const saveSeededAgentComposer = async ({ - agentId, - config, - shouldPublish = false, -}: { - agentId: string - config: AgentSoulConfig - shouldPublish?: boolean -}) => { - await saveAgentComposerDraft(agentId, config) - if (shouldPublish) await publishAgent(agentId, 'E2E seed') +const saveSeededAgentComposer = async ( + client: SeedContext['consoleClient'], + { + agentId, + config, + shouldPublish = false, + }: { + agentId: string + config: AgentSoulConfig + shouldPublish?: boolean + }, +) => { + await saveAgentComposerDraft(client, agentId, config) + if (shouldPublish) { + await client.agent.byAgentId.publish.post({ + body: { version_note: 'E2E seed' }, + params: { agent_id: agentId }, + }) + } } -const ensureDriveSkill = async (agentId: string) => { - const skills = await getAgentDriveSkills(agentId) +const ensureDriveSkill = async (client: SeedContext['consoleClient'], agentId: string) => { + const skills = await getAgentDriveSkills(client, agentId) if (skills.some((skill) => skill.name === agentBuilderPreseededResources.summarySkill)) return - await uploadAgentDriveSkill({ + await uploadAgentDriveSkill(client, { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), @@ -762,26 +652,26 @@ const seedFullConfigAgent = async (context: SeedContext) => { if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) - const { agent, created: wasCreated } = await ensureAgent(title) + const { agent, created: wasCreated } = await ensureAgent(context.consoleClient, title) const agentId = agent.id - const smallFile = await uploadAgentConfigFileToDraft({ + const smallFile = await uploadAgentConfigFileToDraft(context.consoleClient, { agentId, fileName: agentBuilderTestMaterials.smallFile, filePath: getAgentBuilderTestMaterialPath('smallFile'), }) - const specialFile = await uploadAgentConfigFileToDraft({ + const specialFile = await uploadAgentConfigFileToDraft(context.consoleClient, { agentId, fileName: agentBuilderTestMaterials.specialFilename, filePath: getAgentBuilderTestMaterialPath('specialFilename'), }) - const summarySkill = await uploadAgentConfigSkillToDraft({ + const summarySkill = await uploadAgentConfigSkillToDraft(context.consoleClient, { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), }) - await ensureDriveSkill(agentId) + await ensureDriveSkill(context.consoleClient, agentId) - await saveSeededAgentComposer({ + await saveSeededAgentComposer(context.consoleClient, { agentId, config: createAgentSoulConfigWithKnowledgeDataset( createAgentSoulConfigWithModel( @@ -816,14 +706,14 @@ const seedToolStatesAgent = async (context: SeedContext) => { return blocked(title, `${agentBuilderPreseededResources.tavilySearchTool} is not ready.`) if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) - const { agent, created: wasCreated } = await ensureAgent(title) - const summarySkill = await uploadAgentConfigSkillToDraft({ + const { agent, created: wasCreated } = await ensureAgent(context.consoleClient, title) + const summarySkill = await uploadAgentConfigSkillToDraft(context.consoleClient, { agentId: agent.id, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), }) - await ensureDriveSkill(agent.id) - await saveSeededAgentComposer({ + await ensureDriveSkill(context.consoleClient, agent.id) + await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: { ...normalAgentSoulConfig, @@ -845,9 +735,9 @@ const seedDualRetrievalAgent = async (context: SeedContext) => { return blocked(title, `${agentBuilderPreseededResources.agentKnowledgeBase} is not ready.`) if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) - const { agent, created: wasCreated } = await ensureAgent(title) + const { agent, created: wasCreated } = await ensureAgent(context.consoleClient, title) const datasetConfig = { id: dataset.id, name: dataset.name } satisfies AgentKnowledgeDatasetConfig - await saveSeededAgentComposer({ + await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: { ...normalAgentSoulConfig, @@ -879,13 +769,12 @@ const seedDualRetrievalAgent = async (context: SeedContext) => { return wasCreated ? created(title, resource) : updated(title, resource) } -const findWorkflow = (name: string) => { - const query = buildQuery({ limit: '20', mode: 'workflow', name, page: '1' }) - return findConsoleResourceByName({ - action: `Find seed workflow ${name}`, - path: `/console/api/apps?${query}`, - resourceName: name, +const findWorkflow = async (client: SeedContext['consoleClient'], name: string) => { + const body = await client.apps.get({ + query: { limit: 20, mode: 'workflow', name, page: 1 }, }) + const workflow = body.data.find((workflow) => workflow.name === name) + return workflow ? { id: workflow.id, name: workflow.name } : undefined } const seedWorkflowReference = async (context: SeedContext) => { @@ -897,21 +786,25 @@ const seedWorkflowReference = async (context: SeedContext) => { if (context.dryRun) return skipped(title, `Would create or update Agent "${title}" and workflow "${workflowName}".`) - const { agent, created: wasAgentCreated } = await ensureAgent(title) - await saveSeededAgentComposer({ + const { agent, created: wasAgentCreated } = await ensureAgent(context.consoleClient, title) + await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: createAgentSoulConfigWithModel(normalAgentSoulConfig, model), shouldPublish: true, }) - let workflow = await findWorkflow(workflowName) + let workflow = await findWorkflow(context.consoleClient, workflowName) let wasWorkflowCreated = false if (!workflow) { - workflow = await createTestApp(workflowName, 'workflow') + const createdWorkflow = await createTestApp(context.consoleClient, workflowName, 'workflow') + workflow = { id: createdWorkflow.id, name: createdWorkflow.name } wasWorkflowCreated = true } - await syncAgentV2WorkflowDraft(workflow.id, agent.id) - await publishWorkflowApp(workflow.id) + await syncAgentV2WorkflowDraft(context.consoleClient, workflow.id, agent.id) + await context.consoleClient.apps.byAppId.workflows.publish.post({ + body: {}, + params: { app_id: workflow.id }, + }) const resource = { id: workflow.id, kind: 'workflow', name: workflowName } return wasAgentCreated || wasWorkflowCreated diff --git a/e2e/features/agent-v2/support/workflow.ts b/e2e/features/agent-v2/support/workflow.ts new file mode 100644 index 00000000000..19ca155f6d8 --- /dev/null +++ b/e2e/features/agent-v2/support/workflow.ts @@ -0,0 +1,67 @@ +import type { SyncDraftWorkflowPayload } from '@dify/contracts/api/console/apps/types.gen' +import type { ConsoleClient } from '../../../support/api/console-client' +import * as z from 'zod' + +const agentV2WorkflowNodeId = 'agent-v2' +const zWorkflowGraph = z.object({ + nodes: z.array( + z.object({ + data: z.record(z.string(), z.unknown()).optional(), + id: z.string(), + }), + ), +}) + +export async function getAgentV2WorkflowNodeData(client: ConsoleClient, appId: string) { + const draft = await client.apps.byAppId.workflows.draft.get({ params: { app_id: appId } }) + const graph = zWorkflowGraph.parse(draft.graph) + const agentNode = graph.nodes.find((node) => node.id === agentV2WorkflowNodeId) + if (!agentNode) + throw new Error( + `Workflow draft ${appId} does not include Agent v2 node ${agentV2WorkflowNodeId}.`, + ) + + return agentNode.data ?? {} +} + +export async function syncAgentV2WorkflowDraft( + client: ConsoleClient, + appId: string, + agentId: string, +): Promise { + const body = { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'agent-v2', + type: 'custom', + position: { x: 420, y: 282 }, + data: { + id: 'agent-v2', + type: 'agent', + title: 'Agent', + desc: '', + agent_binding: { + binding_type: 'roster_agent', + agent_id: agentId, + }, + agent_node_kind: 'dify_agent', + version: '2', + }, + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } }) +} diff --git a/e2e/features/agent-v2/tools.feature b/e2e/features/agent-v2/tools.feature index 42e3cb975c2..262389b8f69 100644 --- a/e2e/features/agent-v2/tools.feature +++ b/e2e/features/agent-v2/tools.feature @@ -26,13 +26,3 @@ Feature: Agent v2 tools Then the Agent v2 draft should be published and up to date When I send the Agent v2 Backend service API JSON Replace request Then the Agent v2 Backend service API response should include the JSON Replace E2E marker - - @core - Scenario: Tool selector shows an empty state for a missing tool search - Given I am signed in as the default E2E admin - And a basic configured Agent v2 test agent has been created via API - When I open the Agent v2 configure page - And I search for the missing Agent v2 tool from the Tools selector - Then I should see the Agent v2 tool selector empty state - When I clear the Agent v2 tool selector search - Then I should see the Agent v2 tool selector ready for another search diff --git a/e2e/features/apps/share-app.feature b/e2e/features/apps/share-app.feature index 265599ecd16..36399c4123b 100644 --- a/e2e/features/apps/share-app.feature +++ b/e2e/features/apps/share-app.feature @@ -1,17 +1,5 @@ @apps @core -Feature: Share app publicly - - @authenticated - Scenario: Enable public share for a published workflow app - Given I am signed in as the default E2E admin - And a "workflow" app has been created via API - And a minimal runnable workflow draft has been synced - When I open the app from the app list - And I open the publish panel - And I publish the app - And I navigate to the app overview page - And I enable the Web App share - Then the Web App should be in service +Feature: Use a shared workflow app @unauthenticated Scenario: Access a shared workflow app without authentication diff --git a/e2e/features/apps/web-app-service.feature b/e2e/features/apps/web-app-service.feature new file mode 100644 index 00000000000..f027ed3e9da --- /dev/null +++ b/e2e/features/apps/web-app-service.feature @@ -0,0 +1,19 @@ +@apps @authenticated @core +Feature: Manage Web App service + + Scenario: Disable and restore a published workflow Web App + Given I am signed in as the default E2E admin + And a new runnable workflow app has been published + When I navigate to the app overview page + And I open the app information panel + Then the Web App should be in service + When an anonymous visitor opens the Web App + Then the published workflow Web App should be accessible + When I disable the Web App + Then the Web App should be disabled + When the anonymous visitor reloads the Web App + Then the published workflow Web App should be unavailable + When I enable the Web App + Then the Web App should be in service + When the anonymous visitor reloads the Web App + Then the published workflow Web App should be accessible diff --git a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts index 32a58440429..2c9999e9e11 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts @@ -1,11 +1,7 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { - createAgentApiKey, - sendAgentServiceApiChatMessage, - setAgentApiAccess, -} from '../../agent-v2/support/access-point' +import { sendAgentServiceApiChatMessage } from '../../agent-v2/support/access-point' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -15,8 +11,12 @@ import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers' async function enableAgentApiAccessWithKey(world: DifyWorld) { const agentId = getCurrentAgentId(world) - const apiAccess = await setAgentApiAccess(agentId, true) - const apiKey = await createAgentApiKey(agentId) + const client = world.getConsoleClient() + const apiAccess = await client.agent.byAgentId.apiEnable.post({ + body: { enable_api: true }, + params: { agent_id: agentId }, + }) + const apiKey = await client.agent.byAgentId.apiKeys.post({ params: { agent_id: agentId } }) world.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url world.agentBuilder.accessPoint.generatedApiKey = apiKey.token diff --git a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts index dabe1269416..9182bc7ad5a 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts @@ -2,7 +2,6 @@ import type { Page } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens } from '../../agent-v2/support/agent-builder-resources' import { getCurrentAgentId, getDialog, getWebAppCard } from './access-point-helpers' @@ -11,7 +10,10 @@ const WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS = 180_000 const getWebAppMessageInput = (webAppPage: Page) => webAppPage.getByPlaceholder(/^Talk to /).last() const recordComposerDraftSnapshot = async (world: DifyWorld) => { - const draft = await getAgentComposerDraft(getCurrentAgentId(world)) + const agentId = getCurrentAgentId(world) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) world.agentBuilder.accessPoint.composerDraftSnapshot = JSON.stringify(draft.agent_soul ?? {}) } @@ -170,7 +172,10 @@ Then( const snapshot = this.agentBuilder.accessPoint.composerDraftSnapshot if (!snapshot) throw new Error('No Agent v2 orchestration draft snapshot was recorded.') - const draft = await getAgentComposerDraft(getCurrentAgentId(this)) + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) expect(JSON.stringify(draft.agent_soul ?? {})).toBe(snapshot) }, diff --git a/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts index 5fdb3d72577..472bad694d2 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts @@ -14,7 +14,7 @@ Then( agentBuilderPreseededResources.workflowReferenceAgent, 'agent', ) - const references = await getAgentReferencingWorkflows(agent.id) + const references = await getAgentReferencingWorkflows(this.getConsoleClient(), agent.id) const reference = references.find( (item) => item.app_id === workflow.id || item.app_name === workflow.name, ) diff --git a/e2e/features/step-definitions/agent-v2/access-point.steps.ts b/e2e/features/step-definitions/agent-v2/access-point.steps.ts index 61858c02dfb..79b0f06aa40 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -2,7 +2,7 @@ import type { DifyWorld } from '../../support/world' import type { AccessSurfaceName } from './access-point-helpers' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { setAgentApiAccess, setAgentSiteAccessAndGetURL } from '../../agent-v2/support/access-point' +import { enableAgentWebApp } from '../../agent-v2/support/access-point' import { publishAgentWithPublishableDraft } from '../../agent-v2/support/agent' import { getAccessRegion, @@ -12,21 +12,25 @@ import { } from './access-point-helpers' Given('the Agent v2 draft has been published via API', async function (this: DifyWorld) { - await publishAgentWithPublishableDraft(getCurrentAgentId(this)) + await publishAgentWithPublishableDraft(this.getConsoleClient(), getCurrentAgentId(this)) }) Given( /^Agent v2 (Web app|Backend service API) access has been enabled via API$/, async function (this: DifyWorld, surface: AccessSurfaceName) { if (surface === 'Web app') { - this.agentBuilder.accessPoint.webAppURL = await setAgentSiteAccessAndGetURL( + this.agentBuilder.accessPoint.webAppURL = await enableAgentWebApp( + this.getConsoleClient(), getCurrentAgentId(this), - true, ) return } - const apiAccess = await setAgentApiAccess(getCurrentAgentId(this), true) + const agentId = getCurrentAgentId(this) + const apiAccess = await this.getConsoleClient().agent.byAgentId.apiEnable.post({ + body: { enable_api: true }, + params: { agent_id: agentId }, + }) this.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url }, ) diff --git a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts index a462bb830f7..bce34d4ff54 100644 --- a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts @@ -1,9 +1,8 @@ -import type { PostAgentByAgentIdCopyResponse } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' +import { zPostAgentByAgentIdCopyResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' import { createE2EResourceName } from '../../../support/naming' -import { getAgentComposerDraft, getTestAgent, publishAgent } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -19,8 +18,10 @@ import { openAgentKnowledgeRetrievalDialog, } from './configure-helpers' -const getComposerInheritanceSnapshot = async (agentId: string) => { - const draft = await getAgentComposerDraft(agentId) +const getComposerInheritanceSnapshot = async (world: DifyWorld, agentId: string) => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) const soul = draft.agent_soul ?? {} const model = asRecord(soul.model) const prompt = asRecord(soul.prompt) @@ -68,7 +69,11 @@ const getComposerInheritanceSnapshot = async (agentId: string) => { Given( 'the preseeded Agent v2 {string} has been published via API', async function (this: DifyWorld, agentName: string) { - await publishAgent(getPreseededAgent(this, agentName).id) + const agentId = getPreseededAgent(this, agentName).id + await this.getConsoleClient().agent.byAgentId.publish.post({ + body: { version_note: 'E2E publish' }, + params: { agent_id: agentId }, + }) }, ) @@ -100,7 +105,7 @@ When( const copyResponse = await copyResponsePromise expect(copyResponse.status()).toBe(201) - const copiedAgent = (await copyResponse.json()) as PostAgentByAgentIdCopyResponse + const copiedAgent = zPostAgentByAgentIdCopyResponse.parse(await copyResponse.json()) if (!copiedAgent.id) throw new Error('Agent v2 duplicate response did not include a copied Agent ID.') @@ -183,11 +188,12 @@ Then( 'Stable chat model fixture setup must run before asserting the duplicated Agent.', ) + const client = this.getConsoleClient() const [sourceDetail, duplicatedDetail, sourceSnapshot, duplicatedSnapshot] = await Promise.all([ - getTestAgent(sourceAgent.id), - getTestAgent(duplicatedAgentId), - getComposerInheritanceSnapshot(sourceAgent.id), - getComposerInheritanceSnapshot(duplicatedAgentId), + client.agent.byAgentId.get({ params: { agent_id: sourceAgent.id } }), + client.agent.byAgentId.get({ params: { agent_id: duplicatedAgentId } }), + getComposerInheritanceSnapshot(this, sourceAgent.id), + getComposerInheritanceSnapshot(this, duplicatedAgentId), ]) expect(duplicatedDetail.id).toBe(duplicatedAgentId) @@ -226,7 +232,9 @@ Then( await expect .poll( async () => { - const draft = await getAgentComposerDraft(sourceAgent.id) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: sourceAgent.id }, + }) return asString(asRecord(draft.agent_soul?.prompt).system_prompt) }, diff --git a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts index 25c0cb69973..228a54eaa99 100644 --- a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts @@ -1,6 +1,6 @@ -import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' +import { zPostAgentResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' import { createE2EResourceName } from '../../../support/naming' @@ -30,7 +30,7 @@ When('I create an Agent v2 test agent from the Agent Roster', async function (th const createResponse = await createResponsePromise expect(createResponse.ok()).toBe(true) - const createdAgent = (await createResponse.json()) as AgentAppDetailWithSite + const createdAgent = zPostAgentResponse.parse(await createResponse.json()) this.createdAgentIds.push(createdAgent.id) this.lastCreatedAgentName = createdAgent.name this.lastCreatedAgentRole = createdAgent.role ?? undefined diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts index ff2e1c62e40..4e07595de4f 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -1,12 +1,12 @@ +import type { AgentBuildDraftResponse } from '@dify/contracts/api/console/agent/types.gen' import type { Page, Response } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { readFile } from 'node:fs/promises' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft, saveAgentComposerDraft } from '../../agent-v2/support/agent' +import { saveAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuildDraftExists, - getAgentBuildDraft, saveAgentBuildDraft, } from '../../agent-v2/support/agent-build-draft' import { @@ -47,8 +47,7 @@ const getBuildNoteFileButton = (page: Page) => .filter({ hasText: BUILD_NOTE_FILE_NAME }) .filter({ hasText: BUILD_NOTE_GENERATED_BADGE }) -const getConfigNote = (value: Awaited>) => - value.agent_soul?.config_note ?? '' +const getConfigNote = (value: AgentBuildDraftResponse) => value.agent_soul?.config_note ?? '' const getLastBuildChatAnswerText = async (page: Page) => { const answer = page.getByTestId('chat-answer-container').last() @@ -65,7 +64,8 @@ const saveSupportedBuildDraft = async ( { retainSkillInNormalDraft }: { retainSkillInNormalDraft: boolean }, ) => { const agentId = getCurrentAgentId(world) - const configFile = await uploadAgentConfigFileToDraft({ + const client = world.getConsoleClient() + const configFile = await uploadAgentConfigFileToDraft(client, { agentId, fileName: agentBuilderTestMaterials.smallFile, filePath: getAgentBuilderTestMaterialPath('smallFile'), @@ -85,11 +85,11 @@ const saveSupportedBuildDraft = async ( : updatedAgentSoulConfig const configSkills = [skill] - await saveAgentComposerDraft(agentId, { + await saveAgentComposerDraft(client, agentId, { ...normalConfig, ...(retainSkillInNormalDraft ? { config_skills: configSkills } : {}), }) - await saveAgentBuildDraft(agentId, { + await saveAgentBuildDraft(client, agentId, { ...updatedConfig, config_files: [configFile], config_skills: configSkills, @@ -123,7 +123,11 @@ Given( ) Given('an Agent v2 Build draft uses the updated E2E prompt', async function (this: DifyWorld) { - await saveAgentBuildDraft(getCurrentAgentId(this), updatedAgentSoulConfig) + await saveAgentBuildDraft( + this.getConsoleClient(), + getCurrentAgentId(this), + updatedAgentSoulConfig, + ) }) Given( @@ -135,6 +139,7 @@ Given( ) await saveAgentBuildDraft( + this.getConsoleClient(), getCurrentAgentId(this), createAgentSoulConfigWithModel( updatedAgentSoulConfig, @@ -287,9 +292,16 @@ Then( async function (this: DifyWorld) { try { await expect - .poll(async () => getConfigNote(await getAgentBuildDraft(getCurrentAgentId(this))), { - timeout: BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.buildDraft.get({ + params: { agent_id: agentId }, + }) + return getConfigNote(draft) + }, + { timeout: BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS }, + ) .toContain(BUILD_NOTE_MARKER) } catch (error) { const lastAnswerText = await getLastBuildChatAnswerText(this.getPage()) @@ -317,7 +329,9 @@ Then( Then('the Agent v2 Build draft should not be checked out', async function (this: DifyWorld) { await expect - .poll(async () => agentBuildDraftExists(getCurrentAgentId(this)), { timeout: 30_000 }) + .poll(async () => agentBuildDraftExists(this.getConsoleClient(), getCurrentAgentId(this)), { + timeout: 30_000, + }) .toBe(false) }) @@ -371,8 +385,13 @@ Then( async function (this: DifyWorld) { await expect .poll( - async () => - (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.config_note ?? '', + async () => { + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + return draft.agent_soul?.config_note ?? '' + }, { timeout: 30_000 }, ) .not.toContain(BUILD_NOTE_MARKER) @@ -385,7 +404,11 @@ Then( await expect .poll( async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const agentSoul = draft.agent_soul const variables = agentSoul?.env?.variables ?? [] return { @@ -412,7 +435,11 @@ Then( await expect .poll( async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const agentSoul = draft.agent_soul const variables = agentSoul?.env?.variables ?? [] return { @@ -439,7 +466,11 @@ Then( await expect .poll( async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const agentSoul = draft.agent_soul return ( agentSoul?.config_skills?.filter( (skill) => skill.name === agentBuilderPreseededResources.summarySkill, diff --git a/e2e/features/step-definitions/agent-v2/configure-helpers.ts b/e2e/features/step-definitions/agent-v2/configure-helpers.ts index 8ae2917b759..da76a65d44c 100644 --- a/e2e/features/step-definitions/agent-v2/configure-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/configure-helpers.ts @@ -1,8 +1,8 @@ import type { Locator } from '@playwright/test' import type { AgentComposerEnvVariable } from '../../agent-v2/support/agent-soul' import type { DifyWorld } from '../../support/world' +import { zPostAgentByAgentIdConfigFilesResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { uploadAgentConfigSkillToDraft } from '../../agent-v2/support/agent-drive' import { normalAgentPrompt } from '../../agent-v2/support/agent-soul' import { @@ -42,8 +42,12 @@ export const getEnvVariableKey = (variable: AgentComposerEnvVariable) => export const getAgentEnvVariableValue = (variables: AgentComposerEnvVariable[], key: string) => variables.find((variable) => getEnvVariableKey(variable) === key)?.value -export const getAgentEnvVariables = async (agentId: string) => - (await getAgentComposerDraft(agentId)).agent_soul?.env?.variables ?? [] +export const getAgentEnvVariables = async (world: DifyWorld, agentId: string) => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + return draft.agent_soul?.env?.variables ?? [] +} export const uploadAgentConfigFile = async ( world: DifyWorld, @@ -71,7 +75,7 @@ export const uploadAgentConfigFile = async ( await dialog.getByRole('button', { name: 'Upload' }).click() const commitResponse = await commitResponsePromise expect(commitResponse.status()).toBe(201) - const committed = (await commitResponse.json()) as { file?: { name?: string } } + const committed = zPostAgentByAgentIdConfigFilesResponse.parse(await commitResponse.json()) await expect(dialog).not.toBeVisible({ timeout: 30_000 }) const committedName = committed.file?.name @@ -118,9 +122,10 @@ export const expectAgentConfigFileSaved = async ( await expect .poll( async () => { - const file = (await getAgentComposerDraft(agentId)).agent_soul?.config_files?.find( - (file) => file.name === fileName, - ) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + const file = draft.agent_soul?.config_files?.find((file) => file.name === fileName) return file ? { @@ -145,7 +150,7 @@ export const expectAgentModelRequiredFeedback = async (page: ReturnType { const agentId = getCurrentAgentId(world) - const skill = await uploadAgentConfigSkillToDraft({ + const skill = await uploadAgentConfigSkillToDraft(world.getConsoleClient(), { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), @@ -241,9 +246,16 @@ export const expectAgentEnvVariableHidden = async (world: DifyWorld, key: string export const expectNormalAgentPromptDraft = async (world: DifyWorld) => { await expect - .poll(async () => (await getAgentComposerDraft(getCurrentAgentId(world))).agent_soul?.prompt, { - timeout: 30_000, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(world) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + return draft.agent_soul?.prompt + }, + { timeout: 30_000 }, + ) .toEqual({ system_prompt: normalAgentPrompt }) } diff --git a/e2e/features/step-definitions/agent-v2/configure.steps.ts b/e2e/features/step-definitions/agent-v2/configure.steps.ts index 89595a852f7..b4ada8ef204 100644 --- a/e2e/features/step-definitions/agent-v2/configure.steps.ts +++ b/e2e/features/step-definitions/agent-v2/configure.steps.ts @@ -6,7 +6,6 @@ import { waitForAgentConfigureAutosaved } from '../../../support/agent-configure import { createConfiguredTestAgent, createTestAgent, - getAgentComposerDraft, getAgentConfigurePath, saveAgentComposerDraft, } from '../../agent-v2/support/agent' @@ -49,16 +48,22 @@ async function selectAgentModel(page: Page, modelName: string) { await page.getByRole('option', { name: new RegExp(`${escapedModelName}(?:\\s|$)`) }).click() } -async function expectAgentComposerPrompt(agentId: string, prompt: string) { +async function expectAgentComposerPrompt(world: DifyWorld, agentId: string, prompt: string) { await expect - .poll(async () => (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt, { - timeout: 30_000, - }) + .poll( + async () => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + return draft.agent_soul?.prompt?.system_prompt + }, + { timeout: 30_000 }, + ) .toBe(prompt) } Given('an Agent v2 test agent has been created via API', async function (this: DifyWorld) { - const agent = await createTestAgent() + const agent = await createTestAgent(this.getConsoleClient()) this.createdAgentIds.push(agent.id) this.lastCreatedAgentName = agent.name this.lastCreatedAgentRole = agent.role ?? undefined @@ -67,7 +72,7 @@ Given('an Agent v2 test agent has been created via API', async function (this: D Given( 'a basic configured Agent v2 test agent has been created via API', async function (this: DifyWorld) { - const agent = await createConfiguredTestAgent() + const agent = await createConfiguredTestAgent(this.getConsoleClient()) this.createdAgentIds.push(agent.id) this.lastCreatedAgentName = agent.name this.lastCreatedAgentRole = agent.role ?? undefined @@ -78,7 +83,7 @@ Given('a runnable Agent v2 test agent has been created via API', async function if (!this.agentBuilder.fixtures.stableModel) throw new Error('Create a runnable Agent v2 test agent after stable model fixture setup.') - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithModel( normalAgentSoulConfig, this.agentBuilder.fixtures.stableModel, @@ -98,7 +103,7 @@ Given( ) } - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithModel( normalAgentSoulConfig, this.agentBuilder.fixtures.agentDecisionModel, @@ -113,15 +118,20 @@ Given( Given('a minimal Agent v2 composer draft has been synced', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) - await saveAgentComposerDraft(agentId) + await saveAgentComposerDraft(this.getConsoleClient(), agentId) }) Given('the Agent v2 composer draft uses the normal E2E prompt', async function (this: DifyWorld) { - await saveAgentComposerDraft(getCurrentAgentId(this), normalAgentSoulConfig) + await saveAgentComposerDraft( + this.getConsoleClient(), + getCurrentAgentId(this), + normalAgentSoulConfig, + ) }) Given('the Agent v2 composer draft is publishable', async function (this: DifyWorld) { await saveAgentComposerDraft( + this.getConsoleClient(), getCurrentAgentId(this), createPublishableAgentSoulConfig(normalAgentSoulConfig), ) @@ -131,7 +141,7 @@ Given( 'the e2e-summary-skill Skill is available to the Agent v2 test agent', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) - const upload = await uploadAgentDriveSkill({ + const upload = await uploadAgentDriveSkill(this.getConsoleClient(), { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), @@ -145,7 +155,7 @@ Given( Then( 'the Agent v2 test agent should include drive skill {string}', async function (this: DifyWorld, skillName: string) { - const skills = await getAgentDriveSkills(getCurrentAgentId(this)) + const skills = await getAgentDriveSkills(this.getConsoleClient(), getCurrentAgentId(this)) expect(skills.map((skill) => skill.name)).toContain(skillName) }, @@ -257,7 +267,7 @@ When('I save the Agent v2 prompt from the first configure tab', async function ( await fillAgentPromptEditor(this.getPage(), concurrentFirstAgentPrompt) await waitForAgentConfigureAutosaved(this.getPage()) - await expectAgentComposerPrompt(agentId, concurrentFirstAgentPrompt) + await expectAgentComposerPrompt(this, agentId, concurrentFirstAgentPrompt) }) When('I save the Agent v2 prompt from the second configure tab', async function (this: DifyWorld) { @@ -268,7 +278,7 @@ When('I save the Agent v2 prompt from the second configure tab', async function await fillAgentPromptEditor(concurrentPage, concurrentSecondAgentPrompt) await waitForAgentConfigureAutosaved(concurrentPage) - await expectAgentComposerPrompt(agentId, concurrentSecondAgentPrompt) + await expectAgentComposerPrompt(this, agentId, concurrentSecondAgentPrompt) }) When('I refresh both Agent v2 configure tabs', async function (this: DifyWorld) { @@ -360,7 +370,10 @@ Then( await expect .poll( async () => { - const prompt = (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const prompt = draft.agent_soul?.prompt?.system_prompt if (prompt && concurrentAgentPrompts.includes(prompt)) savedPrompt = prompt return !!savedPrompt @@ -392,9 +405,16 @@ Then( 'the normal Agent v2 draft should use the updated E2E prompt', async function (this: DifyWorld) { await expect - .poll(async () => (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.prompt, { - timeout: 30_000, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + return draft.agent_soul?.prompt + }, + { timeout: 30_000 }, + ) .toEqual({ system_prompt: updatedAgentPrompt }) }, ) @@ -407,7 +427,11 @@ Then('the Agent v2 draft should use the stable E2E model', async function (this: await expect .poll( async () => { - const model = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.model + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const model = draft.agent_soul?.model const modelConfig = typeof model === 'object' && model !== null && !Array.isArray(model) ? (model as Record) @@ -438,8 +462,11 @@ Then( await expect .poll( async () => { - const draftModel = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul - ?.model + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const draftModel = draft.agent_soul?.model const modelConfig = typeof draftModel === 'object' && draftModel !== null && !Array.isArray(draftModel) ? (draftModel as Record) diff --git a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts index 38573073441..6713a0acad7 100644 --- a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts +++ b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts @@ -1,7 +1,6 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuilderFixedInputs } from '../../agent-v2/support/agent-builder-resources' import { getAgentBuilderTestMaterialPath } from '../../agent-v2/support/test-materials' import { @@ -97,7 +96,10 @@ Then( await expect .poll( async () => { - const env = (await getAgentComposerDraft(agentId)).agent_soul?.env + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const env = draft.agent_soul?.env const variable = env?.variables?.find( (item) => getEnvVariableKey(item) === agentBuilderFixedInputs.envPlainKey, ) @@ -126,7 +128,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), @@ -152,7 +154,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), @@ -178,7 +180,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), @@ -211,7 +213,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { importedValue: getAgentEnvVariableValue( diff --git a/e2e/features/step-definitions/agent-v2/fixtures.steps.ts b/e2e/features/step-definitions/agent-v2/fixtures.steps.ts index b6e52d9422d..1fb531ecc7a 100644 --- a/e2e/features/step-definitions/agent-v2/fixtures.steps.ts +++ b/e2e/features/step-definitions/agent-v2/fixtures.steps.ts @@ -18,19 +18,25 @@ import { import { requirePreseededTool } from '../../agent-v2/support/fixtures/tools' Given('the Agent Builder stable chat model is available', async function (this: DifyWorld) { - const stableModel = await requireAgentBuilderStableChatModel(this) + const stableModel = await requireAgentBuilderStableChatModel(this, this.getConsoleClient()) this.agentBuilder.fixtures.stableModel = stableModel }) Given('the workspace default speech-to-text model is active', async function (this: DifyWorld) { - const speechToTextModel = await requireAgentBuilderSpeechToTextModel(this) + const speechToTextModel = await requireAgentBuilderSpeechToTextModel( + this, + this.getConsoleClient(), + ) this.agentBuilder.fixtures.speechToTextModel = speechToTextModel }) Given('the Agent Builder agent-decision chat model is available', async function (this: DifyWorld) { - const agentDecisionModel = await requireAgentBuilderAgentDecisionChatModel(this) + const agentDecisionModel = await requireAgentBuilderAgentDecisionChatModel( + this, + this.getConsoleClient(), + ) this.agentBuilder.fixtures.agentDecisionModel = agentDecisionModel }) @@ -42,7 +48,7 @@ Given('the Agent v2 runtime backend is available', async function (this: DifyWor Given( 'the Agent Builder preseeded Agent {string} is available', async function (this: DifyWorld, resourceName: string) { - const resource = await requirePreseededAgent(this, resourceName) + const resource = await requirePreseededAgent(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -51,7 +57,7 @@ Given( Given( 'the Agent Builder preseeded workflow {string} is available', async function (this: DifyWorld, resourceName: string) { - const resource = await requirePreseededWorkflow(this, resourceName) + const resource = await requirePreseededWorkflow(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -60,7 +66,7 @@ Given( Given( 'the Agent Builder preseeded dataset {string} is indexed and ready', async function (this: DifyWorld, resourceName: string) { - const resource = await requireReadyPreseededDataset(this, resourceName) + const resource = await requireReadyPreseededDataset(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -69,7 +75,7 @@ Given( Given( 'the Agent Builder preseeded tool {string} is available', async function (this: DifyWorld, resourceName: string) { - const resource = await requirePreseededTool(this, resourceName) + const resource = await requirePreseededTool(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -78,7 +84,11 @@ Given( Given( 'the Agent Builder preseeded Agent {string} includes the core fixture configuration', async function (this: DifyWorld, agentName: string) { - const resource = await requirePreseededFullConfigAgentCoreConfiguration(this, agentName) + const resource = await requirePreseededFullConfigAgentCoreConfiguration( + this, + this.getConsoleClient(), + agentName, + ) this.agentBuilder.fixtures.preseededResources[`${agentName} / core fixture configuration`] = resource @@ -88,7 +98,11 @@ Given( Given( 'the Agent Builder preseeded Agent {string} includes the tool state fixture configuration', async function (this: DifyWorld, agentName: string) { - const resource = await requirePreseededToolStatesAgentConfiguration(this, agentName) + const resource = await requirePreseededToolStatesAgentConfiguration( + this, + this.getConsoleClient(), + agentName, + ) this.agentBuilder.fixtures.preseededResources[ `${agentName} / tool state fixture configuration` @@ -99,7 +113,11 @@ Given( Given( 'the Agent Builder preseeded Agent {string} includes the dual retrieval fixture configuration', async function (this: DifyWorld, agentName: string) { - const resource = await requirePreseededDualRetrievalAgentConfiguration(this, agentName) + const resource = await requirePreseededDualRetrievalAgentConfiguration( + this, + this.getConsoleClient(), + agentName, + ) this.agentBuilder.fixtures.preseededResources[ `${agentName} / dual retrieval fixture configuration` @@ -110,7 +128,12 @@ Given( Given( 'the Agent Builder preseeded Agent {string} is referenced by workflow {string}', async function (this: DifyWorld, agentName: string, workflowName: string) { - const resource = await requirePreseededAgentWorkflowReference(this, agentName, workflowName) + const resource = await requirePreseededAgentWorkflowReference( + this, + this.getConsoleClient(), + agentName, + workflowName, + ) this.agentBuilder.fixtures.preseededResources[`${agentName} / ${workflowName}`] = resource }, diff --git a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts index 54cf64b1ab9..a409ebbea56 100644 --- a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts +++ b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts @@ -2,7 +2,7 @@ import type { Locator } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { createConfiguredTestAgent, getAgentComposerDraft } from '../../agent-v2/support/agent' +import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { agentBuilderFixedInputs, agentBuilderPreseededResources, @@ -31,8 +31,10 @@ const getPreseededKnowledgeBase = (world: DifyWorld) => { const getKnowledgeSection = (world: DifyWorld) => world.getPage().getByRole('region', { name: 'Knowledge Retrieval' }) -const getKnowledgeSets = async (agentId: string) => { - const draft = await getAgentComposerDraft(agentId) +const getKnowledgeSets = async (world: DifyWorld, agentId: string) => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) return asArray(asRecord(draft.agent_soul?.knowledge).sets) } @@ -96,7 +98,7 @@ const expectKnowledgeRetrievalDraft = async ( await expect .poll( async () => { - const knowledgeSets = await getKnowledgeSets(agentId) + const knowledgeSets = await getKnowledgeSets(world, agentId) const knowledgeSet = asRecord(knowledgeSets[0]) const datasets = asArray(knowledgeSet.datasets) const query = asRecord(knowledgeSet.query) @@ -125,7 +127,7 @@ Given( 'a knowledge-backed Agent v2 test agent has been created via API', async function (this: DifyWorld) { const knowledgeBase = getPreseededKnowledgeBase(this) - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithKnowledgeDataset(normalAgentSoulConfig, { id: knowledgeBase.id, name: knowledgeBase.name, @@ -254,7 +256,7 @@ Then( await expect .poll( async () => { - const knowledgeSets = await getKnowledgeSets(agentId) + const knowledgeSets = await getKnowledgeSets(this, agentId) return knowledgeSets.some((set) => asArray(asRecord(set).datasets).some((dataset) => { diff --git a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts index 79819b1af22..77917464595 100644 --- a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts +++ b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts @@ -2,10 +2,11 @@ import type { DataTable } from '@cucumber/cucumber' import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen' import type { AgentV2WorkflowOutputVariable, DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' +import { zDeclaredOutputConfig } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' -import { getWorkflowDraft } from '../../../support/api' +import * as z from 'zod' +import { getAgentV2WorkflowNodeData } from '../../agent-v2/support/workflow' -const agentV2WorkflowNodeId = 'agent-v2' const taskOutputName = 'e2e_report' const renamedTaskOutputName = 'e2e_final_report' @@ -18,26 +19,19 @@ const getCurrentAppId = (world: DifyWorld) => { return appId } -const getAgentV2WorkflowNodeData = async (appId: string) => { - const draft = await getWorkflowDraft(appId) - const agentNode = draft.graph.nodes.find((node) => node.id === agentV2WorkflowNodeId) - if (!agentNode) - throw new Error( - `Workflow draft ${appId} does not include Agent v2 node ${agentV2WorkflowNodeId}.`, - ) +const parseDeclaredOutputs = (value: unknown): DeclaredOutputConfig[] => + z.array(zDeclaredOutputConfig).optional().default([]).parse(value) - return agentNode.data ?? {} +const getDeclaredOutputsFromDraft = async ( + world: DifyWorld, + appId: string, +): Promise => { + const data = await getAgentV2WorkflowNodeData(world.getConsoleClient(), appId) + return parseDeclaredOutputs(data.agent_declared_outputs) } -const getDeclaredOutputsFromDraft = async (appId: string): Promise => { - const data = await getAgentV2WorkflowNodeData(appId) - const outputs = data.agent_declared_outputs - if (!Array.isArray(outputs)) return [] - - return outputs as DeclaredOutputConfig[] -} - -const getOutputVariablesFromDraft = async (appId: string) => getDeclaredOutputsFromDraft(appId) +const getOutputVariablesFromDraft = async (world: DifyWorld, appId: string) => + getDeclaredOutputsFromDraft(world, appId) const waitForWorkflowDraftSave = (world: DifyWorld, appId: string) => world @@ -191,7 +185,7 @@ Then( await expect .poll( async () => { - const outputs = await getOutputVariablesFromDraft(appId) + const outputs = await getOutputVariablesFromDraft(this, appId) return expectedOutputVariables.map((expected) => { const output = outputs.find((item) => item.name === expected.name) @@ -233,7 +227,7 @@ Then( await expect .poll( async () => { - const outputs = await getDeclaredOutputsFromDraft(appId) + const outputs = await getDeclaredOutputsFromDraft(this, appId) const response = outputs.find((output) => output.name === 'response') return { @@ -309,10 +303,8 @@ async function expectAgentTaskOutputReference( await expect .poll( async () => { - const data = await getAgentV2WorkflowNodeData(appId) - const outputs = Array.isArray(data.agent_declared_outputs) - ? (data.agent_declared_outputs as DeclaredOutputConfig[]) - : [] + const data = await getAgentV2WorkflowNodeData(world.getConsoleClient(), appId) + const outputs = parseDeclaredOutputs(data.agent_declared_outputs) const expectedOutput = outputs.find((output) => output.name === expectedName) return { diff --git a/e2e/features/step-definitions/agent-v2/publish.steps.ts b/e2e/features/step-definitions/agent-v2/publish.steps.ts index aaf2a81868f..dcfaaf69e5f 100644 --- a/e2e/features/step-definitions/agent-v2/publish.steps.ts +++ b/e2e/features/step-definitions/agent-v2/publish.steps.ts @@ -2,7 +2,6 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { waitForAgentConfigureAutosaved } from '../../../support/agent-configure' -import { getTestAgent } from '../../agent-v2/support/agent' import { expectAgentModelRequiredFeedback, getCurrentAgentId } from './configure-helpers' When('I publish the Agent v2 draft', async function (this: DifyWorld) { @@ -30,9 +29,16 @@ Then( Then('the Agent v2 draft should remain unpublished', async function (this: DifyWorld) { await expect - .poll(async () => (await getTestAgent(getCurrentAgentId(this))).active_config_is_published, { - timeout: 30_000, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(this) + const agent = await this.getConsoleClient().agent.byAgentId.get({ + params: { agent_id: agentId }, + }) + return agent.active_config_is_published + }, + { timeout: 30_000 }, + ) .toBe(false) }) @@ -47,7 +53,14 @@ Then('the Agent v2 draft should be published and up to date', async function (th await expect(page.getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 }) await expect(page.getByRole('status', { name: /^Up to date\./ })).toBeVisible() await expect(page.getByText('Up to date')).toBeVisible() - await expect.poll(async () => (await getTestAgent(agentId)).active_config_is_published).toBe(true) + await expect + .poll(async () => { + const agent = await this.getConsoleClient().agent.byAgentId.get({ + params: { agent_id: agentId }, + }) + return agent.active_config_is_published + }) + .toBe(true) }) Then( diff --git a/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts b/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts index e78769e9623..b0a0d36afbc 100644 --- a/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts +++ b/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts @@ -27,7 +27,7 @@ Given( ) } - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithSpeechToText(normalAgentSoulConfig), }) this.createdAgentIds.push(agent.id) diff --git a/e2e/features/step-definitions/agent-v2/tools.steps.ts b/e2e/features/step-definitions/agent-v2/tools.steps.ts index 6219beb50be..d1fd0099a0b 100644 --- a/e2e/features/step-definitions/agent-v2/tools.steps.ts +++ b/e2e/features/step-definitions/agent-v2/tools.steps.ts @@ -3,10 +3,9 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { sendAgentServiceApiChatMessage } from '../../agent-v2/support/access-point' -import { createConfiguredTestAgent, getAgentComposerDraft } from '../../agent-v2/support/agent' +import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens, - agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../../agent-v2/support/agent-builder-resources' import { @@ -40,7 +39,9 @@ const expectJsonReplaceToolDraft = async (world: DifyWorld) => { await expect .poll( async () => { - const draft = await getAgentComposerDraft(agentId) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) const tools = asArray(asRecord(draft.agent_soul?.tools).dify_tools) return hasToolEntry(tools, tool) @@ -116,7 +117,7 @@ Given( if (!this.agentBuilder.fixtures.stableModel) throw new Error('Create a JSON Replace runtime Agent after stable model fixture setup.') - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithDifyTool( createAgentSoulConfigWithModel( { @@ -158,26 +159,6 @@ When( }, ) -When( - 'I search for the missing Agent v2 tool from the Tools selector', - async function (this: DifyWorld) { - const toolsSection = getToolsSection(this) - - await expect(toolsSection).toBeVisible({ timeout: 30_000 }) - await toolsSection.getByRole('button', { name: 'Add tool' }).click() - - const search = getToolSelectorSearch(this) - await expect(search).toBeVisible() - await search.fill(agentBuilderFixedInputs.missingToolSearchWithSuffix) - }, -) - -When('I clear the Agent v2 tool selector search', async function (this: DifyWorld) { - const search = getToolSelectorSearch(this) - - await search.fill('') -}) - Then( 'the Agent v2 JSON Replace tool should be saved in the Agent v2 draft', async function (this: DifyWorld) { @@ -248,25 +229,3 @@ Then( ) }, ) - -Then('I should see the Agent v2 tool selector empty state', async function (this: DifyWorld) { - const page = this.getPage() - - await expect(page.getByText('No integrations were found')).toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole('link', { name: 'Requests to the community' })).toBeVisible() - await expect( - page.getByText(agentBuilderFixedInputs.missingToolSearchWithSuffix), - ).not.toBeVisible() -}) - -Then( - 'I should see the Agent v2 tool selector ready for another search', - async function (this: DifyWorld) { - const page = this.getPage() - const search = getToolSelectorSearch(this) - - await expect(search).toHaveValue('') - await expect(page.getByText('No integrations were found')).not.toBeVisible() - await expect(page.getByText('All tools')).toBeVisible() - }, -) diff --git a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts index c2fc6fda145..3a8d63fc65b 100644 --- a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts +++ b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts @@ -1,14 +1,15 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { createTestApp, syncAgentV2WorkflowDraft } from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' -import { createConfiguredTestAgent, publishAgent } from '../../agent-v2/support/agent' +import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { createAgentSoulConfigWithModel, normalAgentPrompt, normalAgentSoulConfig, } from '../../agent-v2/support/agent-soul' +import { syncAgentV2WorkflowDraft } from '../../agent-v2/support/workflow' Given( 'a workflow app with an Agent v2 node has been created via API', @@ -16,7 +17,8 @@ Given( if (!this.agentBuilder.fixtures.stableModel) throw new Error('Create an Agent v2 workflow node after stable model fixture setup.') - const agent = await createConfiguredTestAgent({ + const client = this.getConsoleClient() + const agent = await createConfiguredTestAgent(client, { agentSoul: createAgentSoulConfigWithModel( normalAgentSoulConfig, this.agentBuilder.fixtures.stableModel, @@ -25,13 +27,20 @@ Given( this.createdAgentIds.push(agent.id) this.lastCreatedAgentName = agent.name this.lastCreatedAgentRole = agent.role ?? undefined - await publishAgent(agent.id) + await client.agent.byAgentId.publish.post({ + body: { version_note: 'E2E publish' }, + params: { agent_id: agent.id }, + }) - const app = await createTestApp(createE2EResourceName('App', 'workflow-agent-v2'), 'workflow') + const app = await createTestApp( + client, + createE2EResourceName('App', 'workflow-agent-v2'), + 'workflow', + ) this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name - await syncAgentV2WorkflowDraft(app.id, agent.id) + await syncAgentV2WorkflowDraft(client, app.id, agent.id) }, ) diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index 3285e63baac..88113afa3fb 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -1,5 +1,6 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' +import { zPostAppsResponse } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' import { openBlankAppCreation } from '../../../support/apps' import { createE2EResourceName } from '../../../support/naming' @@ -43,7 +44,7 @@ When('I confirm app creation', async function (this: DifyWorld) { const response = await responsePromise expect(response.ok()).toBe(true) - const createdApp = (await response.json()) as { id?: string; mode?: string } + const createdApp = zPostAppsResponse.parse(await response.json()) if (!createdApp.id) throw new Error('Create app response did not include an app ID.') const expectedMode = this.lastSelectedAppType diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index 8e976d78cf4..c572efa7ca8 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -1,12 +1,13 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' +import { zPostAppsByAppIdCopyResponse } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' -import { createTestApp } from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' Given('there is an existing E2E app available for testing', async function (this: DifyWorld) { const name = createE2EResourceName('App', 'Test') - const app = await createTestApp(name, 'completion') + const app = await createTestApp(this.getConsoleClient(), name, 'completion') this.lastCreatedAppName = app.name this.createdAppIds.push(app.id) }) @@ -40,7 +41,7 @@ When('I confirm the app duplication', async function (this: DifyWorld) { await page.getByRole('button', { exact: true, name: 'Duplicate' }).click() const response = await responsePromise expect(response.ok()).toBe(true) - const copiedApp = (await response.json()) as { id?: string } + const copiedApp = zPostAppsByAppIdCopyResponse.parse(await response.json()) if (!copiedApp.id) throw new Error('Duplicate app response did not include an app ID.') expect(copiedApp.id).not.toBe(sourceAppId) this.createdAppIds.push(copiedApp.id) diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index b216b518696..93af0e8865d 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -1,46 +1,22 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { - createTestApp, - enableAppSiteAndGetURL, - publishWorkflowApp, - syncRunnableWorkflowDraft, -} from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' +import { getAppSiteURL } from '../../../support/api/web-apps' +import { syncRunnableWorkflowDraft } from '../../../support/api/workflows' import { createE2EResourceName } from '../../../support/naming' -const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - -When('I enable the Web App share', async function (this: DifyWorld) { - const page = this.getPage() - const appName = this.lastCreatedAppName - if (!appName) { - throw new Error( - 'No app name available. Run "a \\"workflow\\" app has been created via API" first.', - ) - } - - await page.getByRole('button', { name: new RegExp(escapeRegExp(appName)) }).click() - const webAppCard = page.getByRole('region', { name: 'Web App' }) - const webAppSwitch = webAppCard.getByRole('switch', { name: 'Web App' }) - await expect(webAppSwitch).toBeEnabled({ timeout: 15_000 }) - await webAppSwitch.click() -}) - -Then('the Web App should be in service', async function (this: DifyWorld) { - const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) - await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({ - timeout: 10_000, - }) -}) - Given('a workflow app has been published and shared via API', async function (this: DifyWorld) { - const app = await createTestApp(createE2EResourceName('App', 'Share'), 'workflow') + const client = this.getConsoleClient() + const app = await createTestApp(client, createE2EResourceName('App', 'Share'), 'workflow') this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name - await syncRunnableWorkflowDraft(app.id) - await publishWorkflowApp(app.id) - this.shareURL = await enableAppSiteAndGetURL(app.id) + await syncRunnableWorkflowDraft(client, app.id) + await client.apps.byAppId.workflows.publish.post({ + body: { marked_comment: '', marked_name: '' }, + params: { app_id: app.id }, + }) + this.shareURL = getAppSiteURL(await client.apps.byAppId.get({ params: { app_id: app.id } })) }) When('I open the shared app URL', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts index 20f513f471b..caf5b22e1ae 100644 --- a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts +++ b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts @@ -1,14 +1,14 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { createTestApp } from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' Given( 'there is an existing E2E completion app available for testing', async function (this: DifyWorld) { const name = createE2EResourceName('App', 'Test') - const app = await createTestApp(name, 'completion') + const app = await createTestApp(this.getConsoleClient(), name, 'completion') this.lastCreatedAppName = app.name this.createdAppIds.push(app.id) }, diff --git a/e2e/features/step-definitions/apps/web-app-service.steps.ts b/e2e/features/step-definitions/apps/web-app-service.steps.ts new file mode 100644 index 00000000000..d7f9d39d2e9 --- /dev/null +++ b/e2e/features/step-definitions/apps/web-app-service.steps.ts @@ -0,0 +1,101 @@ +import type { DifyWorld } from '../../support/world' +import { Given, Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { createTestApp } from '../../../support/api/apps' +import { getAppSiteURL } from '../../../support/api/web-apps' +import { syncRunnableWorkflowDraft } from '../../../support/api/workflows' +import { createE2EResourceName } from '../../../support/naming' +import { baseURL, defaultLocale } from '../../../test-env' + +Given('a new runnable workflow app has been published', async function (this: DifyWorld) { + const client = this.getConsoleClient() + const app = await createTestApp(client, createE2EResourceName('App', 'WebApp'), 'workflow') + this.createdAppIds.push(app.id) + this.lastCreatedAppName = app.name + await syncRunnableWorkflowDraft(client, app.id) + await client.apps.byAppId.workflows.publish.post({ + body: { marked_comment: '', marked_name: '' }, + params: { app_id: app.id }, + }) + + const appDetail = await client.apps.byAppId.get({ params: { app_id: app.id } }) + expect(appDetail.enable_site).toBe(true) + this.shareURL = getAppSiteURL(appDetail) +}) + +When('I open the app information panel', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) { + throw new Error('No app name available. Create an app before opening its information panel.') + } + + await this.getPage().getByRole('button', { name: appName }).click() +}) + +const getWebAppSwitch = (world: DifyWorld) => { + const webAppCard = world.getPage().getByRole('region', { name: 'Web App' }) + return webAppCard.getByRole('switch', { name: 'Web App' }) +} + +When('an anonymous visitor opens the Web App', async function (this: DifyWorld) { + if (!this.shareURL) throw new Error('No Web App URL is available.') + if (!this.context) throw new Error('Playwright browser context has not been initialized.') + + const browser = this.context.browser() + if (!browser) throw new Error('Playwright browser has not been initialized.') + + const anonymousContext = await browser.newContext({ baseURL, locale: defaultLocale }) + this.registerCleanup(() => anonymousContext.close()) + this.sharedAppPage = await anonymousContext.newPage() + await this.sharedAppPage.goto(this.shareURL, { timeout: 20_000 }) +}) + +When('the anonymous visitor reloads the Web App', async function (this: DifyWorld) { + if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') + await this.sharedAppPage.reload({ timeout: 20_000 }) +}) + +When('I disable the Web App', async function (this: DifyWorld) { + const webAppSwitch = getWebAppSwitch(this) + + await expect(webAppSwitch).not.toHaveAttribute('aria-disabled', 'true', { timeout: 15_000 }) + await expect(webAppSwitch).toHaveAttribute('aria-checked', 'true') + await webAppSwitch.click() +}) + +When('I enable the Web App', async function (this: DifyWorld) { + const webAppSwitch = getWebAppSwitch(this) + + await expect(webAppSwitch).not.toHaveAttribute('aria-disabled', 'true', { timeout: 15_000 }) + await expect(webAppSwitch).toHaveAttribute('aria-checked', 'false') + await webAppSwitch.click() +}) + +Then('the Web App should be in service', async function (this: DifyWorld) { + const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) + await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({ + timeout: 10_000, + }) +}) + +Then('the Web App should be disabled', async function (this: DifyWorld) { + const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) + await expect(webAppCard.getByText('Disabled', { exact: true })).toBeVisible({ + timeout: 10_000, + }) +}) + +Then('the published workflow Web App should be accessible', async function (this: DifyWorld) { + if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') + await expect(this.sharedAppPage.getByRole('button', { name: 'Execute' })).toBeVisible({ + timeout: 15_000, + }) +}) + +Then('the published workflow Web App should be unavailable', async function (this: DifyWorld) { + if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') + await expect(this.sharedAppPage.getByRole('heading', { name: '404' })).toBeVisible({ + timeout: 15_000, + }) + await expect(this.sharedAppPage.getByText('App is unavailable', { exact: true })).toBeVisible() +}) diff --git a/e2e/features/step-definitions/apps/workflow-run.steps.ts b/e2e/features/step-definitions/apps/workflow-run.steps.ts index d9cf64bb9c3..e6e7cd4362a 100644 --- a/e2e/features/step-definitions/apps/workflow-run.steps.ts +++ b/e2e/features/step-definitions/apps/workflow-run.steps.ts @@ -1,13 +1,13 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { syncRunnableWorkflowDraft } from '../../../support/api' +import { syncRunnableWorkflowDraft } from '../../../support/api/workflows' Given('a minimal runnable workflow draft has been synced', async function (this: DifyWorld) { const appId = this.createdAppIds.at(-1) if (!appId) throw new Error('No app ID found. Run "a \\"workflow\\" app has been created via API" first.') - await syncRunnableWorkflowDraft(appId) + await syncRunnableWorkflowDraft(this.getConsoleClient(), appId) }) When('I run the workflow', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts index 1fa50cbf0d1..c647af69f7e 100644 --- a/e2e/features/step-definitions/common/app.steps.ts +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -1,26 +1,37 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' +import { zCreateAppPayload } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' -import { createTestApp, syncMinimalWorkflowDraft } from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' +import { syncMinimalWorkflowDraft } from '../../../support/api/workflows' import { waitForAppsConsole } from '../../../support/apps' import { createE2EResourceName } from '../../../support/naming' Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) { - const app = await createTestApp(createE2EResourceName('App', mode), mode) + const appMode = zCreateAppPayload.shape.mode.parse(mode) + const app = await createTestApp( + this.getConsoleClient(), + createE2EResourceName('App', appMode), + appMode, + ) this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name }) Given('a minimal workflow draft has been synced', async function (this: DifyWorld) { - const appId = this.createdAppIds.at(-1)! - await syncMinimalWorkflowDraft(appId) + const appId = this.createdAppIds.at(-1) + if (!appId) throw new Error('No app is available for workflow draft setup.') + await syncMinimalWorkflowDraft(this.getConsoleClient(), appId) }) When('I open the app from the app list', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) throw new Error('No app is available to open from the app list.') + const page = this.getPage() await page.goto('/apps') await waitForAppsConsole(page) - const appLink = page.getByRole('link', { name: this.lastCreatedAppName!, exact: true }) + const appLink = page.getByRole('link', { name: appName, exact: true }) await expect(appLink).toBeVisible() await appLink.click() }) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 0325d04a7a7..006277d5649 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -8,18 +8,9 @@ import { fileURLToPath } from 'node:url' import { After, AfterAll, Before, setDefaultTimeout, Status } from '@cucumber/cucumber' import { chromium, webkit } from '@playwright/test' import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth' -import { deleteTestApp } from '../../support/api' import { runCleanupTasks, shouldFailForCleanupErrors } from '../../support/cleanup' -import { deleteTestDataset } from '../../support/datasets' import { getVoiceInputTestMaterialPath } from '../../support/test-materials' -import { deleteBuiltinToolCredential } from '../../support/tools' import { baseURL, cucumberHeadless, cucumberSlowMo, e2eBrowser } from '../../test-env' -import { deleteTestAgent } from '../agent-v2/support/agent' -import { - deleteAgentConfigFile, - deleteAgentConfigSkill, - deleteAgentDriveFile, -} from '../agent-v2/support/agent-drive' const e2eRoot = fileURLToPath(new URL('../..', import.meta.url)) const artifactsDir = path.join(e2eRoot, 'cucumber-report', 'artifacts') @@ -165,31 +156,57 @@ After( const cleanupTasks: CleanupTask[] = [ ...this.createdAgentConfigSkills.toReversed().map((skill) => ({ label: `Delete Agent config skill ${skill.name}`, - run: () => deleteAgentConfigSkill(skill.agentId, skill.name), + run: async () => { + await this.getConsoleClient().agent.byAgentId.config.skills.byName.delete({ + params: { agent_id: skill.agentId, name: skill.name }, + }) + }, })), ...this.createdAgentConfigFiles.toReversed().map((file) => ({ label: `Delete Agent config file ${file.name}`, - run: () => deleteAgentConfigFile(file.agentId, file.name), + run: async () => { + await this.getConsoleClient().agent.byAgentId.config.files.byName.delete({ + params: { agent_id: file.agentId, name: file.name }, + }) + }, })), ...this.createdAgentDriveFiles.toReversed().map((file) => ({ label: `Delete Agent drive file ${file.key}`, - run: () => deleteAgentDriveFile(file.agentId, file.key), + run: async () => { + await this.getConsoleClient().agent.byAgentId.files.delete({ + params: { agent_id: file.agentId }, + query: { key: file.key }, + }) + }, })), ...this.createdAppIds.toReversed().map((id) => ({ label: `Delete app ${id}`, - run: () => deleteTestApp(id), + run: async () => { + await this.getConsoleClient().apps.byAppId.delete({ params: { app_id: id } }) + }, })), ...this.createdAgentIds.toReversed().map((id) => ({ label: `Delete Agent ${id}`, - run: () => deleteTestAgent(id), + run: async () => { + await this.getConsoleClient().agent.byAgentId.delete({ params: { agent_id: id } }) + }, })), ...this.createdDatasetIds.toReversed().map((id) => ({ label: `Delete dataset ${id}`, - run: () => deleteTestDataset(id), + run: async () => { + await this.getConsoleClient().datasets.byDatasetId.delete({ params: { dataset_id: id } }) + }, })), ...this.createdBuiltinToolCredentials.toReversed().map((credential) => ({ label: `Delete builtin tool credential ${credential.provider}/${credential.credentialId}`, - run: () => deleteBuiltinToolCredential(credential.provider, credential.credentialId), + run: async () => { + await this.getConsoleClient().workspaces.current.toolProvider.builtin.byProvider.delete.post( + { + body: { credential_id: credential.credentialId }, + params: { provider: credential.provider }, + }, + ) + }, })), ] @@ -220,6 +237,7 @@ After( const artifactErrors: string[] = [] const diagnosticPages = uniqueDiagnosticPages([ { label: 'main-page', page: this.page }, + { label: 'shared-app', page: this.sharedAppPage }, { label: 'agent-v2-web-app', page: this.agentBuilder.accessPoint.webAppPage }, { label: 'agent-v2-api-reference', page: this.agentBuilder.accessPoint.apiReferencePage }, { diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index d8b7f3ea472..3cbabd84542 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -1,10 +1,13 @@ import type { IWorldOptions } from '@cucumber/cucumber' -import type { Browser, BrowserContext, Download, Page } from '@playwright/test' +import type { APIRequestContext, Browser, BrowserContext, Download, Page } from '@playwright/test' import type { AuthSessionMetadata } from '../../fixtures/auth' +import type { ConsoleClient } from '../../support/api/console-client' import { setWorldConstructor, World } from '@cucumber/cucumber' +import { request } from '@playwright/test' import { authStatePath, readAuthSessionMetadata } from '../../fixtures/auth' +import { createConsoleClient } from '../../support/api/console-client' import { runCleanupTasks } from '../../support/cleanup' -import { baseURL, defaultLocale } from '../../test-env' +import { apiURL, baseURL, defaultLocale } from '../../test-env' export type ScenarioCleanup = () => Promise | void export type CreatedAgentDriveFile = { @@ -76,6 +79,8 @@ export type AgentBuilderWorldState = ReturnType { if (message.type() === 'error') this.consoleErrors.push(message.text()) }) @@ -156,6 +168,13 @@ export class DifyWorld extends World { return this.page } + getConsoleClient() { + if (!this.consoleClient) + throw new Error('Console API client has not been initialized for this scenario.') + + return this.consoleClient + } + async getAuthSession() { this.session ??= await readAuthSessionMetadata() return this.session @@ -178,7 +197,10 @@ export class DifyWorld extends World { try { await this.context?.close() } finally { + await this.consoleRequestContext?.dispose() this.context = undefined + this.consoleRequestContext = undefined + this.consoleClient = undefined this.page = undefined this.session = undefined this.scenarioStartedAt = undefined diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index 1467e66644f..582f23fadba 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -1,9 +1,10 @@ -import type { APIResponse, Browser, BrowserContext } from '@playwright/test' +import type { Browser } from '@playwright/test' import { Buffer } from 'node:buffer' import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { apiURL, defaultBaseURL, defaultLocale } from '../test-env' +import { createConsoleClient } from '../support/api/console-client' +import { defaultBaseURL, defaultLocale } from '../test-env' export type AuthSessionMetadata = { adminEmail: string @@ -36,16 +37,6 @@ export const readAuthSessionMetadata = async () => { return JSON.parse(content) as AuthSessionMetadata } -const apiEndpoint = (pathname: string) => new URL(pathname, apiURL).toString() - -type SetupStatusResponse = { - step: 'not_started' | 'finished' -} - -type InitStatusResponse = { - status: 'not_started' | 'finished' -} - type AuthBootstrapResult = { mode: AuthSessionMetadata['mode'] usedInitPassword: boolean @@ -55,65 +46,41 @@ const getRemainingTimeout = (deadline: number) => Math.max(deadline - Date.now() const encodeField = (value: string) => Buffer.from(value, 'utf8').toString('base64') -const assertAPIResponse = async (response: APIResponse, action: string) => { - if (response.ok()) return - - const body = await response.text().catch(() => '') - throw new Error( - `${action} failed with ${response.status()} ${response.statusText()}${body ? `: ${body}` : ''}`, - ) -} - -const getConsoleAPI = async (context: BrowserContext, pathname: string, deadline: number) => { - const response = await context.request.get(apiEndpoint(pathname), { - timeout: getRemainingTimeout(deadline), - }) - await assertAPIResponse(response, `GET ${pathname}`) - return response.json() as Promise -} - -const postConsoleAPI = async ( - context: BrowserContext, - pathname: string, +const validateInitPasswordIfNeeded = async ( + client: ReturnType, deadline: number, - data: Record, ) => { - const response = await context.request.post(apiEndpoint(pathname), { - data, - timeout: getRemainingTimeout(deadline), - }) - await assertAPIResponse(response, `POST ${pathname}`) -} - -const validateInitPasswordIfNeeded = async (context: BrowserContext, deadline: number) => { - const initStatus = await getConsoleAPI(context, '/console/api/init', deadline) + const options = { context: { timeoutMs: getRemainingTimeout(deadline) } } + const initStatus = await client.init.get(undefined, options) if (initStatus.status === 'finished') return false console.warn('[e2e] auth bootstrap: validating init password') - await postConsoleAPI(context, '/console/api/init', deadline, { password: initPassword }) + await client.init.post({ body: { password: initPassword } }, options) return true } const ensureAdminAccount = async ( - context: BrowserContext, + client: ReturnType, deadline: number, ): Promise => { - const setupStatus = await getConsoleAPI( - context, - '/console/api/setup', - deadline, - ) + const options = { context: { timeoutMs: getRemainingTimeout(deadline) } } + const setupStatus = await client.setup.get(undefined, options) let usedInitPassword = false if (setupStatus.step === 'not_started') { - usedInitPassword = await validateInitPasswordIfNeeded(context, deadline) + usedInitPassword = await validateInitPasswordIfNeeded(client, deadline) console.warn('[e2e] auth bootstrap: creating admin account') - await postConsoleAPI(context, '/console/api/setup', deadline, { - email: adminCredentials.email, - name: adminCredentials.name, - password: adminCredentials.password, - language: defaultLocale, - }) + await client.setup.post( + { + body: { + email: adminCredentials.email, + name: adminCredentials.name, + password: adminCredentials.password, + language: defaultLocale, + }, + }, + options, + ) return { mode: 'install', usedInitPassword } } @@ -121,13 +88,18 @@ const ensureAdminAccount = async ( return { mode: 'login', usedInitPassword } } -const loginAdmin = async (context: BrowserContext, deadline: number) => { +const loginAdmin = async (client: ReturnType, deadline: number) => { console.warn('[e2e] auth bootstrap: logging in admin') - await postConsoleAPI(context, '/console/api/login', deadline, { - email: adminCredentials.email, - password: encodeField(adminCredentials.password), - remember_me: true, - }) + await client.login.post( + { + body: { + email: adminCredentials.email, + password: encodeField(adminCredentials.password), + remember_me: true, + }, + }, + { context: { timeoutMs: getRemainingTimeout(deadline) } }, + ) } export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => { @@ -140,10 +112,11 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU baseURL, locale: defaultLocale, }) + const client = createConsoleClient({ requestContext: context.request, requireCsrfToken: false }) try { - const { mode, usedInitPassword } = await ensureAdminAccount(context, deadline) - await loginAdmin(context, deadline) + const { mode, usedInitPassword } = await ensureAdminAccount(client, deadline) + await loginAdmin(client, deadline) await context.storageState({ path: authStatePath }) diff --git a/e2e/package.json b/e2e/package.json index 52ec01c30bd..562cfb6a46c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,6 +23,9 @@ "@cucumber/cucumber": "catalog:", "@dify/contracts": "workspace:*", "@dify/tsconfig": "workspace:*", + "@orpc/client": "catalog:", + "@orpc/contract": "catalog:", + "@orpc/openapi-client": "catalog:", "@playwright/test": "catalog:", "@t3-oss/env-core": "catalog:", "@types/node": "catalog:", diff --git a/e2e/scripts/seed.ts b/e2e/scripts/seed.ts index d609612c2db..9b1b13dc5c6 100644 --- a/e2e/scripts/seed.ts +++ b/e2e/scripts/seed.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { chromium } from '@playwright/test' import { createAgentV2SeedTasks } from '../features/agent-v2/support/seed' import { ensureAuthenticatedState } from '../fixtures/auth' +import { createStandaloneConsoleSession } from '../support/api/console-session' import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/process' import { runSeedTasks, writeSeedReport } from '../support/seed' import { startWebServer, stopWebServer } from '../support/web-server' @@ -110,6 +111,7 @@ const main = async () => { const logDir = path.join(e2eDir, '.logs') let apiProcess: ManagedProcess | undefined let celeryProcess: ManagedProcess | undefined + let consoleSession: Awaited> | undefined await mkdir(logDir, { recursive: true }) @@ -128,8 +130,10 @@ const main = async () => { console.warn(`[seed] bootstrapping auth state against ${baseURL}`) await ensureAuth() + consoleSession = await createStandaloneConsoleSession() const results = await runSeedTasks(getTasks(options.pack, options.profile), { + consoleClient: consoleSession.client, dryRun: options.dryRun, resources: new Map(), }) @@ -144,6 +148,7 @@ const main = async () => { ) } } finally { + await consoleSession?.dispose() await stopWebServer() await stopManagedProcess(celeryProcess) await stopManagedProcess(apiProcess) diff --git a/e2e/support/api.ts b/e2e/support/api.ts deleted file mode 100644 index a6dfde8ef17..00000000000 --- a/e2e/support/api.ts +++ /dev/null @@ -1,253 +0,0 @@ -import type { APIResponse } from '@playwright/test' -import { readFile } from 'node:fs/promises' -import { request } from '@playwright/test' -import { authStatePath } from '../fixtures/auth' -import { apiURL } from '../test-env' -import { assertE2EResourceName, createE2EResourceName } from './naming' - -type StorageState = { - cookies: Array<{ name: string; value: string }> -} - -export async function createApiContext() { - const state = JSON.parse(await readFile(authStatePath, 'utf8')) as StorageState - const csrfToken = state.cookies.find((c) => c.name.endsWith('csrf_token'))?.value ?? '' - - return request.newContext({ - baseURL: apiURL, - extraHTTPHeaders: { 'X-CSRF-Token': csrfToken }, - storageState: authStatePath, - }) -} - -export async function expectApiResponseOK(response: APIResponse, action: string): Promise { - if (response.ok()) return - - const body = await response.text().catch(() => '') - throw new Error(`${action} failed with ${response.status()} ${response.statusText()}: ${body}`) -} - -export type AppSeed = { - id: string - name: string -} - -export type WorkflowDraft = { - graph: { - edges: Array> - nodes: Array<{ - data?: Record - id: string - type: string - }> - viewport?: Record - } -} - -export async function createTestApp( - name = createE2EResourceName('App'), - mode = 'workflow', -): Promise { - assertE2EResourceName(name, 'App') - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/apps', { - data: { - name, - mode, - icon_type: 'emoji', - icon: '🤖', - icon_background: '#FFEAD5', - }, - }) - await expectApiResponseOK(response, `Create ${mode} app ${name}`) - const body = (await response.json()) as AppSeed - return body - } finally { - await ctx.dispose() - } -} - -export async function getWorkflowDraft(appId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/apps/${appId}/workflows/draft`) - await expectApiResponseOK(response, `Get workflow draft for ${appId}`) - return (await response.json()) as WorkflowDraft - } finally { - await ctx.dispose() - } -} - -export async function syncMinimalWorkflowDraft(appId: string): Promise { - const ctx = await createApiContext() - try { - await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { - data: { - graph: { - nodes: [ - { - id: '1', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: '1', type: 'start', title: 'Start', variables: [] }, - }, - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - }, - }) - } finally { - await ctx.dispose() - } -} - -export async function syncAgentV2WorkflowDraft(appId: string, agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { - data: { - graph: { - nodes: [ - { - id: 'start', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: 'start', type: 'start', title: 'Start', variables: [] }, - }, - { - id: 'agent-v2', - type: 'custom', - position: { x: 420, y: 282 }, - data: { - id: 'agent-v2', - type: 'agent', - title: 'Agent', - desc: '', - agent_binding: { - binding_type: 'roster_agent', - agent_id: agentId, - }, - agent_node_kind: 'dify_agent', - version: '2', - }, - }, - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - }, - }) - await expectApiResponseOK(response, `Sync Agent v2 workflow draft for ${appId}`) - } finally { - await ctx.dispose() - } -} - -export async function deleteTestApp(id: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.delete(`/console/api/apps/${id}`) - await expectApiResponseOK(response, `Delete app ${id}`) - } finally { - await ctx.dispose() - } -} - -export async function syncRunnableWorkflowDraft(appId: string): Promise { - const ctx = await createApiContext() - try { - await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { - data: { - graph: { - nodes: [ - { - id: 'start', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: 'start', type: 'start', title: 'Start', variables: [] }, - }, - { - id: 'end', - type: 'custom', - position: { x: 480, y: 282 }, - data: { - id: 'end', - type: 'end', - title: 'End', - outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], - }, - }, - ], - edges: [ - { - id: 'start-end', - type: 'custom', - source: 'start', - target: 'end', - sourceHandle: 'source', - targetHandle: 'target', - }, - ], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - }, - }) - } finally { - await ctx.dispose() - } -} - -export async function publishWorkflowApp(appId: string): Promise { - const ctx = await createApiContext() - try { - await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { - data: { marked_name: '', marked_comment: '' }, - }) - } finally { - await ctx.dispose() - } -} - -export type AppDetailWithSite = { - mode?: string - site: { access_token: string; app_base_url: string; enable_site: boolean } -} - -export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { - const webAppMode = mode === 'completion' || mode === 'workflow' ? mode : 'chat' - return `${site.app_base_url}/${webAppMode}/${site.access_token}` -} - -export async function enableAppSiteAndGetURL(appId: string): Promise { - return getAppSiteURL(await setAppSiteEnabled(appId, true)) -} - -export async function setAppSiteEnabled( - appId: string, - enabled: boolean, -): Promise { - const ctx = await createApiContext() - try { - const enableResponse = await ctx.post(`/console/api/apps/${appId}/site-enable`, { - data: { enable_site: enabled }, - }) - await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`) - - const detailResponse = await ctx.get(`/console/api/apps/${appId}`) - await expectApiResponseOK(detailResponse, `Get app site detail for ${appId}`) - return (await detailResponse.json()) as AppDetailWithSite - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/api/apps.ts b/e2e/support/api/apps.ts new file mode 100644 index 00000000000..a6976325013 --- /dev/null +++ b/e2e/support/api/apps.ts @@ -0,0 +1,20 @@ +import type { CreateAppPayload, PostAppsResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { ConsoleClient } from './console-client' +import { assertE2EResourceName, createE2EResourceName } from '../naming' + +export async function createTestApp( + client: ConsoleClient, + name = createE2EResourceName('App'), + mode: CreateAppPayload['mode'] = 'workflow', +): Promise { + assertE2EResourceName(name, 'App') + const body = { + name, + mode, + icon_type: 'emoji', + icon: '🤖', + icon_background: '#FFEAD5', + } satisfies CreateAppPayload + + return client.apps.post({ body }) +} diff --git a/e2e/support/api/console-client.ts b/e2e/support/api/console-client.ts new file mode 100644 index 00000000000..7a6a24604b8 --- /dev/null +++ b/e2e/support/api/console-client.ts @@ -0,0 +1,52 @@ +import type { ContractRouterClient } from '@orpc/contract' +import type { JsonifiedClient } from '@orpc/openapi-client' +import type { APIRequestContext } from '@playwright/test' +import type { ConsoleClientContext } from './playwright-fetch' +import { consoleRouterContract } from '@dify/contracts/api/console/router.gen' +import { createORPCClient } from '@orpc/client' +import { RequestValidationPlugin, ResponseValidationPlugin } from '@orpc/contract/plugins' +import { OpenAPILink } from '@orpc/openapi-client/fetch' +import { apiURL } from '../../test-env' +import { createPlaywrightFetch } from './playwright-fetch' + +type ConsoleRequestContext = Pick + +export type ConsoleClient = JsonifiedClient< + ContractRouterClient +> + +export type CreateConsoleClientOptions = { + apiBaseURL?: string + requestContext: ConsoleRequestContext + requireCsrfToken?: boolean +} + +const getCsrfToken = async (requestContext: ConsoleRequestContext) => { + const state = await requestContext.storageState() + return state.cookies.find((cookie) => cookie.name.endsWith('csrf_token'))?.value +} + +export function createConsoleClient({ + apiBaseURL = apiURL, + requestContext, + requireCsrfToken = true, +}: CreateConsoleClientOptions): ConsoleClient { + const link = new OpenAPILink(consoleRouterContract, { + fetch: createPlaywrightFetch(requestContext), + headers: async () => { + const headers = new Headers({ Accept: 'application/json' }) + const csrfToken = await getCsrfToken(requestContext) + if (!csrfToken && requireCsrfToken) + throw new Error('The Console API client requires an authenticated CSRF token.') + if (csrfToken) headers.set('X-CSRF-Token', csrfToken) + return headers + }, + plugins: [ + new RequestValidationPlugin(consoleRouterContract), + new ResponseValidationPlugin(consoleRouterContract), + ], + url: new URL('/console/api/', apiBaseURL).toString(), + }) + + return createORPCClient(link) +} diff --git a/e2e/support/api/console-session.ts b/e2e/support/api/console-session.ts new file mode 100644 index 00000000000..ab7fc8790ac --- /dev/null +++ b/e2e/support/api/console-session.ts @@ -0,0 +1,16 @@ +import { request } from '@playwright/test' +import { authStatePath } from '../../fixtures/auth' +import { apiURL } from '../../test-env' +import { createConsoleClient } from './console-client' + +export async function createStandaloneConsoleSession() { + const requestContext = await request.newContext({ + baseURL: apiURL, + storageState: authStatePath, + }) + + return { + client: createConsoleClient({ requestContext }), + dispose: () => requestContext.dispose(), + } +} diff --git a/e2e/support/api/playwright-fetch.ts b/e2e/support/api/playwright-fetch.ts new file mode 100644 index 00000000000..baae3e01257 --- /dev/null +++ b/e2e/support/api/playwright-fetch.ts @@ -0,0 +1,61 @@ +import type { OpenAPILinkOptions } from '@orpc/openapi-client/fetch' +import type { APIRequestContext } from '@playwright/test' +import { Buffer } from 'node:buffer' + +export type ConsoleClientContext = { + timeoutMs?: number +} + +type PlaywrightRequestContext = Pick +type OpenAPIFetch = NonNullable['fetch']> + +const defaultRequestTimeoutMs = 30_000 +const bodylessResponseStatuses = new Set([204, 205, 304]) + +export function createPlaywrightFetch(requestContext: PlaywrightRequestContext): OpenAPIFetch { + return async (request, _init, options, path) => { + request.signal.throwIfAborted() + + const headers = Object.fromEntries(request.headers.entries()) + delete headers['content-length'] + + const data = request.body ? Buffer.from(await request.arrayBuffer()) : undefined + const apiResponse = await requestContext.fetch(request.url, { + ...(data === undefined ? {} : { data }), + failOnStatusCode: false, + headers, + maxRedirects: 0, + method: request.method, + timeout: options.context.timeoutMs ?? defaultRequestTimeoutMs, + }) + + try { + const status = apiResponse.status() + if (status >= 300 && status < 400) { + const location = apiResponse.headers().location + throw new Error( + `Console API ${path.join('.')} redirected with ${status}${location ? ` to ${location}` : ''}.`, + ) + } + + const responseHeaders = new Headers() + for (const { name, value } of apiResponse.headersArray()) responseHeaders.append(name, value) + responseHeaders.delete('content-encoding') + responseHeaders.delete('content-length') + responseHeaders.delete('transfer-encoding') + + const body = + request.method === 'HEAD' || bodylessResponseStatuses.has(status) + ? null + : Uint8Array.from(await apiResponse.body()) + + return new Response(body, { + headers: responseHeaders, + status, + statusText: apiResponse.statusText(), + }) + } finally { + await apiResponse.dispose() + } + } +} diff --git a/e2e/support/api/web-apps.ts b/e2e/support/api/web-apps.ts new file mode 100644 index 00000000000..fe97aee87ef --- /dev/null +++ b/e2e/support/api/web-apps.ts @@ -0,0 +1,14 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' + +export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { + if (!site?.app_base_url || !site.access_token) + throw new Error('App detail does not include a Web App URL.') + + const webAppMode = (() => { + if (mode === 'completion' || mode === 'workflow') return mode + if (mode === 'advanced-chat' || mode === 'agent-chat' || mode === 'chat') return 'chat' + throw new Error(`Unsupported Web App mode: ${mode}`) + })() + + return `${site.app_base_url}/${webAppMode}/${site.access_token}` +} diff --git a/e2e/support/api/workflows.ts b/e2e/support/api/workflows.ts new file mode 100644 index 00000000000..00ee6544d4e --- /dev/null +++ b/e2e/support/api/workflows.ts @@ -0,0 +1,70 @@ +import type { SyncDraftWorkflowPayload } from '@dify/contracts/api/console/apps/types.gen' +import type { ConsoleClient } from './console-client' + +export async function syncMinimalWorkflowDraft( + client: ConsoleClient, + appId: string, +): Promise { + const body = { + graph: { + nodes: [ + { + id: '1', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: '1', type: 'start', title: 'Start', variables: [] }, + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } }) +} + +export async function syncRunnableWorkflowDraft( + client: ConsoleClient, + appId: string, +): Promise { + const body = { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'end', + type: 'custom', + position: { x: 480, y: 282 }, + data: { + id: 'end', + type: 'end', + title: 'End', + outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], + }, + }, + ], + edges: [ + { + id: 'start-end', + type: 'custom', + source: 'start', + target: 'end', + sourceHandle: 'source', + targetHandle: 'target', + }, + ], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } }) +} diff --git a/e2e/support/datasets.ts b/e2e/support/datasets.ts deleted file mode 100644 index 196b335af32..00000000000 --- a/e2e/support/datasets.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createApiContext, expectApiResponseOK } from './api' - -export async function deleteTestDataset(datasetId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.delete(`/console/api/datasets/${datasetId}`) - await expectApiResponseOK(response, `Delete dataset ${datasetId}`) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/marketplace-plugins.ts b/e2e/support/marketplace-plugins.ts index e5d1b25cd64..cb8b837ef11 100644 --- a/e2e/support/marketplace-plugins.ts +++ b/e2e/support/marketplace-plugins.ts @@ -1,36 +1,11 @@ +import type { PluginInstallTask } from '@dify/contracts/api/console/workspaces/types.gen' +import type { ConsoleClient } from './api/console-client' import type { SeedContext, SeedResult } from './seed' import { Buffer } from 'node:buffer' -import { createApiContext, expectApiResponseOK } from './api' +import { ORPCError } from '@orpc/client' import { sleep } from './process' import { blocked, created, skipped, verified } from './seed' -type LatestPlugin = { - unique_identifier?: string - version?: string -} - -type PluginInstallation = { - plugin_id: string - plugin_unique_identifier: string -} - -type PluginInstallTask = { - id?: string - plugins?: Array<{ - message?: string - plugin_id?: string - plugin_unique_identifier?: string - status?: string - }> - status?: string -} - -type PluginInstallStartResponse = { - all_installed?: boolean - task?: PluginInstallTask | null - task_id?: string -} - type MarketplacePluginBootstrapConfig = { defaultPluginIds: string[] pluginIdsEnv: string @@ -53,64 +28,48 @@ const unique = (values: string[]) => Array.from(new Set(values)) const getPluginId = (pluginUniqueIdentifier: string) => pluginUniqueIdentifier.split(':')[0]?.trim() || pluginUniqueIdentifier.trim() -const resolveLatestPluginIdentifiers = async (pluginIds: string[]) => { +const resolveLatestPluginIdentifiers = async (client: ConsoleClient, pluginIds: string[]) => { if (pluginIds.length === 0) return { identifiers: [] as string[], missing: [] as string[] } - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/list/latest-versions', { - data: { plugin_ids: pluginIds }, + const body = await client.workspaces.current.plugin.list.latestVersions.post({ + body: { plugin_ids: pluginIds }, + }) + const identifiers: string[] = [] + const missing: string[] = [] + + for (const pluginId of pluginIds) { + const latest = body.versions[pluginId] + if (latest?.unique_identifier) identifiers.push(latest.unique_identifier) + else missing.push(pluginId) + } + + return { identifiers, missing } +} + +const listInstalledPlugins = async (client: ConsoleClient, pluginIds: string[]) => { + if (pluginIds.length === 0) return [] + + return ( + await client.workspaces.current.plugin.list.installations.ids.post({ + body: { plugin_ids: pluginIds }, }) - await expectApiResponseOK(response, 'Resolve latest marketplace plugin versions') - const body = (await response.json()) as { versions?: Record } - const identifiers: string[] = [] - const missing: string[] = [] - - for (const pluginId of pluginIds) { - const latest = body.versions?.[pluginId] - if (latest?.unique_identifier) identifiers.push(latest.unique_identifier) - else missing.push(pluginId) - } - - return { identifiers, missing } - } finally { - await ctx.dispose() - } + ).plugins } -const listInstalledPlugins = async (pluginIds: string[]) => { - if (pluginIds.length === 0) return [] as PluginInstallation[] - - const ctx = await createApiContext() - try { - const response = await ctx.post( - '/console/api/workspaces/current/plugin/list/installations/ids', - { - data: { plugin_ids: pluginIds }, - }, - ) - await expectApiResponseOK(response, 'List installed marketplace plugins') - const body = (await response.json()) as { plugins?: PluginInstallation[] } - return body.plugins ?? [] - } finally { - await ctx.dispose() - } -} - -const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => { +const waitForPluginInstallTask = async ( + client: ConsoleClient, + taskId: string, + timeoutMs = 300_000, +) => { const deadline = Date.now() + timeoutMs let lastTask: PluginInstallTask | undefined while (Date.now() < deadline) { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/workspaces/current/plugin/tasks/${taskId}`) - await expectApiResponseOK(response, `Fetch marketplace plugin install task ${taskId}`) - const body = (await response.json()) as { task?: PluginInstallTask } - lastTask = body.task - } finally { - await ctx.dispose() - } + lastTask = ( + await client.workspaces.current.plugin.tasks.byTaskId.get({ + params: { task_id: taskId }, + }) + ).task if (lastTask?.status === terminalSuccessTaskStatus) return { ok: true as const, task: lastTask } @@ -139,19 +98,6 @@ const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => return { ok: false as const, reason: `Plugin install task did not finish within ${timeoutMs}ms.` } } -const installMarketplacePlugins = async (pluginUniqueIdentifiers: string[]) => { - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/install/marketplace', { - data: { plugin_unique_identifiers: pluginUniqueIdentifiers }, - }) - await expectApiResponseOK(response, 'Install marketplace plugins') - return (await response.json()) as PluginInstallStartResponse - } finally { - await ctx.dispose() - } -} - const getMarketplaceDownloadUrl = (pluginUniqueIdentifier: string) => { const url = new URL( '/api/v1/plugins/download', @@ -172,57 +118,49 @@ const downloadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) return Buffer.from(await response.arrayBuffer()) } -const uploadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) => { +const uploadMarketplacePluginPackage = async ( + client: ConsoleClient, + pluginUniqueIdentifier: string, +) => { const pkg = await downloadMarketplacePluginPackage(pluginUniqueIdentifier) - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/upload/pkg', { - multipart: { - pkg: { - buffer: pkg, - mimeType: 'application/octet-stream', - name: `${getPluginId(pluginUniqueIdentifier).replaceAll('/', '-')}.difypkg`, - }, - }, - }) - await expectApiResponseOK( - response, - `Upload marketplace package ${getPluginId(pluginUniqueIdentifier)}`, - ) - const body = (await response.json()) as { unique_identifier?: string } - if (!body.unique_identifier) - throw new Error( - `Upload marketplace package ${getPluginId(pluginUniqueIdentifier)} did not return a unique identifier.`, - ) - - return body.unique_identifier - } finally { - await ctx.dispose() - } + const fileName = `${getPluginId(pluginUniqueIdentifier).replaceAll('/', '-')}.difypkg` + const response = await client.workspaces.current.plugin.upload.pkg.post({ + body: { + pkg: new File([Uint8Array.from(pkg)], fileName, { type: 'application/octet-stream' }), + }, + }) + return response.unique_identifier } -const installLocalPluginPackages = async (pluginUniqueIdentifiers: string[]) => { - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/install/pkg', { - data: { plugin_unique_identifiers: pluginUniqueIdentifiers }, - }) - await expectApiResponseOK(response, 'Install uploaded plugin packages') - return (await response.json()) as PluginInstallStartResponse - } finally { - await ctx.dispose() +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const getMarketplaceInstallErrorText = (error: unknown) => { + const messages = [error instanceof Error ? error.message : String(error)] + + if (error instanceof ORPCError && isRecord(error.data)) { + const body = error.data.body + if (isRecord(body) && typeof body.message === 'string') messages.push(body.message) } + + return messages.join('\n') } -const shouldFallbackToLocalPackageInstall = (error: string) => - error.includes('/plugins/download') || error.includes('Reached maximum retries') +const shouldFallbackToLocalPackageInstall = (error: unknown) => { + const message = getMarketplaceInstallErrorText(error) + return message.includes('/plugins/download') || message.includes('Reached maximum retries') +} -const installMarketplacePluginsWithFallback = async (pluginUniqueIdentifiers: string[]) => { +const installMarketplacePluginsWithFallback = async ( + client: ConsoleClient, + pluginUniqueIdentifiers: string[], +) => { try { - return await installMarketplacePlugins(pluginUniqueIdentifiers) + return await client.workspaces.current.plugin.install.marketplace.post({ + body: { plugin_unique_identifiers: pluginUniqueIdentifiers }, + }) } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (!shouldFallbackToLocalPackageInstall(message)) throw error + if (!shouldFallbackToLocalPackageInstall(error)) throw error console.warn( '[seed] marketplace install download failed in API process; falling back to local package upload.', @@ -230,10 +168,12 @@ const installMarketplacePluginsWithFallback = async (pluginUniqueIdentifiers: st const uploadedPluginUniqueIdentifiers: string[] = [] for (const pluginUniqueIdentifier of pluginUniqueIdentifiers) uploadedPluginUniqueIdentifiers.push( - await uploadMarketplacePluginPackage(pluginUniqueIdentifier), + await uploadMarketplacePluginPackage(client, pluginUniqueIdentifier), ) - return await installLocalPluginPackages(uploadedPluginUniqueIdentifiers) + return await client.workspaces.current.plugin.install.pkg.post({ + body: { plugin_unique_identifiers: uploadedPluginUniqueIdentifiers }, + }) } } @@ -242,12 +182,13 @@ export const bootstrapMarketplacePlugins = async ( config: MarketplacePluginBootstrapConfig, ): Promise => { const requestedPluginIds = parseListEnv(config.pluginIdsEnv) + const client = context.consoleClient const pluginIds = unique( requestedPluginIds.length > 0 ? requestedPluginIds : config.defaultPluginIds, ) if (pluginIds.length > 0) { - const installedPlugins = await listInstalledPlugins(pluginIds) + const installedPlugins = await listInstalledPlugins(client, pluginIds) const installedPluginIds = new Set(installedPlugins.map((plugin) => plugin.plugin_id)) if (pluginIds.every((pluginId) => installedPluginIds.has(pluginId))) { return verified(config.title, { @@ -258,7 +199,7 @@ export const bootstrapMarketplacePlugins = async ( } } - const resolved = await resolveLatestPluginIdentifiers(pluginIds) + const resolved = await resolveLatestPluginIdentifiers(client, pluginIds) if (resolved.missing.length > 0) { return blocked( @@ -273,7 +214,7 @@ export const bootstrapMarketplacePlugins = async ( if (requiredPluginUniqueIdentifiers.length === 0) return skipped(config.title, 'No marketplace plugins were requested.') - const installedPlugins = await listInstalledPlugins(requiredPluginIds) + const installedPlugins = await listInstalledPlugins(client, requiredPluginIds) const installedPluginIds = new Set(installedPlugins.map((plugin) => plugin.plugin_id)) const missingPluginUniqueIdentifiers = requiredPluginUniqueIdentifiers.filter( (identifier) => !installedPluginIds.has(getPluginId(identifier)), @@ -294,9 +235,10 @@ export const bootstrapMarketplacePlugins = async ( } const startedTask = await installMarketplacePluginsWithFallback( + client, missingPluginUniqueIdentifiers, ).catch((error) => { - return { error: error instanceof Error ? error.message : String(error) } + return { error: getMarketplaceInstallErrorText(error) } }) if ('error' in startedTask) return blocked(config.title, startedTask.error) @@ -305,7 +247,7 @@ export const bootstrapMarketplacePlugins = async ( const taskId = startedTask.task_id || startedTask.task?.id if (!taskId) return blocked(config.title, 'Marketplace plugin install did not return a task id.') - const taskResult = await waitForPluginInstallTask(taskId) + const taskResult = await waitForPluginInstallTask(client, taskId) if (!taskResult.ok) return blocked(config.title, taskResult.reason) return created(config.title, resource) diff --git a/e2e/support/seed.ts b/e2e/support/seed.ts index eddac03dd46..e0ec106de0d 100644 --- a/e2e/support/seed.ts +++ b/e2e/support/seed.ts @@ -1,3 +1,4 @@ +import type { ConsoleClient } from './api/console-client' import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { e2eDir } from '../scripts/common' @@ -18,6 +19,7 @@ export type SeedResult = { } export type SeedContext = { + consoleClient: ConsoleClient dryRun: boolean resources: Map } diff --git a/e2e/support/tools.ts b/e2e/support/tools.ts deleted file mode 100644 index 11ea82d4dcd..00000000000 --- a/e2e/support/tools.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createApiContext, expectApiResponseOK } from './api' - -export async function deleteBuiltinToolCredential( - provider: string, - credentialId: string, -): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post( - `/console/api/workspaces/current/tool-provider/builtin/${provider}/delete`, - { - data: { credential_id: credentialId }, - }, - ) - await expectApiResponseOK( - response, - `Delete built-in tool credential ${credentialId} for ${provider}`, - ) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/tests/console-client.test.ts b/e2e/tests/console-client.test.ts new file mode 100644 index 00000000000..40078b08554 --- /dev/null +++ b/e2e/tests/console-client.test.ts @@ -0,0 +1,208 @@ +import type { APIRequestContext, APIResponse } from '@playwright/test' +import { Buffer } from 'node:buffer' +import { describe, expect, it, vi } from 'vitest' +import { createConsoleClient } from '../support/api/console-client' +import { createPlaywrightFetch } from '../support/api/playwright-fetch' + +const createApiResponse = ({ + body = '', + headers = { 'content-type': 'application/json' }, + status = 200, + statusText = 'OK', + url = 'http://api.test/console/api/apps/app-1', +}: { + body?: string + headers?: Record + status?: number + statusText?: string + url?: string +} = {}): APIResponse => { + const bodyBuffer = Buffer.from(body) + + return { + body: async () => bodyBuffer, + dispose: async () => {}, + headers: () => headers, + headersArray: () => Object.entries(headers).map(([name, value]) => ({ name, value })), + json: async () => JSON.parse(body), + ok: () => status >= 200 && status < 300, + securityDetails: async () => null, + serverAddr: async () => null, + status: () => status, + statusText: () => statusText, + text: async () => body, + url: () => url, + [Symbol.asyncDispose]: async () => {}, + } +} + +const createRequestContext = (response: APIResponse, csrfToken = 'csrf-token') => { + const fetch = vi.fn(async () => response) + const context = { + fetch, + storageState: vi.fn(async () => ({ + cookies: [ + { + domain: 'api.test', + expires: -1, + httpOnly: false, + name: 'csrf_token', + path: '/', + sameSite: 'Lax', + secure: false, + value: csrfToken, + }, + ], + origins: [], + })), + } satisfies Pick + + return { context, fetch } +} + +const callPlaywrightFetch = (requestContext: Pick, request: Request) => + createPlaywrightFetch(requestContext)(request, {}, { context: {} }, ['test'], undefined) + +describe('createPlaywrightFetch', () => { + it('forwards the Fetch request without following redirects and returns a standard Response', async () => { + const apiResponse = createApiResponse({ + body: '{"ok":true}', + status: 201, + statusText: 'Created', + }) + const { context, fetch } = createRequestContext(apiResponse) + const response = await callPlaywrightFetch( + context, + new Request('http://api.test/console/api/apps', { + body: '{"name":"E2E App"}', + headers: { 'content-type': 'application/json', 'x-test': 'value' }, + method: 'POST', + }), + ) + + expect(fetch).toHaveBeenCalledWith( + 'http://api.test/console/api/apps', + expect.objectContaining({ + data: Buffer.from('{"name":"E2E App"}'), + failOnStatusCode: false, + headers: expect.objectContaining({ 'content-type': 'application/json', 'x-test': 'value' }), + maxRedirects: 0, + method: 'POST', + }), + ) + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ ok: true }) + }) + + it('represents a 204 response without an invalid response body', async () => { + const { context } = createRequestContext(createApiResponse({ body: '', status: 204 })) + + const response = await callPlaywrightFetch( + context, + new Request('http://api.test/console/api/apps/app-1', { method: 'DELETE' }), + ) + + expect(response.status).toBe(204) + await expect(response.text()).resolves.toBe('') + }) + + it('forwards generated multipart bodies as raw bytes with their boundary', async () => { + const { context, fetch } = createRequestContext(createApiResponse({ body: '{"ok":true}' })) + const formData = new FormData() + formData.append('pkg', new File(['plugin-package'], 'plugin.difypkg')) + + await callPlaywrightFetch( + context, + new Request('http://api.test/console/api/workspaces/current/plugin/upload/pkg', { + body: formData, + method: 'POST', + }), + ) + + const options = fetch.mock.calls[0]?.[1] + expect(options?.headers).toEqual( + expect.objectContaining({ 'content-type': expect.stringContaining('multipart/form-data') }), + ) + expect(Buffer.isBuffer(options?.data)).toBe(true) + expect((options?.data as Buffer).toString()).toContain('plugin.difypkg') + expect((options?.data as Buffer).toString()).toContain('plugin-package') + }) + + it('rejects redirects as an authentication or routing infrastructure failure', async () => { + const dispose = vi.fn(async () => {}) + const { context } = createRequestContext({ + ...createApiResponse({ + body: '', + headers: { location: 'http://web.test/signin' }, + status: 302, + statusText: 'Found', + }), + dispose, + }) + + await expect( + callPlaywrightFetch(context, new Request('http://api.test/console/api/apps')), + ).rejects.toThrow('redirected with 302') + expect(dispose).toHaveBeenCalledOnce() + }) +}) + +describe('createConsoleClient', () => { + it('validates generated request inputs before transport', async () => { + const { context, fetch } = createRequestContext(createApiResponse()) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.get({ + params: { app_id: 1 as unknown as string }, + }), + ).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) + + it('rejects invalid generated multipart values before transport', async () => { + const { context, fetch } = createRequestContext(createApiResponse()) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.workspaces.current.plugin.upload.pkg.post({ + body: { pkg: 1 as unknown as File }, + }), + ).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) + + it('validates server responses against the generated response schema', async () => { + const { context } = createRequestContext(createApiResponse({ body: '{"id":1}' })) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.get({ params: { app_id: '00000000-0000-4000-8000-000000000001' } }), + ).rejects.toThrow() + }) + + it('adds the current CSRF token and accepts generated 204 responses', async () => { + const { context, fetch } = createRequestContext(createApiResponse({ body: '', status: 204 })) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.delete({ + params: { app_id: '00000000-0000-4000-8000-000000000001' }, + }), + ).resolves.toBeUndefined() + const requestHeaders = fetch.mock.calls[0]?.[1]?.headers + expect(requestHeaders).toEqual(expect.objectContaining({ 'x-csrf-token': 'csrf-token' })) + }) + + it('rejects authenticated calls without a CSRF token before transport', async () => { + const { context, fetch } = createRequestContext(createApiResponse(), '') + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.delete({ + params: { app_id: '00000000-0000-4000-8000-000000000001' }, + }), + ).rejects.toThrow('requires an authenticated CSRF token') + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/e2e/tests/marketplace-plugins.test.ts b/e2e/tests/marketplace-plugins.test.ts new file mode 100644 index 00000000000..dda4008adb7 --- /dev/null +++ b/e2e/tests/marketplace-plugins.test.ts @@ -0,0 +1,108 @@ +import type { ConsoleClient } from '../support/api/console-client' +import { ORPCError } from '@orpc/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bootstrapMarketplacePlugins } from '../support/marketplace-plugins' + +const createMarketplaceConsoleClient = (installError: unknown) => { + const installMarketplace = vi.fn().mockRejectedValue(installError) + const uploadPackage = vi.fn().mockResolvedValue({ + unique_identifier: 'langgenius/test:1.0.0@package', + }) + const installPackage = vi.fn().mockResolvedValue({ all_installed: true }) + const consoleClient = { + workspaces: { + current: { + plugin: { + install: { + marketplace: { post: installMarketplace }, + pkg: { post: installPackage }, + }, + list: { + installations: { + ids: { post: vi.fn().mockResolvedValue({ plugins: [] }) }, + }, + latestVersions: { + post: vi.fn().mockResolvedValue({ + versions: { + 'langgenius/test': { + unique_identifier: 'langgenius/test:1.0.0@marketplace', + }, + }, + }), + }, + }, + upload: { pkg: { post: uploadPackage } }, + }, + }, + }, + } as unknown as ConsoleClient + + return { consoleClient, installPackage, uploadPackage } +} + +const bootstrapTestPlugin = (consoleClient: ConsoleClient) => + bootstrapMarketplacePlugins( + { consoleClient, dryRun: false, resources: new Map() }, + { + defaultPluginIds: ['langgenius/test'], + pluginIdsEnv: 'E2E_TEST_MARKETPLACE_PLUGIN_IDS', + title: 'Test marketplace plugin', + }, + ) + +describe('bootstrapMarketplacePlugins', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('uses generated package upload when the API process cannot download from Marketplace', async () => { + vi.stubEnv('E2E_TEST_MARKETPLACE_PLUGIN_IDS', '') + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('plugin-package')) + const { consoleClient, installPackage, uploadPackage } = createMarketplaceConsoleClient( + new ORPCError('INTERNAL_SERVER_ERROR', { + data: { + body: { + message: + 'Reached maximum retries (3) for URL https://marketplace.test/plugins/download', + }, + }, + status: 500, + }), + ) + const result = await bootstrapTestPlugin(consoleClient) + + expect(result.status).toBe('verified') + expect(uploadPackage).toHaveBeenCalledWith({ + body: { pkg: expect.any(File) }, + }) + expect(installPackage).toHaveBeenCalledWith({ + body: { plugin_unique_identifiers: ['langgenius/test:1.0.0@package'] }, + }) + }) + + it('does not hide unrelated generated client failures behind package upload', async () => { + vi.stubEnv('E2E_TEST_MARKETPLACE_PLUGIN_IDS', '') + const marketplaceFetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('plugin-package')) + const { consoleClient, installPackage, uploadPackage } = createMarketplaceConsoleClient( + new ORPCError('INTERNAL_SERVER_ERROR', { + data: { body: { message: 'Database unavailable' } }, + status: 500, + }), + ) + + const result = await bootstrapTestPlugin(consoleClient) + + expect(result).toEqual( + expect.objectContaining({ + reason: expect.stringContaining('Database unavailable'), + status: 'blocked', + }), + ) + expect(marketplaceFetch).not.toHaveBeenCalled() + expect(uploadPackage).not.toHaveBeenCalled() + expect(installPackage).not.toHaveBeenCalled() + }) +}) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index c9e6e2ff30e..fb2c46efaf5 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -157,14 +157,6 @@ "count": 1 } }, - "web/app/(shareLayout)/components/splash.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -671,11 +663,6 @@ "count": 1 } }, - "web/app/components/app/create-from-dsl-modal/index.tsx": { - "eslint-react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/app/duplicate-modal/index.tsx": { "no-restricted-imports": { "count": 1 @@ -848,14 +835,6 @@ "count": 1 } }, - "web/app/components/base/audio-btn/audio.ts": { - "node-js/prefer-global/buffer": { - "count": 1 - }, - "typescript/no-explicit-any": { - "count": 3 - } - }, "web/app/components/base/audio-gallery/AudioPlayer.tsx": { "jsx_a11y/media-has-caption": { "count": 1 @@ -1440,7 +1419,7 @@ }, "web/app/components/base/icons/src/public/files/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 11 + "count": 10 } }, "web/app/components/base/icons/src/public/knowledge/dataset-card/index.ts": { diff --git a/packages/contracts/binary-zod.test.ts b/packages/contracts/binary-zod.test.ts new file mode 100644 index 00000000000..35a6a96b290 --- /dev/null +++ b/packages/contracts/binary-zod.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { zPostFilesUploadBody } from './generated/api/console/files/zod.gen' +import { zPostWorkspacesCurrentPluginUploadPkgBody } from './generated/api/console/workspaces/zod.gen' + +describe('generated binary schemas', () => { + it.each([ + ['file upload', zPostFilesUploadBody, 'file'], + ['plugin package upload', zPostWorkspacesCurrentPluginUploadPkgBody, 'pkg'], + ] as const)('validates %s values at runtime', (_, schema, field) => { + const file = new File(['test'], 'test.txt', { type: 'text/plain' }) + + expect(schema.safeParse({ [field]: file }).success).toBe(true) + expect(schema.safeParse({ [field]: 123 }).success).toBe(false) + }) +}) diff --git a/packages/contracts/console.ts b/packages/contracts/console.ts new file mode 100644 index 00000000000..af4c1f89f3e --- /dev/null +++ b/packages/contracts/console.ts @@ -0,0 +1,7 @@ +import { consoleRouterContract as generatedConsoleRouterContract } from './generated/api/console/router.gen' +import { contract as knowledgeFsContract } from './generated/knowledge-fs/orpc.gen' + +export const consoleRouterContract = { + ...generatedConsoleRouterContract, + knowledgeFs: knowledgeFsContract, +} diff --git a/packages/contracts/generated/api/console/activate/orpc.gen.ts b/packages/contracts/generated/api/console/activate/orpc.gen.ts index 5ab609f5bb9..3bb61c9e7f1 100644 --- a/packages/contracts/generated/api/console/activate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/activate/orpc.gen.ts @@ -29,15 +29,22 @@ export const check = { } /** + * Accept an invitation without letting an existing session act for another account + * * Activate account with invitation token + * Token-only activation remains available for legacy clients. When the request already + * carries a console session, that session must belong to the account encoded in the + * invitation before the token is consumed or tenant membership is changed. */ export const post = oc .route({ - description: 'Activate account with invitation token', + description: + 'Activate account with invitation token\nToken-only activation remains available for legacy clients. When the request already\ncarries a console session, that session must belong to the account encoded in the\ninvitation before the token is consumed or tenant membership is changed.', inputStructure: 'detailed', method: 'POST', operationId: 'postActivate', path: '/activate', + summary: 'Accept an invitation without letting an existing session act for another account', tags: ['console'], }) .input(z.object({ body: zPostActivateBody })) diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index 2cc590c433d..12b58ef4188 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -143,6 +143,7 @@ import { zPostAgentByAgentIdCopyBody, zPostAgentByAgentIdCopyPath, zPostAgentByAgentIdCopyResponse, + zPostAgentByAgentIdDebugConversationRefreshBody, zPostAgentByAgentIdDebugConversationRefreshPath, zPostAgentByAgentIdDebugConversationRefreshResponse, zPostAgentByAgentIdFeaturesBody, @@ -852,7 +853,12 @@ export const post12 = oc path: '/agent/{agent_id}/debug-conversation/refresh', tags: ['console'], }) - .input(z.object({ params: zPostAgentByAgentIdDebugConversationRefreshPath })) + .input( + z.object({ + body: zPostAgentByAgentIdDebugConversationRefreshBody.optional(), + params: zPostAgentByAgentIdDebugConversationRefreshPath, + }), + ) .output(zPostAgentByAgentIdDebugConversationRefreshResponse) export const refresh = { diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index d26268d5f7b..98fda2ccab9 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -46,7 +46,7 @@ export type AgentAppDetailWithSite = { maintainer?: string | null max_active_requests?: number | null mode: string - model_config?: ModelConfig | null + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array role?: string | null @@ -282,6 +282,10 @@ export type AgentAppCopyPayload = { role?: string | null } +export type AgentDebugConversationRefreshPayload = { + draft_type?: AgentConfigDraftType +} + export type AgentDebugConversationRefreshResponse = { debug_conversation_has_messages?: boolean debug_conversation_id: string @@ -536,13 +540,31 @@ export type DeletedTool = { type: string } -export type ModelConfig = { - completion_params?: { - [key: string]: unknown - } - mode: LlmMode - name: string - provider: string +export type AppModelConfigResponse = { + agent_mode?: unknown | null + annotation_reply?: unknown | null + chat_prompt_config?: unknown | null + completion_prompt_config?: unknown | null + created_at?: number | null + created_by?: string | null + dataset_configs?: unknown | null + dataset_query_variable?: string | null + external_data_tools?: unknown | null + file_upload?: unknown | null + model?: unknown | null + more_like_this?: unknown | null + opening_statement?: string | null + pre_prompt?: string | null + prompt_type?: string | null + retriever_resource?: unknown | null + sensitive_word_avoidance?: unknown | null + speech_to_text?: unknown | null + suggested_questions?: unknown | null + suggested_questions_after_answer?: unknown | null + text_to_speech?: unknown | null + updated_at?: number | null + updated_by?: string | null + user_input_form?: unknown | null } export type AppDetailSiteResponse = { @@ -800,6 +822,8 @@ export type AgentConfigSkillMarkdownResponse = { truncated: boolean } +export type AgentConfigDraftType = 'debug_build' | 'draft' + export type AgentDriveItemResponse = { created_at?: number | null file_kind: string @@ -1095,8 +1119,6 @@ export type AgentAppPublishedReferenceResponse = { app_name: string } -export type LlmMode = 'chat' | 'completion' - export type AgentKind = 'dify_agent' export type AgentPublishedReferenceResponse = { @@ -1205,8 +1227,6 @@ export type AgentSoulToolsConfig = { dify_tools?: Array } -export type AgentConfigDraftType = 'debug_build' | 'draft' - export type DeclaredOutputConfig = { array_item?: DeclaredArrayItem | null check?: DeclaredOutputCheckConfig | null @@ -1218,14 +1238,14 @@ export type DeclaredOutputConfig = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -1604,14 +1624,14 @@ export type DeclaredArrayItem = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -1894,7 +1914,7 @@ export type AgentAppDetailWithSiteWritable = { maintainer?: string | null max_active_requests?: number | null mode: string - model_config?: ModelConfig | null + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array role?: string | null @@ -2252,6 +2272,10 @@ export type GetAgentByAgentIdBuildDraftData = { url: '/agent/{agent_id}/build-draft' } +export type GetAgentByAgentIdBuildDraftErrors = { + 404: unknown +} + export type GetAgentByAgentIdBuildDraftResponses = { 200: AgentBuildDraftResponse } @@ -2733,7 +2757,7 @@ export type PostAgentByAgentIdCopyResponse = PostAgentByAgentIdCopyResponses[keyof PostAgentByAgentIdCopyResponses] export type PostAgentByAgentIdDebugConversationRefreshData = { - body?: never + body?: AgentDebugConversationRefreshPayload path: { agent_id: string } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index cae2f7f9eb3..4146d261ffc 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -288,6 +288,36 @@ export const zDeletedTool = z.object({ type: z.string(), }) +/** + * AppModelConfigResponse + */ +export const zAppModelConfigResponse = z.object({ + agent_mode: z.unknown().nullish(), + annotation_reply: z.unknown().nullish(), + chat_prompt_config: z.unknown().nullish(), + completion_prompt_config: z.unknown().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + dataset_configs: z.unknown().nullish(), + dataset_query_variable: z.string().nullish(), + external_data_tools: z.unknown().nullish(), + file_upload: z.unknown().nullish(), + model: z.unknown().nullish(), + more_like_this: z.unknown().nullish(), + opening_statement: z.string().nullish(), + pre_prompt: z.string().nullish(), + prompt_type: z.string().nullish(), + retriever_resource: z.unknown().nullish(), + sensitive_word_avoidance: z.unknown().nullish(), + speech_to_text: z.unknown().nullish(), + suggested_questions: z.unknown().nullish(), + suggested_questions_after_answer: z.unknown().nullish(), + text_to_speech: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + user_input_form: z.unknown().nullish(), +}) + /** * AppDetailSiteResponse */ @@ -339,6 +369,47 @@ export const zWorkflowPartial = z.object({ updated_by: z.string().nullish(), }) +/** + * AgentAppDetailWithSite + */ +export const zAgentAppDetailWithSite = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + api_base_url: z.string().nullish(), + app_id: z.string().nullish(), + backing_app_id: z.string().nullish(), + bound_agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + debug_conversation_has_messages: z.boolean().optional().default(false), + debug_conversation_id: z.string().nullish(), + debug_conversation_message_count: z.int().optional().default(0), + deleted_tools: z.array(zDeletedTool).optional(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + hidden_app_backed: z.boolean().optional().default(false), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + id: z.string(), + maintainer: z.string().nullish(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zAppModelConfigResponse.nullish(), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + role: z.string().nullish(), + site: zAppDetailSiteResponse.nullish(), + tags: z.array(zTag).optional(), + tracing: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + /** * ComposerBindingPayload */ @@ -580,6 +651,45 @@ export const zAgentConfigSkillInspectResponse = z.object({ warnings: z.array(z.string()).optional(), }) +/** + * AgentConfigDraftType + * + * Editable Agent Soul draft workspace type. + */ +export const zAgentConfigDraftType = z.enum(['debug_build', 'draft']) + +/** + * AgentDebugConversationRefreshPayload + */ +export const zAgentDebugConversationRefreshPayload = z.object({ + draft_type: zAgentConfigDraftType.optional().default('debug_build'), +}) + +/** + * AgentConfigDraftSummaryResponse + */ +export const zAgentConfigDraftSummaryResponse = z.object({ + account_id: z.string().nullish(), + agent_id: z.string(), + base_snapshot_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + draft_type: zAgentConfigDraftType, + id: z.string(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), +}) + +/** + * AgentPublishResponse + */ +export const zAgentPublishResponse = z.object({ + active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), + active_config_snapshot_id: z.string(), + draft: zAgentConfigDraftSummaryResponse.nullish(), + result: z.string(), +}) + /** * AgentDriveItemResponse */ @@ -1005,64 +1115,6 @@ export const zAgentAppPagination = z.object({ total: z.int(), }) -/** - * LLMMode - * - * Enum class for large language model mode. - */ -export const zLlmMode = z.enum(['chat', 'completion']) - -/** - * ModelConfig - */ -export const zModelConfig = z.object({ - completion_params: z.record(z.string(), z.unknown()).optional(), - mode: zLlmMode, - name: z.string(), - provider: z.string(), -}) - -/** - * AgentAppDetailWithSite - */ -export const zAgentAppDetailWithSite = z.object({ - access_mode: z.string().nullish(), - active_config_is_published: z.boolean().optional().default(false), - api_base_url: z.string().nullish(), - app_id: z.string().nullish(), - backing_app_id: z.string().nullish(), - bound_agent_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - debug_conversation_has_messages: z.boolean().optional().default(false), - debug_conversation_id: z.string().nullish(), - debug_conversation_message_count: z.int().optional().default(0), - deleted_tools: z.array(zDeletedTool).optional(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - hidden_app_backed: z.boolean().optional().default(false), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: z.string().nullish(), - icon_url: z.string().nullable(), - id: z.string(), - maintainer: z.string().nullish(), - max_active_requests: z.int().nullish(), - mode: z.string(), - model_config: zModelConfig.nullish(), - name: z.string(), - permission_keys: z.array(z.string()).optional(), - role: z.string().nullish(), - site: zAppDetailSiteResponse.nullish(), - tags: z.array(zTag).optional(), - tracing: z.unknown().nullish(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.nullish(), -}) - /** * AgentKind * @@ -1227,38 +1279,6 @@ export const zAgentSoulPromptConfig = z.object({ system_prompt: z.string().optional().default(''), }) -/** - * AgentConfigDraftType - * - * Editable Agent Soul draft workspace type. - */ -export const zAgentConfigDraftType = z.enum(['debug_build', 'draft']) - -/** - * AgentConfigDraftSummaryResponse - */ -export const zAgentConfigDraftSummaryResponse = z.object({ - account_id: z.string().nullish(), - agent_id: z.string(), - base_snapshot_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - draft_type: zAgentConfigDraftType, - id: z.string(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), -}) - -/** - * AgentPublishResponse - */ -export const zAgentPublishResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), - active_config_snapshot_id: z.string(), - draft: zAgentConfigDraftSummaryResponse.nullish(), - result: z.string(), -}) - /** * AgentHumanContactConfig */ @@ -1719,10 +1739,10 @@ export const zDeclaredArrayItem = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -2188,10 +2208,10 @@ export const zDeclaredOutputConfig = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -2732,7 +2752,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({ maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), - model_config: zModelConfig.nullish(), + model_config: zAppModelConfigResponse.nullish(), name: z.string(), permission_keys: z.array(z.string()).optional(), role: z.string().nullish(), @@ -2874,7 +2894,7 @@ export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void() export const zPostAgentByAgentIdAudioToTextBody = z.object({ draft_type: z.enum(['debug_build', 'draft']).optional().default('draft'), - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAgentByAgentIdAudioToTextPath = z.object({ @@ -3124,7 +3144,7 @@ export const zGetAgentByAgentIdConfigSkillsQuery = z.object({ export const zGetAgentByAgentIdConfigSkillsResponse = zAgentConfigSkillListResponse export const zPostAgentByAgentIdConfigSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAgentByAgentIdConfigSkillsUploadPath = z.object({ @@ -3244,6 +3264,8 @@ export const zPostAgentByAgentIdCopyPath = z.object({ */ export const zPostAgentByAgentIdCopyResponse = zAgentAppDetailWithSite +export const zPostAgentByAgentIdDebugConversationRefreshBody = zAgentDebugConversationRefreshPayload + export const zPostAgentByAgentIdDebugConversationRefreshPath = z.object({ agent_id: z.uuid(), }) @@ -3498,7 +3520,7 @@ export const zPostAgentByAgentIdSandboxFilesUploadPath = z.object({ export const zPostAgentByAgentIdSandboxFilesUploadResponse = zSandboxUploadResponse export const zPostAgentByAgentIdSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAgentByAgentIdSkillsUploadPath = z.object({ diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index f6513363093..abf5b16538a 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -40,7 +40,7 @@ export type AppDetailWithSite = { maintainer?: string | null max_active_requests?: number | null mode: string - model_config?: ModelConfig | null + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array site?: AppDetailSiteResponse | null @@ -415,7 +415,6 @@ export type AppApiStatusPayload = { export type AppDetail = { access_mode?: string | null - app_model_config?: ModelConfig | null created_at?: number | null created_by?: string | null description?: string | null @@ -425,7 +424,8 @@ export type AppDetail = { icon_background?: string | null id: string maintainer?: string | null - mode_compatible_with_agent: string + mode: string + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array tags?: Array @@ -1020,9 +1020,9 @@ export type SyncDraftWorkflowPayload = { } export type SyncDraftWorkflowResponse = { - hash?: string - result?: string - updated_at?: string + hash: string + result: string + updated_at: number } export type WorkflowDraftVariableList = { @@ -1322,13 +1322,31 @@ export type DeletedTool = { type: string } -export type ModelConfig = { - completion_params?: { - [key: string]: unknown - } - mode: LlmMode - name: string - provider: string +export type AppModelConfigResponse = { + agent_mode?: unknown | null + annotation_reply?: unknown | null + chat_prompt_config?: unknown | null + completion_prompt_config?: unknown | null + created_at?: number | null + created_by?: string | null + dataset_configs?: unknown | null + dataset_query_variable?: string | null + external_data_tools?: unknown | null + file_upload?: unknown | null + model?: unknown | null + more_like_this?: unknown | null + opening_statement?: string | null + pre_prompt?: string | null + prompt_type?: string | null + retriever_resource?: unknown | null + sensitive_word_avoidance?: unknown | null + speech_to_text?: unknown | null + suggested_questions?: unknown | null + suggested_questions_after_answer?: unknown | null + text_to_speech?: unknown | null + updated_at?: number | null + updated_by?: string | null + user_input_form?: unknown | null } export type AppDetailSiteResponse = { @@ -1608,6 +1626,15 @@ export type FeedbackStat = { like: number } +export type ModelConfig = { + completion_params?: { + [key: string]: unknown + } + mode: LlmMode + name: string + provider: string +} + export type Conversation = { admin_feedback_stats?: FeedbackStat | null annotation?: ConversationAnnotation | null @@ -2043,14 +2070,14 @@ export type DeclaredOutputConfig = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -2196,8 +2223,6 @@ export type ModelConfigPartial = { updated_by?: string | null } -export type LlmMode = 'chat' | 'completion' - export type PluginDependencyType = 'github' | 'marketplace' | 'package' export type Github = { @@ -2259,6 +2284,8 @@ export type StatusCount = { success: number } +export type LlmMode = 'chat' | 'completion' + export type SimpleMessageDetail = { answer: string inputs: { @@ -2477,14 +2504,14 @@ export type DeclaredArrayItem = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -3075,7 +3102,7 @@ export type AppDetailWithSiteWritable = { maintainer?: string | null max_active_requests?: number | null mode: string - model_config?: ModelConfig | null + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array site?: AppDetailSiteResponseWritable | null diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 9cc33be2af6..a41672798e6 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -663,10 +663,13 @@ export const zSyncDraftWorkflowPayload = z.object({ hash: z.string().nullish(), }) +/** + * SyncDraftWorkflowResponse + */ export const zSyncDraftWorkflowResponse = z.object({ - hash: z.string().optional(), - result: z.string().optional(), - updated_at: z.string().optional(), + hash: z.string(), + result: z.string(), + updated_at: z.int(), }) /** @@ -886,6 +889,36 @@ export const zDeletedTool = z.object({ type: z.string(), }) +/** + * AppModelConfigResponse + */ +export const zAppModelConfigResponse = z.object({ + agent_mode: z.unknown().nullish(), + annotation_reply: z.unknown().nullish(), + chat_prompt_config: z.unknown().nullish(), + completion_prompt_config: z.unknown().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + dataset_configs: z.unknown().nullish(), + dataset_query_variable: z.string().nullish(), + external_data_tools: z.unknown().nullish(), + file_upload: z.unknown().nullish(), + model: z.unknown().nullish(), + more_like_this: z.unknown().nullish(), + opening_statement: z.string().nullish(), + pre_prompt: z.string().nullish(), + prompt_type: z.string().nullish(), + retriever_resource: z.unknown().nullish(), + sensitive_word_avoidance: z.unknown().nullish(), + speech_to_text: z.unknown().nullish(), + suggested_questions: z.unknown().nullish(), + suggested_questions_after_answer: z.unknown().nullish(), + text_to_speech: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + user_input_form: z.unknown().nullish(), +}) + /** * AppDetailSiteResponse */ @@ -937,6 +970,66 @@ export const zWorkflowPartial = z.object({ updated_by: z.string().nullish(), }) +/** + * AppDetailWithSite + */ +export const zAppDetailWithSite = z.object({ + access_mode: z.string().nullish(), + api_base_url: z.string().nullish(), + app_id: z.string().nullish(), + bound_agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + deleted_tools: z.array(zDeletedTool).optional(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + id: z.string(), + maintainer: z.string().nullish(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zAppModelConfigResponse.nullish(), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + site: zAppDetailSiteResponse.nullish(), + tags: z.array(zTag).optional(), + tracing: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + +/** + * AppDetail + */ +export const zAppDetail = z.object({ + access_mode: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + id: z.string(), + maintainer: z.string().nullish(), + mode: z.string(), + model_config: zAppModelConfigResponse.nullish(), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + tags: z.array(zTag).optional(), + tracing: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + /** * ImportStatus */ @@ -2251,102 +2344,6 @@ export const zAppPagination = z.object({ total: z.int(), }) -/** - * LLMMode - * - * Enum class for large language model mode. - */ -export const zLlmMode = z.enum(['chat', 'completion']) - -/** - * ModelConfig - */ -export const zModelConfig = z.object({ - completion_params: z.record(z.string(), z.unknown()).optional(), - mode: zLlmMode, - name: z.string(), - provider: z.string(), -}) - -/** - * AppDetailWithSite - */ -export const zAppDetailWithSite = z.object({ - access_mode: z.string().nullish(), - api_base_url: z.string().nullish(), - app_id: z.string().nullish(), - bound_agent_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - deleted_tools: z.array(zDeletedTool).optional(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: z.string().nullish(), - icon_url: z.string().nullable(), - id: z.string(), - maintainer: z.string().nullish(), - max_active_requests: z.int().nullish(), - mode: z.string(), - model_config: zModelConfig.nullish(), - name: z.string(), - permission_keys: z.array(z.string()).optional(), - site: zAppDetailSiteResponse.nullish(), - tags: z.array(zTag).optional(), - tracing: z.unknown().nullish(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.nullish(), -}) - -/** - * AppDetail - */ -export const zAppDetail = z.object({ - access_mode: z.string().nullish(), - app_model_config: zModelConfig.nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - id: z.string(), - maintainer: z.string().nullish(), - mode_compatible_with_agent: z.string(), - name: z.string(), - permission_keys: z.array(z.string()).optional(), - tags: z.array(zTag).optional(), - tracing: z.unknown().nullish(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.nullish(), -}) - -/** - * ConversationDetail - */ -export const zConversationDetail = z.object({ - admin_feedback_stats: zFeedbackStat.nullish(), - annotated: z.boolean(), - created_at: z.int().nullish(), - from_account_id: z.string().nullish(), - from_end_user_id: z.string().nullish(), - from_source: z.string(), - id: z.string(), - introduction: z.string().nullish(), - message_count: z.int(), - model_config: zModelConfig.nullish(), - status: z.string(), - updated_at: z.int().nullish(), - user_feedback_stats: zFeedbackStat.nullish(), -}) - /** * PluginDependencyType */ @@ -2537,6 +2534,42 @@ export const zConversationWithSummaryPagination = z.object({ total: z.int(), }) +/** + * LLMMode + * + * Enum class for large language model mode. + */ +export const zLlmMode = z.enum(['chat', 'completion']) + +/** + * ModelConfig + */ +export const zModelConfig = z.object({ + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: zLlmMode, + name: z.string(), + provider: z.string(), +}) + +/** + * ConversationDetail + */ +export const zConversationDetail = z.object({ + admin_feedback_stats: zFeedbackStat.nullish(), + annotated: z.boolean(), + created_at: z.int().nullish(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + introduction: z.string().nullish(), + message_count: z.int(), + model_config: zModelConfig.nullish(), + status: z.string(), + updated_at: z.int().nullish(), + user_feedback_stats: zFeedbackStat.nullish(), +}) + /** * SimpleMessageDetail */ @@ -2905,10 +2938,10 @@ export const zDeclaredArrayItem = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -3635,10 +3668,10 @@ export const zDeclaredOutputConfig = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -4234,7 +4267,7 @@ export const zAppDetailWithSiteWritable = z.object({ maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), - model_config: zModelConfig.nullish(), + model_config: zAppModelConfigResponse.nullish(), name: z.string(), permission_keys: z.array(z.string()).optional(), site: zAppDetailSiteResponseWritable.nullish(), @@ -4688,7 +4721,7 @@ export const zGetAppsByAppIdAgentConfigSkillsQuery = z.object({ export const zGetAppsByAppIdAgentConfigSkillsResponse = zAgentConfigSkillListResponse export const zPostAppsByAppIdAgentConfigSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAppsByAppIdAgentConfigSkillsUploadPath = z.object({ @@ -4919,7 +4952,7 @@ export const zGetAppsByAppIdAgentLogsQuery = z.object({ export const zGetAppsByAppIdAgentLogsResponse = zAgentLogResponse export const zPostAppsByAppIdAgentSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ @@ -5130,7 +5163,7 @@ export const zPostAppsByAppIdApiEnablePath = z.object({ export const zPostAppsByAppIdApiEnableResponse = zAppDetail export const zPostAppsByAppIdAudioToTextBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAppsByAppIdAudioToTextPath = z.object({ diff --git a/packages/contracts/generated/api/console/files/zod.gen.ts b/packages/contracts/generated/api/console/files/zod.gen.ts index 34fe6d2aa3d..d3d35b401a3 100644 --- a/packages/contracts/generated/api/console/files/zod.gen.ts +++ b/packages/contracts/generated/api/console/files/zod.gen.ts @@ -64,7 +64,7 @@ export const zGetFilesSupportTypeResponse = zAllowedExtensionsResponse export const zGetFilesUploadResponse = zUploadConfig export const zPostFilesUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), source: z.enum(['datasets']).optional(), }) diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index e52940bb8a4..f0acbe06c0e 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -144,7 +144,9 @@ export const zTextToAudioPayload = z.object({ /** * AudioBinaryResponse */ -export const zAudioBinaryResponse = z.custom() +export const zAudioBinaryResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * WorkflowRunPayload diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index 28cb3250d68..d5b6a62cfc4 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -436,14 +436,14 @@ export type DeclaredOutputConfig = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -652,14 +652,14 @@ export type DeclaredArrayItem = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index 491b9acdada..4474c465df9 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -660,10 +660,10 @@ export const zDeclaredArrayItem = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -1214,10 +1214,10 @@ export const zDeclaredOutputConfig = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), 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 0417727f5ba..65652a60498 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -21,6 +21,7 @@ export type SystemFeatureModel = { is_allow_create_workspace: boolean is_allow_register: boolean is_email_setup: boolean + knowledge_fs_enabled: boolean license: LicenseModel max_plugin_package_size: number plugin_installation_permission: PluginInstallationPermissionModel 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 3ce9d68841e..8d2aa34ee22 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -119,6 +119,7 @@ export const zSystemFeatureModel = z.object({ is_allow_create_workspace: z.boolean().default(false), is_allow_register: z.boolean().default(false), is_email_setup: z.boolean().default(false), + knowledge_fs_enabled: z.boolean().default(false), license: zLicenseModel.default({ expired_at: '', seats: { diff --git a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts index 14d8bed2c92..9f6536e5190 100644 --- a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts @@ -115,7 +115,9 @@ export const zTextToSpeechRequest = z.object({ /** * AudioBinaryResponse */ -export const zAudioBinaryResponse = z.custom() +export const zAudioBinaryResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * WorkflowRunRequest @@ -457,7 +459,7 @@ export const zGetTrialAppsByAppIdDatasetsQuery = z.object({ export const zGetTrialAppsByAppIdDatasetsResponse = zTrialDatasetListResponse export const zPostTrialAppsByAppIdFilesUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), source: z.enum(['datasets']).optional(), }) diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 0d1836cf9f5..a1ca3641db9 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -342,6 +342,7 @@ import { zPostWorkspacesCurrentPluginUploadBundleResponse, zPostWorkspacesCurrentPluginUploadGithubBody, zPostWorkspacesCurrentPluginUploadGithubResponse, + zPostWorkspacesCurrentPluginUploadPkgBody, zPostWorkspacesCurrentPluginUploadPkgResponse, zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath, zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse, @@ -2152,6 +2153,7 @@ export const post43 = oc path: '/workspaces/current/plugin/upload/pkg', tags: ['console'], }) + .input(z.object({ body: zPostWorkspacesCurrentPluginUploadPkgBody })) .output(zPostWorkspacesCurrentPluginUploadPkgResponse) export const pkg3 = { diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 5a48ea1f747..b8d8e148e6f 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -2269,6 +2269,7 @@ export type ToolParameter = { llm_description?: string | null max?: number | number | null min?: number | number | null + multiple?: boolean name: string options?: Array placeholder?: I18nObject | null @@ -4167,7 +4168,9 @@ export type PostWorkspacesCurrentPluginUploadGithubResponse = PostWorkspacesCurrentPluginUploadGithubResponses[keyof PostWorkspacesCurrentPluginUploadGithubResponses] export type PostWorkspacesCurrentPluginUploadPkgData = { - body?: never + body: { + pkg: Blob | File + } path?: never query?: never url: '/workspaces/current/plugin/upload/pkg' diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 627fa2aad20..ad7bcb669cd 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -252,7 +252,9 @@ export const zWorkspacePermissionResponse = z.object({ /** * BinaryFileResponse */ -export const zBinaryFileResponse = z.custom() +export const zBinaryFileResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * PluginAutoUpgradeChangeResponse @@ -3431,7 +3433,7 @@ export const zPluginParameterAutoGenerate = z.object({ /** * ToolParameter * - * Overrides type + * Tool-specific parameter declaration and invocation-value normalization. */ export const zToolParameter = z.object({ auto_generate: zPluginParameterAutoGenerate.nullish(), @@ -3452,6 +3454,7 @@ export const zToolParameter = z.object({ llm_description: z.string().nullish(), max: z.union([z.number(), z.int()]).nullish(), min: z.union([z.number(), z.int()]).nullish(), + multiple: z.boolean().optional().default(false), name: z.string(), options: z.array(zPluginParameterOption).optional(), placeholder: zI18nObject.nullish(), @@ -4619,6 +4622,10 @@ export const zPostWorkspacesCurrentPluginUploadGithubBody = zParserGithubUpload */ export const zPostWorkspacesCurrentPluginUploadGithubResponse = zPluginDecodeResponse +export const zPostWorkspacesCurrentPluginUploadPkgBody = z.object({ + pkg: z.custom((value) => value instanceof Blob || value instanceof File), +}) + /** * Success */ @@ -5872,7 +5879,7 @@ export const zPostWorkspacesCustomConfigBody = zWorkspaceCustomConfigPayload export const zPostWorkspacesCustomConfigResponse = zWorkspaceTenantResultResponse export const zPostWorkspacesCustomConfigWebappLogoUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) /** diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 2ec5f11174b..78fcc65cdad 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -112,7 +112,9 @@ export const zAppMetaResponse = z.object({ /** * AudioBinaryResponse */ -export const zAudioBinaryResponse = z.custom() +export const zAudioBinaryResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * AudioTranscriptResponse @@ -124,7 +126,9 @@ export const zAudioTranscriptResponse = z.object({ /** * BinaryFileResponse */ -export const zBinaryFileResponse = z.custom() +export const zBinaryFileResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * ButtonStyle @@ -2452,7 +2456,7 @@ export const zPutAppsAnnotationsByAnnotationIdPath = z.object({ export const zPutAppsAnnotationsByAnnotationIdResponse = zAnnotation export const zPostAudioToTextBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), user: z.string().optional(), }) @@ -2591,7 +2595,7 @@ export const zPostDatasetsBody = zDatasetCreatePayload export const zPostDatasetsResponse = zDatasetDetailResponse export const zPostDatasetsPipelineFileUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) /** @@ -2670,7 +2674,7 @@ export const zPatchDatasetsByDatasetIdResponse = zDatasetDetailWithPartialMember export const zPostDatasetsByDatasetIdDocumentCreateByFileBody = z.object({ data: z.string().optional(), - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostDatasetsByDatasetIdDocumentCreateByFilePath = z.object({ @@ -2695,7 +2699,7 @@ export const zPostDatasetsByDatasetIdDocumentCreateByTextResponse = zDocumentAnd export const zPostDatasetsByDatasetIdDocumentCreateByFile2Body = z.object({ data: z.string().optional(), - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostDatasetsByDatasetIdDocumentCreateByFile2Path = z.object({ @@ -2745,7 +2749,9 @@ export const zPostDatasetsByDatasetIdDocumentsDownloadZipPath = z.object({ /** * ZIP archive containing the requested documents. */ -export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.custom() +export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) export const zPostDatasetsByDatasetIdDocumentsMetadataBody = zMetadataOperationData @@ -2807,7 +2813,7 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = zDocumentDet export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdBody = z.object({ data: z.string().optional(), - file: z.custom().optional(), + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), }) export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ @@ -2967,7 +2973,7 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileBody = z.object({ data: z.string().optional(), - file: z.custom().optional(), + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), }) export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFilePath = z.object({ @@ -2996,7 +3002,7 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponse = export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Body = z.object({ data: z.string().optional(), - file: z.custom().optional(), + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), }) export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Path = z.object({ @@ -3167,7 +3173,7 @@ export const zGetEndUsersByEndUserIdPath = z.object({ export const zGetEndUsersByEndUserIdResponse = zEndUserDetail export const zPostFilesUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), user: z.string().optional(), }) @@ -3188,7 +3194,9 @@ export const zGetFilesByFileIdPreviewQuery = z.object({ /** * Returns the raw file content. The `Content-Type` header is set to the file's MIME type. If `as_attachment` is `true`, the file is returned as a download with `Content-Disposition: attachment`. */ -export const zGetFilesByFileIdPreviewResponse = z.custom() +export const zGetFilesByFileIdPreviewResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), @@ -3271,7 +3279,9 @@ export const zPostTextToAudioBody = zTextToAudioPayloadWithUser /** * Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; otherwise the provider output is returned directly. */ -export const zPostTextToAudioResponse = z.custom() +export const zPostTextToAudioResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) export const zGetWorkflowByTaskIdEventsPath = z.object({ task_id: z.string(), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 607386d387d..a593faced5c 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -525,6 +525,7 @@ export type SystemFeatureModel = { is_allow_create_workspace: boolean is_allow_register: boolean is_email_setup: boolean + knowledge_fs_enabled: boolean license: LicenseModel max_plugin_package_size: number plugin_installation_permission: PluginInstallationPermissionModel @@ -645,7 +646,7 @@ export type WebSiteResponse = { icon?: string | null icon_background?: string | null icon_type?: string | null - readonly icon_url: string | null + icon_url?: string | null input_placeholder?: string | null privacy_policy?: string | null prompt_public?: boolean | null @@ -668,32 +669,10 @@ export type WorkflowRunPayload = { export type GeneratedAppResponseWritable = JsonValue -export type HumanInputFormDefinitionResponseWritable = { - expiration_time: number - form_content: string - inputs: Array - resolved_default_values: { - [key: string]: string - } - site?: WebAppSiteResponseWritable | null - user_actions: Array -} - export type HumanInputFormSubmitResponseWritable = { [key: string]: unknown } -export type WebAppSiteResponseWritable = { - app_id: string - can_replace_logo: boolean - custom_config?: WebAppCustomConfigResponse | null - enable_site: boolean - end_user_id?: string | null - model_config?: WebModelConfigResponse | null - plan: string - site: WebSiteResponseWritable -} - export type WebMessageInfiniteScrollPaginationWritable = { data: Array has_more: boolean @@ -725,24 +704,6 @@ export type WebMessageListItemWritable = { total_price?: string | null } -export type WebSiteResponseWritable = { - chat_color_theme?: string | null - chat_color_theme_inverted: boolean - copyright?: string | null - custom_disclaimer?: string | null - default_language?: string | null - description?: string | null - icon?: string | null - icon_background?: string | null - icon_type?: string | null - input_placeholder?: string | null - privacy_policy?: string | null - prompt_public?: boolean | null - show_workflow_steps?: boolean | null - title: string - use_icon_as_answer_icon?: boolean | null -} - export type PostAudioToTextData = { body?: never path?: never diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 4dec23a2fe5..b1504220f27 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -797,6 +797,7 @@ export const zSystemFeatureModel = z.object({ is_allow_create_workspace: z.boolean().default(false), is_allow_register: z.boolean().default(false), is_email_setup: z.boolean().default(false), + knowledge_fs_enabled: z.boolean().default(false), license: zLicenseModel.default({ expired_at: '', seats: { @@ -903,7 +904,7 @@ export const zWebSiteResponse = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), - icon_url: z.string().nullable(), + icon_url: z.string().nullish(), input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), prompt_public: z.boolean().nullish(), @@ -1003,53 +1004,6 @@ export const zWebMessageInfiniteScrollPaginationWritable = z.object({ limit: z.int(), }) -/** - * WebSiteResponse - */ -export const zWebSiteResponseWritable = z.object({ - chat_color_theme: z.string().nullish(), - chat_color_theme_inverted: z.boolean(), - copyright: z.string().nullish(), - custom_disclaimer: z.string().nullish(), - default_language: z.string().nullish(), - description: z.string().nullish(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: z.string().nullish(), - input_placeholder: z.string().nullish(), - privacy_policy: z.string().nullish(), - prompt_public: z.boolean().nullish(), - show_workflow_steps: z.boolean().nullish(), - title: z.string(), - use_icon_as_answer_icon: z.boolean().nullish(), -}) - -/** - * WebAppSiteResponse - */ -export const zWebAppSiteResponseWritable = z.object({ - app_id: z.string(), - can_replace_logo: z.boolean(), - custom_config: zWebAppCustomConfigResponse.nullish(), - enable_site: z.boolean(), - end_user_id: z.string().nullish(), - model_config: zWebModelConfigResponse.nullish(), - plan: z.string(), - site: zWebSiteResponseWritable, -}) - -/** - * HumanInputFormDefinitionResponse - */ -export const zHumanInputFormDefinitionResponseWritable = z.object({ - expiration_time: z.int(), - form_content: z.string(), - inputs: z.array(zFormInputConfig), - resolved_default_values: z.record(z.string(), z.string()), - site: zWebAppSiteResponseWritable.nullish(), - user_actions: z.array(zUserActionConfig), -}) - /** * Success */ diff --git a/packages/contracts/generated/knowledge-fs/metadata.gen.ts b/packages/contracts/generated/knowledge-fs/metadata.gen.ts new file mode 100644 index 00000000000..ecbe25e5b49 --- /dev/null +++ b/packages/contracts/generated/knowledge-fs/metadata.gen.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-knowledge-fs-contract.mjs. +// Do not edit it manually. + +export const knowledgeFsSourceOpenapiSha256 = + 'f18910e9c45a64f0855e0643a7a626fb2889021b4f943458de86c6bd2469facb' +export const knowledgeFsConsoleDeclarationsSha256 = + '8bd1924747fdd0d478ca085817cbe000eb7e8630b2c6a03f4f13a6a0fac07946' +export const knowledgeFsGeneratedArtifactSha256 = { + 'orpc.gen.ts': 'e0d9954f817e97a4e95dd38c4522fb403c8659d741ce485fa671b1b4ce90a540', + 'types.gen.ts': 'a558ab80f32a8555bb5b44b7a596ef4a4a7a8cb7904390993aabcc587915f530', + 'zod.gen.ts': '6aa3d3e768fe0008ca405ecbe423ee325bd8f65f745dd2d1c86f2bdb887a97fd', +} as const diff --git a/packages/contracts/generated/knowledge-fs/orpc.gen.ts b/packages/contracts/generated/knowledge-fs/orpc.gen.ts new file mode 100644 index 00000000000..97816509fc2 --- /dev/null +++ b/packages/contracts/generated/knowledge-fs/orpc.gen.ts @@ -0,0 +1,1568 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { eventIterator, oc } from '@orpc/contract' +import * as z from 'zod' +import { + zCreateKnowledgeSpaceBody, + zCreateKnowledgeSpaceHeaders, + zCreateKnowledgeSpaceResponse, + zDeleteJobsByIdHeaders, + zDeleteJobsByIdPath, + zDeleteJobsByIdResponse, + zDeleteKnowledgeSpacesByIdBody, + zDeleteKnowledgeSpacesByIdDocumentsBulkBody, + zDeleteKnowledgeSpacesByIdDocumentsBulkHeaders, + zDeleteKnowledgeSpacesByIdDocumentsBulkPath, + zDeleteKnowledgeSpacesByIdDocumentsBulkResponse, + zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdBody, + zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdHeaders, + zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdPath, + zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdHeaders, + zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdPath, + zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponse, + zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdResponse, + zDeleteKnowledgeSpacesByIdHeaders, + zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdBody, + zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdHeaders, + zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdPath, + zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponse, + zDeleteKnowledgeSpacesByIdPath, + zDeleteKnowledgeSpacesByIdResponse, + zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdHeaders, + zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdPath, + zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdQuery, + zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponse, + zDeleteKnowledgeSpacesByIdSourcesBySourceIdBody, + zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsHeaders, + zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsPath, + zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsQuery, + zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponse, + zDeleteKnowledgeSpacesByIdSourcesBySourceIdHeaders, + zDeleteKnowledgeSpacesByIdSourcesBySourceIdPath, + zDeleteKnowledgeSpacesByIdSourcesBySourceIdQuery, + zDeleteKnowledgeSpacesByIdSourcesBySourceIdResponse, + zGetBulkJobsByIdHeaders, + zGetBulkJobsByIdPath, + zGetBulkJobsByIdResponse, + zGetDeletionJobsByJobIdHeaders, + zGetDeletionJobsByJobIdPath, + zGetDeletionJobsByJobIdResponse, + zGetJobsByIdHeaders, + zGetJobsByIdPath, + zGetJobsByIdResponse, + zGetKnowledgeSpacesByIdAccessPolicyHeaders, + zGetKnowledgeSpacesByIdAccessPolicyPath, + zGetKnowledgeSpacesByIdAccessPolicyResponse, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdHeaders, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineHeaders, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdOutlinePath, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineResponse, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdPath, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsHeaders, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsPath, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsResponse, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdHeaders, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdPath, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponse, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksHeaders, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksPath, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksQuery, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksResponse, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdResponse, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdHeaders, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdPath, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdResponse, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksHeaders, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksPath, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksQuery, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksResponse, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsHeaders, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsPath, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsQuery, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsResponse, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsHeaders, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsPath, + zGetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponse, + zGetKnowledgeSpacesByIdDocumentsHeaders, + zGetKnowledgeSpacesByIdDocumentsPath, + zGetKnowledgeSpacesByIdDocumentsQuery, + zGetKnowledgeSpacesByIdDocumentsResponse, + zGetKnowledgeSpacesByIdHeaders, + zGetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdHeaders, + zGetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdPath, + zGetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponse, + zGetKnowledgeSpacesByIdLogicalDocumentsHeaders, + zGetKnowledgeSpacesByIdLogicalDocumentsPath, + zGetKnowledgeSpacesByIdLogicalDocumentsQuery, + zGetKnowledgeSpacesByIdLogicalDocumentsResponse, + zGetKnowledgeSpacesByIdPath, + zGetKnowledgeSpacesByIdProcessingTasksHeaders, + zGetKnowledgeSpacesByIdProcessingTasksPath, + zGetKnowledgeSpacesByIdProcessingTasksQuery, + zGetKnowledgeSpacesByIdProcessingTasksResponse, + zGetKnowledgeSpacesByIdResponse, + zGetKnowledgeSpacesByIdSourceConnectionsByConnectionIdHeaders, + zGetKnowledgeSpacesByIdSourceConnectionsByConnectionIdPath, + zGetKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponse, + zGetKnowledgeSpacesByIdSourceConnectionsHeaders, + zGetKnowledgeSpacesByIdSourceConnectionsPath, + zGetKnowledgeSpacesByIdSourceConnectionsQuery, + zGetKnowledgeSpacesByIdSourceConnectionsResponse, + zGetKnowledgeSpacesByIdSourcesBySourceIdFilesHeaders, + zGetKnowledgeSpacesByIdSourcesBySourceIdFilesPath, + zGetKnowledgeSpacesByIdSourcesBySourceIdFilesQuery, + zGetKnowledgeSpacesByIdSourcesBySourceIdFilesResponse, + zGetKnowledgeSpacesByIdSourcesBySourceIdHeaders, + zGetKnowledgeSpacesByIdSourcesBySourceIdPagesHeaders, + zGetKnowledgeSpacesByIdSourcesBySourceIdPagesPath, + zGetKnowledgeSpacesByIdSourcesBySourceIdPagesQuery, + zGetKnowledgeSpacesByIdSourcesBySourceIdPagesResponse, + zGetKnowledgeSpacesByIdSourcesBySourceIdPath, + zGetKnowledgeSpacesByIdSourcesBySourceIdResponse, + zGetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyHeaders, + zGetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyPath, + zGetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse, + zGetKnowledgeSpacesByIdSourcesHeaders, + zGetKnowledgeSpacesByIdSourcesPath, + zGetKnowledgeSpacesByIdSourcesQuery, + zGetKnowledgeSpacesByIdSourcesResponse, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsHeaders, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsPath, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsQuery, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsResponse, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdHeaders, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesHeaders, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesPath, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesQuery, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPath, + zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdResponse, + zGetKnowledgeSpacesByIdSourceWorkflowsHeaders, + zGetKnowledgeSpacesByIdSourceWorkflowsPath, + zGetKnowledgeSpacesByIdSourceWorkflowsQuery, + zGetKnowledgeSpacesByIdSourceWorkflowsResponse, + zGetKnowledgeSpacesByIdStatsHeaders, + zGetKnowledgeSpacesByIdStatsPath, + zGetKnowledgeSpacesByIdStatsQuery, + zGetKnowledgeSpacesByIdStatsResponse, + zGetSourceProvidersHeaders, + zGetSourceProvidersResponse, + zListKnowledgeSpacesHeaders, + zListKnowledgeSpacesQuery, + zListKnowledgeSpacesResponse, + zPatchKnowledgeSpacesByIdAccessPolicyBody, + zPatchKnowledgeSpacesByIdAccessPolicyHeaders, + zPatchKnowledgeSpacesByIdAccessPolicyPath, + zPatchKnowledgeSpacesByIdAccessPolicyResponse, + zPatchKnowledgeSpacesByIdBody, + zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataBody, + zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataHeaders, + zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataPath, + zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataResponse, + zPatchKnowledgeSpacesByIdHeaders, + zPatchKnowledgeSpacesByIdPath, + zPatchKnowledgeSpacesByIdResponse, + zPatchKnowledgeSpacesByIdSourcesBySourceIdBody, + zPatchKnowledgeSpacesByIdSourcesBySourceIdHeaders, + zPatchKnowledgeSpacesByIdSourcesBySourceIdPath, + zPatchKnowledgeSpacesByIdSourcesBySourceIdResponse, + zPostDeletionJobsByJobIdRetryHeaders, + zPostDeletionJobsByJobIdRetryPath, + zPostDeletionJobsByJobIdRetryResponse, + zPostJobsByIdRetryHeaders, + zPostJobsByIdRetryPath, + zPostJobsByIdRetryResponse, + zPostKnowledgeSpacesByIdDocumentsBody, + zPostKnowledgeSpacesByIdDocumentsBulkBody, + zPostKnowledgeSpacesByIdDocumentsBulkHeaders, + zPostKnowledgeSpacesByIdDocumentsBulkPath, + zPostKnowledgeSpacesByIdDocumentsBulkReindexBody, + zPostKnowledgeSpacesByIdDocumentsBulkReindexHeaders, + zPostKnowledgeSpacesByIdDocumentsBulkReindexPath, + zPostKnowledgeSpacesByIdDocumentsBulkReindexResponse, + zPostKnowledgeSpacesByIdDocumentsBulkResponse, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryHeaders, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryPath, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryResponse, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateBody, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateHeaders, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStatePath, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateResponse, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackBody, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackHeaders, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackPath, + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackResponse, + zPostKnowledgeSpacesByIdDocumentsHeaders, + zPostKnowledgeSpacesByIdDocumentsPath, + zPostKnowledgeSpacesByIdDocumentsResponse, + zPostKnowledgeSpacesByIdSourceConnectionsBody, + zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshBody, + zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshHeaders, + zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshPath, + zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshResponse, + zPostKnowledgeSpacesByIdSourceConnectionsHeaders, + zPostKnowledgeSpacesByIdSourceConnectionsOauthBody, + zPostKnowledgeSpacesByIdSourceConnectionsOauthHeaders, + zPostKnowledgeSpacesByIdSourceConnectionsOauthPath, + zPostKnowledgeSpacesByIdSourceConnectionsOauthResponse, + zPostKnowledgeSpacesByIdSourceConnectionsPath, + zPostKnowledgeSpacesByIdSourceConnectionsResponse, + zPostKnowledgeSpacesByIdSourcesBody, + zPostKnowledgeSpacesByIdSourcesBulkBody, + zPostKnowledgeSpacesByIdSourcesBulkHeaders, + zPostKnowledgeSpacesByIdSourcesBulkPath, + zPostKnowledgeSpacesByIdSourcesBulkResponse, + zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlHeaders, + zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPath, + zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewHeaders, + zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewPath, + zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewResponse, + zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlResponse, + zPostKnowledgeSpacesByIdSourcesBySourceIdImportBody, + zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesBody, + zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesHeaders, + zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesPath, + zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesResponse, + zPostKnowledgeSpacesByIdSourcesBySourceIdImportHeaders, + zPostKnowledgeSpacesByIdSourcesBySourceIdImportPath, + zPostKnowledgeSpacesByIdSourcesBySourceIdImportResponse, + zPostKnowledgeSpacesByIdSourcesBySourceIdSyncHeaders, + zPostKnowledgeSpacesByIdSourcesBySourceIdSyncPath, + zPostKnowledgeSpacesByIdSourcesBySourceIdSyncResponse, + zPostKnowledgeSpacesByIdSourcesBySourceIdTestHeaders, + zPostKnowledgeSpacesByIdSourcesBySourceIdTestPath, + zPostKnowledgeSpacesByIdSourcesBySourceIdTestResponse, + zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsBody, + zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsHeaders, + zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsPath, + zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsResponse, + zPostKnowledgeSpacesByIdSourcesHeaders, + zPostKnowledgeSpacesByIdSourcesPath, + zPostKnowledgeSpacesByIdSourcesResponse, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelBody, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelHeaders, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelPath, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelResponse, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryHeaders, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryPath, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryResponse, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionBody, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionHeaders, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionPath, + zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionResponse, + zPostSourceOauthCallbackBody, + zPostSourceOauthCallbackHeaders, + zPostSourceOauthCallbackResponse, + zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsBody, + zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsHeaders, + zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsPath, + zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponse, + zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsBody, + zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsHeaders, + zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsPath, + zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponse, + zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyBody, + zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyHeaders, + zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyPath, + zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse, +} from './zod.gen' + +export const listKnowledgeSpaces = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'listKnowledgeSpaces', + path: '/knowledge-fs/knowledge-spaces', + tags: ['Knowledge Spaces'], + }) + .input( + z.object({ + headers: zListKnowledgeSpacesHeaders.optional(), + query: zListKnowledgeSpacesQuery.optional(), + }), + ) + .output(zListKnowledgeSpacesResponse) + +export const createKnowledgeSpace = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'createKnowledgeSpace', + path: '/knowledge-fs/knowledge-spaces', + successStatus: 201, + tags: ['Knowledge Spaces'], + }) + .input( + z.object({ body: zCreateKnowledgeSpaceBody, headers: zCreateKnowledgeSpaceHeaders.optional() }), + ) + .output(zCreateKnowledgeSpaceResponse) + +export const deleteKnowledgeSpacesById = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteKnowledgeSpacesById', + path: '/knowledge-fs/knowledge-spaces/{id}', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zDeleteKnowledgeSpacesByIdBody, + headers: zDeleteKnowledgeSpacesByIdHeaders, + params: zDeleteKnowledgeSpacesByIdPath, + }), + ) + .output(zDeleteKnowledgeSpacesByIdResponse) + +export const getKnowledgeSpacesById = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesById', + path: '/knowledge-fs/knowledge-spaces/{id}', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdHeaders.optional(), + params: zGetKnowledgeSpacesByIdPath, + }), + ) + .output(zGetKnowledgeSpacesByIdResponse) + +export const patchKnowledgeSpacesById = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchKnowledgeSpacesById', + path: '/knowledge-fs/knowledge-spaces/{id}', + tags: ['default'], + }) + .input( + z.object({ + body: zPatchKnowledgeSpacesByIdBody, + headers: zPatchKnowledgeSpacesByIdHeaders.optional(), + params: zPatchKnowledgeSpacesByIdPath, + }), + ) + .output(zPatchKnowledgeSpacesByIdResponse) + +export const getKnowledgeSpacesByIdStats = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdStats', + path: '/knowledge-fs/knowledge-spaces/{id}/stats', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdStatsHeaders.optional(), + params: zGetKnowledgeSpacesByIdStatsPath, + query: zGetKnowledgeSpacesByIdStatsQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdStatsResponse) + +export const getKnowledgeSpacesByIdAccessPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdAccessPolicy', + path: '/knowledge-fs/knowledge-spaces/{id}/access-policy', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdAccessPolicyHeaders.optional(), + params: zGetKnowledgeSpacesByIdAccessPolicyPath, + }), + ) + .output(zGetKnowledgeSpacesByIdAccessPolicyResponse) + +export const patchKnowledgeSpacesByIdAccessPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchKnowledgeSpacesByIdAccessPolicy', + path: '/knowledge-fs/knowledge-spaces/{id}/access-policy', + tags: ['default'], + }) + .input( + z.object({ + body: zPatchKnowledgeSpacesByIdAccessPolicyBody, + headers: zPatchKnowledgeSpacesByIdAccessPolicyHeaders.optional(), + params: zPatchKnowledgeSpacesByIdAccessPolicyPath, + }), + ) + .output(zPatchKnowledgeSpacesByIdAccessPolicyResponse) + +export const getSourceProviders = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSourceProviders', + path: '/knowledge-fs/source-providers', + tags: ['default'], + }) + .input(z.object({ headers: zGetSourceProvidersHeaders.optional() })) + .output(zGetSourceProvidersResponse) + +export const getKnowledgeSpacesByIdSourceConnections = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourceConnections', + path: '/knowledge-fs/knowledge-spaces/{id}/source-connections', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourceConnectionsHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourceConnectionsPath, + query: zGetKnowledgeSpacesByIdSourceConnectionsQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdSourceConnectionsResponse) + +export const postKnowledgeSpacesByIdSourceConnections = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourceConnections', + path: '/knowledge-fs/knowledge-spaces/{id}/source-connections', + successStatus: 201, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourceConnectionsBody, + headers: zPostKnowledgeSpacesByIdSourceConnectionsHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourceConnectionsPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourceConnectionsResponse) + +export const postKnowledgeSpacesByIdSourceConnectionsOauth = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourceConnectionsOauth', + path: '/knowledge-fs/knowledge-spaces/{id}/source-connections/oauth', + successStatus: 201, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourceConnectionsOauthBody, + headers: zPostKnowledgeSpacesByIdSourceConnectionsOauthHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourceConnectionsOauthPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourceConnectionsOauthResponse) + +export const postSourceOauthCallback = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSourceOauthCallback', + path: '/knowledge-fs/source-oauth/callback', + tags: ['default'], + }) + .input( + z.object({ + body: zPostSourceOauthCallbackBody, + headers: zPostSourceOauthCallbackHeaders.optional(), + }), + ) + .output(zPostSourceOauthCallbackResponse) + +export const deleteKnowledgeSpacesByIdSourceConnectionsByConnectionId = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteKnowledgeSpacesByIdSourceConnectionsByConnectionId', + path: '/knowledge-fs/knowledge-spaces/{id}/source-connections/{connectionId}', + tags: ['default'], + }) + .input( + z.object({ + headers: zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdHeaders.optional(), + params: zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdPath, + query: zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdQuery, + }), + ) + .output(zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponse) + +export const getKnowledgeSpacesByIdSourceConnectionsByConnectionId = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourceConnectionsByConnectionId', + path: '/knowledge-fs/knowledge-spaces/{id}/source-connections/{connectionId}', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourceConnectionsByConnectionIdHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourceConnectionsByConnectionIdPath, + }), + ) + .output(zGetKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponse) + +export const postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh', + path: '/knowledge-fs/knowledge-spaces/{id}/source-connections/{connectionId}/refresh', + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshBody, + headers: zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshResponse) + +export const getKnowledgeSpacesByIdSources = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSources', + path: '/knowledge-fs/knowledge-spaces/{id}/sources', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourcesHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourcesPath, + query: zGetKnowledgeSpacesByIdSourcesQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdSourcesResponse) + +export const postKnowledgeSpacesByIdSources = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSources', + path: '/knowledge-fs/knowledge-spaces/{id}/sources', + successStatus: 201, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourcesBody, + headers: zPostKnowledgeSpacesByIdSourcesHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourcesPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourcesResponse) + +export const deleteKnowledgeSpacesByIdSourcesBySourceId = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteKnowledgeSpacesByIdSourcesBySourceId', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zDeleteKnowledgeSpacesByIdSourcesBySourceIdBody, + headers: zDeleteKnowledgeSpacesByIdSourcesBySourceIdHeaders, + params: zDeleteKnowledgeSpacesByIdSourcesBySourceIdPath, + query: zDeleteKnowledgeSpacesByIdSourcesBySourceIdQuery.optional(), + }), + ) + .output(zDeleteKnowledgeSpacesByIdSourcesBySourceIdResponse) + +export const getKnowledgeSpacesByIdSourcesBySourceId = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourcesBySourceId', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourcesBySourceIdHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourcesBySourceIdPath, + }), + ) + .output(zGetKnowledgeSpacesByIdSourcesBySourceIdResponse) + +export const patchKnowledgeSpacesByIdSourcesBySourceId = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchKnowledgeSpacesByIdSourcesBySourceId', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}', + tags: ['default'], + }) + .input( + z.object({ + body: zPatchKnowledgeSpacesByIdSourcesBySourceIdBody, + headers: zPatchKnowledgeSpacesByIdSourcesBySourceIdHeaders.optional(), + params: zPatchKnowledgeSpacesByIdSourcesBySourceIdPath, + }), + ) + .output(zPatchKnowledgeSpacesByIdSourcesBySourceIdResponse) + +export const deleteKnowledgeSpacesByIdSourcesBySourceIdCredentials = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteKnowledgeSpacesByIdSourcesBySourceIdCredentials', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/credentials', + tags: ['default'], + }) + .input( + z.object({ + headers: zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsHeaders.optional(), + params: zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsPath, + query: zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsQuery, + }), + ) + .output(zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponse) + +export const putKnowledgeSpacesByIdSourcesBySourceIdCredentials = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putKnowledgeSpacesByIdSourcesBySourceIdCredentials', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/credentials', + tags: ['default'], + }) + .input( + z.object({ + body: zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsBody, + headers: zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsHeaders.optional(), + params: zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsPath, + }), + ) + .output(zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponse) + +export const postKnowledgeSpacesByIdSourcesBySourceIdSync = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourcesBySourceIdSync', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/sync', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + headers: zPostKnowledgeSpacesByIdSourcesBySourceIdSyncHeaders, + params: zPostKnowledgeSpacesByIdSourcesBySourceIdSyncPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourcesBySourceIdSyncResponse) + +export const postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/crawl-preview', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + headers: zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewHeaders, + params: zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewResponse) + +export const postKnowledgeSpacesByIdSourcesBySourceIdWorkflowImports = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourcesBySourceIdWorkflowImports', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/workflow-imports', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsBody, + headers: zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsHeaders, + params: zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsResponse) + +export const getKnowledgeSpacesByIdSourcesBySourceIdPages = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourcesBySourceIdPages', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/pages', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourcesBySourceIdPagesHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourcesBySourceIdPagesPath, + query: zGetKnowledgeSpacesByIdSourcesBySourceIdPagesQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdSourcesBySourceIdPagesResponse) + +export const getKnowledgeSpacesByIdSourcesBySourceIdFiles = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourcesBySourceIdFiles', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/files', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourcesBySourceIdFilesHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourcesBySourceIdFilesPath, + query: zGetKnowledgeSpacesByIdSourcesBySourceIdFilesQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdSourcesBySourceIdFilesResponse) + +export const postKnowledgeSpacesByIdSourcesBySourceIdCrawl = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourcesBySourceIdCrawl', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/crawl', + tags: ['default'], + }) + .input( + z.object({ + headers: zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlResponse) + +export const postKnowledgeSpacesByIdSourcesBySourceIdImport = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourcesBySourceIdImport', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/import', + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourcesBySourceIdImportBody, + headers: zPostKnowledgeSpacesByIdSourcesBySourceIdImportHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourcesBySourceIdImportPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourcesBySourceIdImportResponse) + +export const postKnowledgeSpacesByIdSourcesBySourceIdTest = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourcesBySourceIdTest', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/test', + tags: ['default'], + }) + .input( + z.object({ + headers: zPostKnowledgeSpacesByIdSourcesBySourceIdTestHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourcesBySourceIdTestPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourcesBySourceIdTestResponse) + +export const postKnowledgeSpacesByIdSourcesBySourceIdImportFiles = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourcesBySourceIdImportFiles', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/import-files', + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesBody, + headers: zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesResponse) + +export const postKnowledgeSpacesByIdSourcesBulk = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourcesBulk', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/bulk', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourcesBulkBody, + headers: zPostKnowledgeSpacesByIdSourcesBulkHeaders, + params: zPostKnowledgeSpacesByIdSourcesBulkPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourcesBulkResponse) + +export const getKnowledgeSpacesByIdSourceWorkflows = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourceWorkflows', + path: '/knowledge-fs/knowledge-spaces/{id}/source-workflows', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourceWorkflowsHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourceWorkflowsPath, + query: zGetKnowledgeSpacesByIdSourceWorkflowsQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdSourceWorkflowsResponse) + +export const getKnowledgeSpacesByIdSourceWorkflowsByRunId = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourceWorkflowsByRunId', + path: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPath, + }), + ) + .output(zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdResponse) + +export const getKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItems = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItems', + path: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/bulk-items', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsPath, + query: zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsResponse) + +export const getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages', + path: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/pages', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesPath, + query: zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse) + +export const postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel', + path: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/cancel', + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelBody, + headers: zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelResponse) + +export const postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry', + path: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/retry', + tags: ['default'], + }) + .input( + z.object({ + headers: zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryHeaders.optional(), + params: zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryResponse) + +export const postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection', + path: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/selection', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionBody, + headers: zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionHeaders, + params: zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionPath, + }), + ) + .output(zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionResponse) + +export const getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/sync-policy', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyHeaders.optional(), + params: zGetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyPath, + }), + ) + .output(zGetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse) + +export const putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy', + path: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/sync-policy', + tags: ['default'], + }) + .input( + z.object({ + body: zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyBody, + headers: zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyHeaders.optional(), + params: zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyPath, + }), + ) + .output(zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse) + +export const getKnowledgeSpacesByIdDocuments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocuments', + path: '/knowledge-fs/knowledge-spaces/{id}/documents', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdDocumentsHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsPath, + query: zGetKnowledgeSpacesByIdDocumentsQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdDocumentsResponse) + +export const postKnowledgeSpacesByIdDocuments = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdDocuments', + path: '/knowledge-fs/knowledge-spaces/{id}/documents', + successStatus: 201, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdDocumentsBody, + headers: zPostKnowledgeSpacesByIdDocumentsHeaders.optional(), + params: zPostKnowledgeSpacesByIdDocumentsPath, + }), + ) + .output(zPostKnowledgeSpacesByIdDocumentsResponse) + +export const deleteKnowledgeSpacesByIdDocumentsBulk = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteKnowledgeSpacesByIdDocumentsBulk', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/bulk', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zDeleteKnowledgeSpacesByIdDocumentsBulkBody, + headers: zDeleteKnowledgeSpacesByIdDocumentsBulkHeaders, + params: zDeleteKnowledgeSpacesByIdDocumentsBulkPath, + }), + ) + .output(zDeleteKnowledgeSpacesByIdDocumentsBulkResponse) + +export const postKnowledgeSpacesByIdDocumentsBulk = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdDocumentsBulk', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/bulk', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdDocumentsBulkBody, + headers: zPostKnowledgeSpacesByIdDocumentsBulkHeaders.optional(), + params: zPostKnowledgeSpacesByIdDocumentsBulkPath, + }), + ) + .output(zPostKnowledgeSpacesByIdDocumentsBulkResponse) + +export const postKnowledgeSpacesByIdDocumentsBulkReindex = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdDocumentsBulkReindex', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/bulk/reindex', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdDocumentsBulkReindexBody, + headers: zPostKnowledgeSpacesByIdDocumentsBulkReindexHeaders.optional(), + params: zPostKnowledgeSpacesByIdDocumentsBulkReindexPath, + }), + ) + .output(zPostKnowledgeSpacesByIdDocumentsBulkReindexResponse) + +export const deleteKnowledgeSpacesByIdDocumentsByDocumentId = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteKnowledgeSpacesByIdDocumentsByDocumentId', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdBody, + headers: zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdHeaders, + params: zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdPath, + }), + ) + .output(zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdResponse) + +export const getKnowledgeSpacesByIdDocumentsByDocumentId = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocumentsByDocumentId', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdDocumentsByDocumentIdHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsByDocumentIdPath, + }), + ) + .output(zGetKnowledgeSpacesByIdDocumentsByDocumentIdResponse) + +export const getKnowledgeSpacesByIdLogicalDocuments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdLogicalDocuments', + path: '/knowledge-fs/knowledge-spaces/{id}/logical-documents', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdLogicalDocumentsHeaders.optional(), + params: zGetKnowledgeSpacesByIdLogicalDocumentsPath, + query: zGetKnowledgeSpacesByIdLogicalDocumentsQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdLogicalDocumentsResponse) + +export const deleteKnowledgeSpacesByIdLogicalDocumentsByDocumentId = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteKnowledgeSpacesByIdLogicalDocumentsByDocumentId', + path: '/knowledge-fs/knowledge-spaces/{id}/logical-documents/{documentId}', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdBody, + headers: zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdHeaders, + params: zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdPath, + }), + ) + .output(zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponse) + +export const getKnowledgeSpacesByIdLogicalDocumentsByDocumentId = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdLogicalDocumentsByDocumentId', + path: '/knowledge-fs/knowledge-spaces/{id}/logical-documents/{documentId}', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdHeaders.optional(), + params: zGetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdPath, + }), + ) + .output(zGetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponse) + +export const getKnowledgeSpacesByIdDocumentsByDocumentIdOutline = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocumentsByDocumentIdOutline', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/outline', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsByDocumentIdOutlinePath, + }), + ) + .output(zGetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineResponse) + +export const getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsPath, + query: zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsResponse) + +export const postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollback = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollback', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/rollback', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackBody, + headers: + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackHeaders.optional(), + params: zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackPath, + }), + ) + .output(zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackResponse) + +export const patchKnowledgeSpacesByIdDocumentsByDocumentIdMetadata = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchKnowledgeSpacesByIdDocumentsByDocumentIdMetadata', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/metadata', + tags: ['default'], + }) + .input( + z.object({ + body: zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataBody, + headers: zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataHeaders.optional(), + params: zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataPath, + }), + ) + .output(zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataResponse) + +export const getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunks = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunks', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/chunks', + tags: ['default'], + }) + .input( + z.object({ + headers: + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksPath, + query: zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksResponse) + +export const getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkId = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkId', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/chunks/{chunkId}', + tags: ['default'], + }) + .input( + z.object({ + headers: + zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdPath, + }), + ) + .output(zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdResponse) + +export const postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdState = + oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: + 'postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdState', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/chunks/{chunkId}/state', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateBody, + headers: + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateHeaders.optional(), + params: + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStatePath, + }), + ) + .output( + zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateResponse, + ) + +export const getKnowledgeSpacesByIdProcessingTasks = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdProcessingTasks', + path: '/knowledge-fs/knowledge-spaces/{id}/processing-tasks', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdProcessingTasksHeaders.optional(), + params: zGetKnowledgeSpacesByIdProcessingTasksPath, + query: zGetKnowledgeSpacesByIdProcessingTasksQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdProcessingTasksResponse) + +export const getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasks = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasks', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksPath, + query: zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksQuery.optional(), + }), + ) + .output(zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksResponse) + +export const deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}', + tags: ['default'], + }) + .input( + z.object({ + headers: + zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdHeaders.optional(), + params: zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdPath, + }), + ) + .output(zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponse) + +export const getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}', + tags: ['default'], + }) + .input( + z.object({ + headers: + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdPath, + }), + ) + .output(zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponse) + +export const getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}/events', + tags: ['default'], + }) + .input( + z.object({ + headers: + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsPath, + }), + ) + .output( + eventIterator( + zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsResponse, + ), + ) + +export const postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}/retry', + tags: ['default'], + }) + .input( + z.object({ + headers: + zPostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryHeaders.optional(), + params: zPostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryPath, + }), + ) + .output(zPostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryResponse) + +export const getKnowledgeSpacesByIdDocumentsByDocumentIdSettings = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getKnowledgeSpacesByIdDocumentsByDocumentIdSettings', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/settings', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsHeaders.optional(), + params: zGetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsPath, + }), + ) + .output(zGetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponse) + +export const putKnowledgeSpacesByIdDocumentsByDocumentIdSettings = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putKnowledgeSpacesByIdDocumentsByDocumentIdSettings', + path: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/settings', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + body: zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsBody, + headers: zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsHeaders.optional(), + params: zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsPath, + }), + ) + .output(zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponse) + +export const deleteJobsById = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteJobsById', + path: '/knowledge-fs/jobs/{id}', + tags: ['default'], + }) + .input(z.object({ headers: zDeleteJobsByIdHeaders.optional(), params: zDeleteJobsByIdPath })) + .output(zDeleteJobsByIdResponse) + +export const getJobsById = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getJobsById', + path: '/knowledge-fs/jobs/{id}', + tags: ['default'], + }) + .input(z.object({ headers: zGetJobsByIdHeaders.optional(), params: zGetJobsByIdPath })) + .output(zGetJobsByIdResponse) + +export const postJobsByIdRetry = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postJobsByIdRetry', + path: '/knowledge-fs/jobs/{id}/retry', + tags: ['default'], + }) + .input( + z.object({ headers: zPostJobsByIdRetryHeaders.optional(), params: zPostJobsByIdRetryPath }), + ) + .output(zPostJobsByIdRetryResponse) + +export const getDeletionJobsByJobId = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getDeletionJobsByJobId', + path: '/knowledge-fs/deletion-jobs/{jobId}', + tags: ['default'], + }) + .input( + z.object({ + headers: zGetDeletionJobsByJobIdHeaders.optional(), + params: zGetDeletionJobsByJobIdPath, + }), + ) + .output(zGetDeletionJobsByJobIdResponse) + +export const postDeletionJobsByJobIdRetry = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postDeletionJobsByJobIdRetry', + path: '/knowledge-fs/deletion-jobs/{jobId}/retry', + successStatus: 202, + tags: ['default'], + }) + .input( + z.object({ + headers: zPostDeletionJobsByJobIdRetryHeaders, + params: zPostDeletionJobsByJobIdRetryPath, + }), + ) + .output(zPostDeletionJobsByJobIdRetryResponse) + +export const getBulkJobsById = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getBulkJobsById', + path: '/knowledge-fs/bulk-jobs/{id}', + tags: ['default'], + }) + .input(z.object({ headers: zGetBulkJobsByIdHeaders.optional(), params: zGetBulkJobsByIdPath })) + .output(zGetBulkJobsByIdResponse) + +export const contract = { + listKnowledgeSpaces, + createKnowledgeSpace, + deleteKnowledgeSpacesById, + getKnowledgeSpacesById, + patchKnowledgeSpacesById, + getKnowledgeSpacesByIdStats, + getKnowledgeSpacesByIdAccessPolicy, + patchKnowledgeSpacesByIdAccessPolicy, + getSourceProviders, + getKnowledgeSpacesByIdSourceConnections, + postKnowledgeSpacesByIdSourceConnections, + postKnowledgeSpacesByIdSourceConnectionsOauth, + postSourceOauthCallback, + deleteKnowledgeSpacesByIdSourceConnectionsByConnectionId, + getKnowledgeSpacesByIdSourceConnectionsByConnectionId, + postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh, + getKnowledgeSpacesByIdSources, + postKnowledgeSpacesByIdSources, + deleteKnowledgeSpacesByIdSourcesBySourceId, + getKnowledgeSpacesByIdSourcesBySourceId, + patchKnowledgeSpacesByIdSourcesBySourceId, + deleteKnowledgeSpacesByIdSourcesBySourceIdCredentials, + putKnowledgeSpacesByIdSourcesBySourceIdCredentials, + postKnowledgeSpacesByIdSourcesBySourceIdSync, + postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview, + postKnowledgeSpacesByIdSourcesBySourceIdWorkflowImports, + getKnowledgeSpacesByIdSourcesBySourceIdPages, + getKnowledgeSpacesByIdSourcesBySourceIdFiles, + postKnowledgeSpacesByIdSourcesBySourceIdCrawl, + postKnowledgeSpacesByIdSourcesBySourceIdImport, + postKnowledgeSpacesByIdSourcesBySourceIdTest, + postKnowledgeSpacesByIdSourcesBySourceIdImportFiles, + postKnowledgeSpacesByIdSourcesBulk, + getKnowledgeSpacesByIdSourceWorkflows, + getKnowledgeSpacesByIdSourceWorkflowsByRunId, + getKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItems, + getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages, + postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel, + postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry, + postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection, + getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy, + putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy, + getKnowledgeSpacesByIdDocuments, + postKnowledgeSpacesByIdDocuments, + deleteKnowledgeSpacesByIdDocumentsBulk, + postKnowledgeSpacesByIdDocumentsBulk, + postKnowledgeSpacesByIdDocumentsBulkReindex, + deleteKnowledgeSpacesByIdDocumentsByDocumentId, + getKnowledgeSpacesByIdDocumentsByDocumentId, + getKnowledgeSpacesByIdLogicalDocuments, + deleteKnowledgeSpacesByIdLogicalDocumentsByDocumentId, + getKnowledgeSpacesByIdLogicalDocumentsByDocumentId, + getKnowledgeSpacesByIdDocumentsByDocumentIdOutline, + getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions, + postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollback, + patchKnowledgeSpacesByIdDocumentsByDocumentIdMetadata, + getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunks, + getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkId, + postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdState, + getKnowledgeSpacesByIdProcessingTasks, + getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasks, + deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId, + getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId, + getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents, + postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry, + getKnowledgeSpacesByIdDocumentsByDocumentIdSettings, + putKnowledgeSpacesByIdDocumentsByDocumentIdSettings, + deleteJobsById, + getJobsById, + postJobsByIdRetry, + getDeletionJobsByJobId, + postDeletionJobsByJobIdRetry, + getBulkJobsById, +} diff --git a/packages/contracts/generated/knowledge-fs/types.gen.ts b/packages/contracts/generated/knowledge-fs/types.gen.ts new file mode 100644 index 00000000000..dc7be0b1fa7 --- /dev/null +++ b/packages/contracts/generated/knowledge-fs/types.gen.ts @@ -0,0 +1,3302 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}) +} + +export type KnowledgeSpaceCreationResponse = { + createdAt: string + description?: string + iconRef?: string + id: string + name: string + revision: number + slug: string + tenantId: string + updatedAt: string + configurationStatus: 'pending-validation' | 'ready' | 'setup-required' | 'validation-failed' +} + +export type ErrorResponse = { + code?: string + error: string +} + +export type CreateKnowledgeSpace = { + description?: string + embeddingProfile?: { + model: string + pluginId: string + provider: string + } + iconRef?: string + idempotencyKey?: string + name: string + retrievalProfile?: { + defaultMode: 'fast' | 'research' | 'deep' + reasoningModel: { + model: string + pluginId: string + provider: string + } + rerank: { + enabled: boolean + model?: { + model: string + pluginId: string + provider: string + } + } + scoreThreshold: { + enabled: boolean + stage: 'mode-final' | 'rerank' + value?: number + } + topK: number + } + slug?: string +} + +export type KnowledgeSpace = { + createdAt: string + description?: string + iconRef?: string + id: string + name: string + revision: number + slug: string + tenantId: string + updatedAt: string +} + +export type KnowledgeSpaceList = { + items: Array + nextCursor?: string +} + +export type KnowledgeSpaceStats = { + cache: { + available: boolean + entries: number + totalBytes: number + } + commits: { + failedRetryable: number + failedTerminal: number + sampled: number + truncated: boolean + } + generatedAt: string + knowledgeSpaceId: string + metrics: { + available: boolean + reason?: string + } + projections: { + denseVector: { + building: number + failed: number + ready: number + stale: number + total: number + } + fts: { + building: number + failed: number + ready: number + stale: number + total: number + } + graph: { + building: number + failed: number + ready: number + stale: number + total: number + } + metadata: { + building: number + failed: number + ready: number + stale: number + total: number + } + projectionVersion: number + } + runtime: { + activeLeaseSampleCount: number + activeSessionSampleCount: number + truncated: boolean + } + storage: { + documentCount: number + rawDocumentBytes: number + } + tenantId: string + window: { + end: string + minutes: number + start: string + } +} + +export type DurableDeletionJob = { + checkpoint: + | 'requested' + | 'quiescing' + | 'deleting_objects' + | 'deleting_derived_data' + | 'deleting_primary_data' + | 'completed' + completedAt?: string + createdAt: string + error?: { + code: string + message: string + retryable: boolean + } + id: string + knowledgeSpaceId: string + mode?: 'cascade' | 'keep' + progress?: { + completedItems: number + currentItemKind?: string + totalItems?: number + } + retryAt?: string + runState: + | 'dispatch_pending' + | 'queued' + | 'running' + | 'retry_wait' + | 'completed' + | 'failed' + | 'canceled' + targetId: string + targetType: 'knowledge_space' | 'source' | 'document' | 'logical_document' + updatedAt: string +} + +export type DurableDeletionAccepted = { + job: DurableDeletionJob + statusUrl: string +} + +export type DurableBulkDeletionAccepted = { + items: Array<{ + documentId: string + job: DurableDeletionJob + statusUrl: string + }> + total: number +} + +export type DocumentAsset = { + createdAt: string + filename: string + id: string + knowledgeSpaceId: string + metadata?: { + [key: string]: unknown + } + mimeType: string + objectKey: string + parserStatus: 'pending' | 'parsed' | 'failed' + sha256: string + sizeBytes: number + sourceId?: string + updatedAt?: string + version: number +} + +export type DocumentAssetList = { + items: Array + nextCursor?: string +} + +export type DocumentOutlineNode = { + childNodeIds?: Array + children?: Array<{ + [key: string]: unknown + }> + endOffset?: number + endPage?: number + id: string + level: number + metadata: { + [key: string]: unknown + } + sectionPath?: Array + sourceElementIds?: Array + sourceNodeIds?: Array + startOffset?: number + startPage?: number + summary?: string + title: string + titleLocation?: { + [key: string]: unknown + } + tocSource: string +} + +export type DocumentOutline = { + artifactHash: string + createdAt: string + documentAssetId: string + id: string + knowledgeSpaceId: string + metadata: { + [key: string]: unknown + } + nodes: Array + outlineVersion: string + parseArtifactId: string + updatedAt?: string + version: number +} + +export type LogicalDocumentRevision = { + activatedAt?: string + contentHash: string + createdAt: string + documentAssetId: string + documentAssetVersion: number + documentId: string + knowledgeSpaceId: string + mimeType: string + revision: number + sizeBytes: number + state: 'candidate' | 'active' | 'superseded' | 'failed' +} | null + +export type LogicalDocument = { + active: LogicalDocumentRevision + activeRevision?: number + createdAt: string + id: string + knowledgeSpaceId: string + providerItemId?: string + rowVersion: number + sourceId?: string + status: 'pending' | 'ready' | 'failed' | 'deleting' + title: string + updatedAt: string + userMetadata: { + [key: string]: unknown + } +} + +export type LogicalDocumentList = { + items: Array + nextCursor?: string +} + +export type DocumentRevisionList = { + items: Array< + LogicalDocumentRevision & { + [key: string]: unknown + } + > + nextCursor?: string +} + +export type DocumentProcessingTask = { + completedAt?: string + createdAt: string + documentId: string + documentRevision: number + errorCode?: string + errorMessage?: string + id: string + knowledgeSpaceId: string + progressPercent: number + retryAt?: string + stage: + | 'queued' + | 'parsed' + | 'outline_built' + | 'nodes_generated' + | 'projection_built' + | 'smoke_eval_passed' + | 'published' + state: + | 'dispatch_pending' + | 'queued' + | 'running' + | 'retry_wait' + | 'succeeded' + | 'failed' + | 'canceled' + | 'superseded' + updatedAt: string +} + +export type DocumentRevisionChunk = { + createdAt: string + documentId: string + documentRevision: number + enabled: boolean + id: string + knowledgeSpaceId: string + ordinal: number + parentChunkId?: string + text: string + tokenCount: number + userMetadata: { + [key: string]: unknown + } +} + +export type DocumentChunkList = { + items: Array + nextCursor?: string +} + +export type DocumentChunkStateChangeAccepted = { + candidateFingerprint?: string + candidatePublicationId?: string + chunkId: string + compilationAttemptId: string + createdAt: string + documentId: string + documentRevision: number + enabled: boolean + id: string + knowledgeSpaceId: string + state: 'candidate' + statusUrl: string +} + +export type DocumentProcessingTaskList = { + items: Array + nextCursor?: string +} + +export type DocumentProcessingTaskEvent = + | { + data: { + progressPercent: number + stage: + | 'queued' + | 'parsed' + | 'outline_built' + | 'nodes_generated' + | 'projection_built' + | 'smoke_eval_passed' + | 'published' + state: + | 'dispatch_pending' + | 'queued' + | 'running' + | 'retry_wait' + | 'succeeded' + | 'failed' + | 'canceled' + | 'superseded' + updatedAt: string + } + event: 'progress' + } + | { + data: { + errorCode?: string + state: 'succeeded' | 'failed' | 'canceled' | 'superseded' + } + event: 'terminal' + } + +export type DocumentSettingsHead = { + activeRevision: number + profile: { + activatedAt?: string + createdAt: string + revision: number + settings: { + chunkOverlap: number + chunkSize: number + enableGraph: boolean + enablePageIndex: boolean + language?: string + } + state: 'active' + } + rowVersion: number + updatedAt: string +} + +export type DocumentReindexAccepted = { + attemptId: string + compilationAttemptId: string + settingsRevision: number + state: 'running' + statusUrl: string +} + +export type DocumentCompilationJob = { + baseHeadRevision?: number + candidateFingerprint?: string + candidatePublicationId?: string + completedAt?: number + createdAt: number + documentAssetId: string + error?: string + executionAttempts?: number + id: string + knowledgeSpaceId: string + leaseExpiresAt?: number + maxExecutionAttempts?: number + publicationGenerationId?: string + queueJobId?: string + retryAt?: number + runState?: + | 'dispatch_pending' + | 'queued' + | 'running' + | 'retry_wait' + | 'succeeded' + | 'failed' + | 'canceled' + | 'superseded' + stage: + | 'queued' + | 'parsed' + | 'outline_built' + | 'nodes_generated' + | 'projection_built' + | 'smoke_eval_passed' + | 'published' + | 'failed' + | 'canceled' + tenantId: string + updatedAt: number + version: number +} + +export type BulkOperationProgress = { + completedItems: number + createdAt: string + failedItemIds: Array + failedItems: number + id: string + knowledgeSpaceId: string + status: 'running' | 'completed' | 'failed' + totalItems: number + type: 'document_upload' | 'document_delete' | 'document_reindex' + updatedAt: string +} + +export type BulkDocumentReindexResult = { + bulkJobId: string + items: Array< + | { + asset: DocumentAsset + compilationJob: { + id: string + stage: 'queued' + } + status: 'queued' + statusUrl: string + } + | { + documentId: string + status: 'not_found' + } + > + total: number +} + +export type DocumentUploadAccepted = { + asset: DocumentAsset + assetStatusUrl?: string + compilationJob: { + id: string + stage: 'queued' + } + logicalDocument: { + id: string + revision: number + } + logicalDocumentId: string + documentRevision: number + statusUrl: string + status?: 'accepted' +} + +export type BulkDocumentUploadAccepted = { + accepted: number + bulkJobId: string + excluded: number + items: Array< + | DocumentUploadAccepted + | { + filename: string + index: number + mimeType: string + reason: + | 'batch_byte_limit_exceeded' + | 'document_not_found' + | 'file_count_limit_exceeded' + | 'file_too_large' + | 'invalid_file' + | 'invalid_target' + | 'processing_failed' + | 'quota_exceeded' + | 'revision_conflict' + | 'unsupported_mime_type' + sizeBytes: number + status: 'excluded' + } + > + total: number +} + +export type SourceWorkflowRun = { + canceledAt?: string + checkpoint: string + completedAt?: string + createdAt: string + cursor?: string + executionAttempts: number + id: string + knowledgeSpaceId: string + kind: string + lastErrorCode?: string + maxExecutionAttempts: number + progressCompleted: number + progressFailed: number + progressSkipped: number + progressTotal?: number + sourceId?: string + state: string + updatedAt: string +} + +export type Source = { + connectionId?: string + createdAt: string + id: string + knowledgeSpaceId: string + metadata: { + [key: string]: unknown + } + name: string + permissionScope?: Array + status: 'active' | 'syncing' | 'error' | 'disabled' + type: 'upload' | 'object-storage' | 'connector' | 'web' + updatedAt: string + uri: string + version?: number + credentialConfigured?: boolean +} + +export type WebsiteCrawlResult = { + completed?: number + failed?: number + imported?: number + pages: Array<{ + content: string + description?: string + sourceUrl: string + title?: string + }> + replaced?: number + skipped?: number + status?: string + total?: number +} + +export type OnlineDocumentPages = { + nextCursor?: string + workspaces: Array<{ + pages: Array<{ + lastEditedTime?: string + pageId: string + pageName: string + parentId?: string + type: string + }> + total?: number + workspaceId?: string + workspaceName?: string + }> +} + +export type SourceImportResult = { + documents: Array<{ + documentAssetId: string + filename: string + }> + failed: Array<{ + code: string + error: string + filename: string + }> + skipped: Array +} + +export type SourceCredentialTest = { + code?: string + error?: string + valid: boolean +} + +export type OnlineDriveFiles = { + buckets: Array<{ + bucket?: string + continuationToken?: string + files: Array<{ + id: string + name: string + size?: number + type: string + }> + isTruncated?: boolean + }> +} + +export type ConsoleProxyError = { + code: string + message: string + status: number +} + +export type ListKnowledgeSpacesData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path?: never + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces' +} + +export type ListKnowledgeSpacesErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 502: ConsoleProxyError +} + +export type ListKnowledgeSpacesError = ListKnowledgeSpacesErrors[keyof ListKnowledgeSpacesErrors] + +export type ListKnowledgeSpacesResponses = { + 200: KnowledgeSpaceList +} + +export type ListKnowledgeSpacesResponse = + ListKnowledgeSpacesResponses[keyof ListKnowledgeSpacesResponses] + +export type CreateKnowledgeSpaceData = { + body: CreateKnowledgeSpace + headers?: { + 'x-trace-id'?: string + } + path?: never + query?: never + url: '/knowledge-fs/knowledge-spaces' +} + +export type CreateKnowledgeSpaceErrors = { + 400: + | { + code: 'RETRIEVAL_PROFILE_SCORE_THRESHOLD_REQUIRES_RERANK' + error: 'Fast/Deep mode-final score threshold requires the knowledge-space reranker to be enabled' + mode: 'fast' | 'research' | 'deep' + } + | ErrorResponse + 403: ConsoleProxyError + 409: ErrorResponse + 422: ErrorResponse + 429: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type CreateKnowledgeSpaceError = CreateKnowledgeSpaceErrors[keyof CreateKnowledgeSpaceErrors] + +export type CreateKnowledgeSpaceResponses = { + 201: KnowledgeSpaceCreationResponse +} + +export type CreateKnowledgeSpaceResponse = + CreateKnowledgeSpaceResponses[keyof CreateKnowledgeSpaceResponses] + +export type DeleteKnowledgeSpacesByIdData = { + body: { + challenge: string + expectedRevision: number + } + headers: { + 'idempotency-key': string + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}' +} + +export type DeleteKnowledgeSpacesByIdErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type DeleteKnowledgeSpacesByIdError = + DeleteKnowledgeSpacesByIdErrors[keyof DeleteKnowledgeSpacesByIdErrors] + +export type DeleteKnowledgeSpacesByIdResponses = { + 202: DurableDeletionAccepted +} + +export type DeleteKnowledgeSpacesByIdResponse = + DeleteKnowledgeSpacesByIdResponses[keyof DeleteKnowledgeSpacesByIdResponses] + +export type GetKnowledgeSpacesByIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}' +} + +export type GetKnowledgeSpacesByIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdError = + GetKnowledgeSpacesByIdErrors[keyof GetKnowledgeSpacesByIdErrors] + +export type GetKnowledgeSpacesByIdResponses = { + 200: KnowledgeSpace +} + +export type GetKnowledgeSpacesByIdResponse = + GetKnowledgeSpacesByIdResponses[keyof GetKnowledgeSpacesByIdResponses] + +export type PatchKnowledgeSpacesByIdData = { + body: { + description?: string + expectedRevision: number + iconRef?: string | null + name?: string + slug?: string + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}' +} + +export type PatchKnowledgeSpacesByIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PatchKnowledgeSpacesByIdError = + PatchKnowledgeSpacesByIdErrors[keyof PatchKnowledgeSpacesByIdErrors] + +export type PatchKnowledgeSpacesByIdResponses = { + 200: KnowledgeSpace +} + +export type PatchKnowledgeSpacesByIdResponse = + PatchKnowledgeSpacesByIdResponses[keyof PatchKnowledgeSpacesByIdResponses] + +export type GetKnowledgeSpacesByIdStatsData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: { + windowMinutes?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/stats' +} + +export type GetKnowledgeSpacesByIdStatsErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdStatsError = + GetKnowledgeSpacesByIdStatsErrors[keyof GetKnowledgeSpacesByIdStatsErrors] + +export type GetKnowledgeSpacesByIdStatsResponses = { + 200: KnowledgeSpaceStats +} + +export type GetKnowledgeSpacesByIdStatsResponse = + GetKnowledgeSpacesByIdStatsResponses[keyof GetKnowledgeSpacesByIdStatsResponses] + +export type GetKnowledgeSpacesByIdAccessPolicyData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/access-policy' +} + +export type GetKnowledgeSpacesByIdAccessPolicyErrors = { + 400: ErrorResponse & { + [key: string]: unknown + } + 403: ConsoleProxyError + 404: ErrorResponse & { + [key: string]: unknown + } + 409: ErrorResponse & { + [key: string]: unknown + } + 429: ErrorResponse & { + [key: string]: unknown + } + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdAccessPolicyError = + GetKnowledgeSpacesByIdAccessPolicyErrors[keyof GetKnowledgeSpacesByIdAccessPolicyErrors] + +export type GetKnowledgeSpacesByIdAccessPolicyResponses = { + 200: { + id: string + ownerSubjectId: string + partialMemberSubjectIds: Array + revision: number + visibility: 'only_me' | 'all_members' | 'partial_members' + } +} + +export type GetKnowledgeSpacesByIdAccessPolicyResponse = + GetKnowledgeSpacesByIdAccessPolicyResponses[keyof GetKnowledgeSpacesByIdAccessPolicyResponses] + +export type PatchKnowledgeSpacesByIdAccessPolicyData = { + body: { + expectedRevision: number + partialMemberSubjectIds?: Array + visibility: 'only_me' | 'all_members' | 'partial_members' + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/access-policy' +} + +export type PatchKnowledgeSpacesByIdAccessPolicyErrors = { + 400: ErrorResponse & { + [key: string]: unknown + } + 403: ConsoleProxyError + 404: ErrorResponse & { + [key: string]: unknown + } + 409: ErrorResponse & { + [key: string]: unknown + } + 429: ErrorResponse & { + [key: string]: unknown + } + 502: ConsoleProxyError +} + +export type PatchKnowledgeSpacesByIdAccessPolicyError = + PatchKnowledgeSpacesByIdAccessPolicyErrors[keyof PatchKnowledgeSpacesByIdAccessPolicyErrors] + +export type PatchKnowledgeSpacesByIdAccessPolicyResponses = { + 200: { + id: string + ownerSubjectId: string + partialMemberSubjectIds: Array + revision: number + visibility: 'only_me' | 'all_members' | 'partial_members' + } +} + +export type PatchKnowledgeSpacesByIdAccessPolicyResponse = + PatchKnowledgeSpacesByIdAccessPolicyResponses[keyof PatchKnowledgeSpacesByIdAccessPolicyResponses] + +export type GetSourceProvidersData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path?: never + query?: never + url: '/knowledge-fs/source-providers' +} + +export type GetSourceProvidersErrors = { + 403: ConsoleProxyError + 502: ConsoleProxyError +} + +export type GetSourceProvidersError = GetSourceProvidersErrors[keyof GetSourceProvidersErrors] + +export type GetSourceProvidersResponses = { + 200: { + items: Array<{ + authKinds: Array<'api-key' | 'endpoint' | 'oauth2'> + available: boolean + capabilities: Array<'website-crawl' | 'online-document' | 'online-drive'> + configuration: Array<{ + description?: string + format?: 'password' | 'uri' + name: string + required: boolean + secret: boolean + type: 'boolean' | 'integer' | 'string' + }> + displayName: string + id: string + unavailableReason?: string + }> + } +} + +export type GetSourceProvidersResponse = + GetSourceProvidersResponses[keyof GetSourceProvidersResponses] + +export type GetKnowledgeSpacesByIdSourceConnectionsData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/source-connections' +} + +export type GetKnowledgeSpacesByIdSourceConnectionsErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourceConnectionsError = + GetKnowledgeSpacesByIdSourceConnectionsErrors[keyof GetKnowledgeSpacesByIdSourceConnectionsErrors] + +export type GetKnowledgeSpacesByIdSourceConnectionsResponses = { + 200: { + items: Array<{ + authKind: 'api-key' | 'endpoint' | 'oauth2' + configuration: { + [key: string]: boolean | number | string + } + createdAt: string + errorCode?: string + expiresAt?: string + id: string + knowledgeSpaceId: string + name: string + providerId: string + scopes: Array + status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked' + updatedAt: string + version: number + }> + nextCursor?: string + } +} + +export type GetKnowledgeSpacesByIdSourceConnectionsResponse = + GetKnowledgeSpacesByIdSourceConnectionsResponses[keyof GetKnowledgeSpacesByIdSourceConnectionsResponses] + +export type PostKnowledgeSpacesByIdSourceConnectionsData = { + body: { + authKind: 'api-key' | 'endpoint' + configuration?: { + [key: string]: boolean | number | string + } + credentials: { + [key: string]: unknown + } + name: string + providerId: string + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/source-connections' +} + +export type PostKnowledgeSpacesByIdSourceConnectionsErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ErrorResponse | ConsoleProxyError + 503: ErrorResponse +} + +export type PostKnowledgeSpacesByIdSourceConnectionsError = + PostKnowledgeSpacesByIdSourceConnectionsErrors[keyof PostKnowledgeSpacesByIdSourceConnectionsErrors] + +export type PostKnowledgeSpacesByIdSourceConnectionsResponses = { + 201: { + authKind: 'api-key' | 'endpoint' | 'oauth2' + configuration: { + [key: string]: boolean | number | string + } + createdAt: string + errorCode?: string + expiresAt?: string + id: string + knowledgeSpaceId: string + name: string + providerId: string + scopes: Array + status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked' + updatedAt: string + version: number + } +} + +export type PostKnowledgeSpacesByIdSourceConnectionsResponse = + PostKnowledgeSpacesByIdSourceConnectionsResponses[keyof PostKnowledgeSpacesByIdSourceConnectionsResponses] + +export type PostKnowledgeSpacesByIdSourceConnectionsOauthData = { + body: { + configuration?: { + [key: string]: boolean | number | string + } + name: string + providerId: string + redirectUri: string + scopes?: Array + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/source-connections/oauth' +} + +export type PostKnowledgeSpacesByIdSourceConnectionsOauthErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 502: ErrorResponse | ConsoleProxyError + 503: ErrorResponse +} + +export type PostKnowledgeSpacesByIdSourceConnectionsOauthError = + PostKnowledgeSpacesByIdSourceConnectionsOauthErrors[keyof PostKnowledgeSpacesByIdSourceConnectionsOauthErrors] + +export type PostKnowledgeSpacesByIdSourceConnectionsOauthResponses = { + 201: { + authorizationUrl: string + connection: { + authKind: 'api-key' | 'endpoint' | 'oauth2' + configuration: { + [key: string]: boolean | number | string + } + createdAt: string + errorCode?: string + expiresAt?: string + id: string + knowledgeSpaceId: string + name: string + providerId: string + scopes: Array + status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked' + updatedAt: string + version: number + } + } +} + +export type PostKnowledgeSpacesByIdSourceConnectionsOauthResponse = + PostKnowledgeSpacesByIdSourceConnectionsOauthResponses[keyof PostKnowledgeSpacesByIdSourceConnectionsOauthResponses] + +export type PostSourceOauthCallbackData = { + body: { + code: string + state: string + } + headers?: { + 'x-trace-id'?: string + } + path?: never + query?: never + url: '/knowledge-fs/source-oauth/callback' +} + +export type PostSourceOauthCallbackErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 409: ErrorResponse + 502: ErrorResponse | ConsoleProxyError + 503: ErrorResponse +} + +export type PostSourceOauthCallbackError = + PostSourceOauthCallbackErrors[keyof PostSourceOauthCallbackErrors] + +export type PostSourceOauthCallbackResponses = { + 200: { + authKind: 'api-key' | 'endpoint' | 'oauth2' + configuration: { + [key: string]: boolean | number | string + } + createdAt: string + errorCode?: string + expiresAt?: string + id: string + knowledgeSpaceId: string + name: string + providerId: string + scopes: Array + status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked' + updatedAt: string + version: number + } +} + +export type PostSourceOauthCallbackResponse = + PostSourceOauthCallbackResponses[keyof PostSourceOauthCallbackResponses] + +export type DeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + connectionId: string + } + query: { + expectedVersion: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/source-connections/{connectionId}' +} + +export type DeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type DeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdError = + DeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdErrors[keyof DeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdErrors] + +export type DeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponses = { + 200: { + authKind: 'api-key' | 'endpoint' | 'oauth2' + configuration: { + [key: string]: boolean | number | string + } + createdAt: string + errorCode?: string + expiresAt?: string + id: string + knowledgeSpaceId: string + name: string + providerId: string + scopes: Array + status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked' + updatedAt: string + version: number + } +} + +export type DeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponse = + DeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponses[keyof DeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponses] + +export type GetKnowledgeSpacesByIdSourceConnectionsByConnectionIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + connectionId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/source-connections/{connectionId}' +} + +export type GetKnowledgeSpacesByIdSourceConnectionsByConnectionIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourceConnectionsByConnectionIdError = + GetKnowledgeSpacesByIdSourceConnectionsByConnectionIdErrors[keyof GetKnowledgeSpacesByIdSourceConnectionsByConnectionIdErrors] + +export type GetKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponses = { + 200: { + authKind: 'api-key' | 'endpoint' | 'oauth2' + configuration: { + [key: string]: boolean | number | string + } + createdAt: string + errorCode?: string + expiresAt?: string + id: string + knowledgeSpaceId: string + name: string + providerId: string + scopes: Array + status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked' + updatedAt: string + version: number + } +} + +export type GetKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponse = + GetKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponses[keyof GetKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponses] + +export type PostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshData = { + body: { + expectedVersion: number + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + connectionId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/source-connections/{connectionId}/refresh' +} + +export type PostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ErrorResponse | ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshError = + PostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshErrors[keyof PostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshErrors] + +export type PostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshResponses = { + 200: { + authKind: 'api-key' | 'endpoint' | 'oauth2' + configuration: { + [key: string]: boolean | number | string + } + createdAt: string + errorCode?: string + expiresAt?: string + id: string + knowledgeSpaceId: string + name: string + providerId: string + scopes: Array + status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked' + updatedAt: string + version: number + } +} + +export type PostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshResponse = + PostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshResponses[keyof PostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshResponses] + +export type GetKnowledgeSpacesByIdSourcesData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/sources' +} + +export type GetKnowledgeSpacesByIdSourcesErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError + 503: { + code: 'CANDIDATE_VISIBILITY_SCAN_BUDGET_EXCEEDED' + error: 'Candidate visibility scan budget exceeded' + } +} + +export type GetKnowledgeSpacesByIdSourcesError = + GetKnowledgeSpacesByIdSourcesErrors[keyof GetKnowledgeSpacesByIdSourcesErrors] + +export type GetKnowledgeSpacesByIdSourcesResponses = { + 200: { + items: Array + nextCursor?: string + } +} + +export type GetKnowledgeSpacesByIdSourcesResponse = + GetKnowledgeSpacesByIdSourcesResponses[keyof GetKnowledgeSpacesByIdSourcesResponses] + +export type PostKnowledgeSpacesByIdSourcesData = { + body: { + connectionId?: string + credentials?: { + [key: string]: unknown + } + metadata?: { + [key: string]: unknown + } + name: string + permissionScope?: Array + status?: 'active' | 'syncing' | 'error' | 'disabled' + type: 'upload' | 'object-storage' | 'connector' | 'web' + uri: string + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources' +} + +export type PostKnowledgeSpacesByIdSourcesErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 429: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type PostKnowledgeSpacesByIdSourcesError = + PostKnowledgeSpacesByIdSourcesErrors[keyof PostKnowledgeSpacesByIdSourcesErrors] + +export type PostKnowledgeSpacesByIdSourcesResponses = { + 201: Source +} + +export type PostKnowledgeSpacesByIdSourcesResponse = + PostKnowledgeSpacesByIdSourcesResponses[keyof PostKnowledgeSpacesByIdSourcesResponses] + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdData = { + body: { + expectedRevision: number + } + headers: { + 'idempotency-key': string + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: { + documents?: 'cascade' | 'keep' + } + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}' +} + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdError = + DeleteKnowledgeSpacesByIdSourcesBySourceIdErrors[keyof DeleteKnowledgeSpacesByIdSourcesBySourceIdErrors] + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdResponses = { + 202: DurableDeletionAccepted +} + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdResponse = + DeleteKnowledgeSpacesByIdSourcesBySourceIdResponses[keyof DeleteKnowledgeSpacesByIdSourcesBySourceIdResponses] + +export type GetKnowledgeSpacesByIdSourcesBySourceIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}' +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdError = + GetKnowledgeSpacesByIdSourcesBySourceIdErrors[keyof GetKnowledgeSpacesByIdSourcesBySourceIdErrors] + +export type GetKnowledgeSpacesByIdSourcesBySourceIdResponses = { + 200: Source +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdResponse = + GetKnowledgeSpacesByIdSourcesBySourceIdResponses[keyof GetKnowledgeSpacesByIdSourcesBySourceIdResponses] + +export type PatchKnowledgeSpacesByIdSourcesBySourceIdData = { + body: { + expectedVersion?: number + metadata?: { + [key: string]: unknown + } + name?: string + status?: 'active' | 'syncing' | 'error' | 'disabled' + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}' +} + +export type PatchKnowledgeSpacesByIdSourcesBySourceIdErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PatchKnowledgeSpacesByIdSourcesBySourceIdError = + PatchKnowledgeSpacesByIdSourcesBySourceIdErrors[keyof PatchKnowledgeSpacesByIdSourcesBySourceIdErrors] + +export type PatchKnowledgeSpacesByIdSourcesBySourceIdResponses = { + 200: Source +} + +export type PatchKnowledgeSpacesByIdSourcesBySourceIdResponse = + PatchKnowledgeSpacesByIdSourcesBySourceIdResponses[keyof PatchKnowledgeSpacesByIdSourcesBySourceIdResponses] + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query: { + expectedVersion: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/credentials' +} + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsError = + DeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsErrors[keyof DeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsErrors] + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponses = { + 200: Source +} + +export type DeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponse = + DeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponses[keyof DeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponses] + +export type PutKnowledgeSpacesByIdSourcesBySourceIdCredentialsData = { + body: { + credentials: { + [key: string]: unknown + } + expectedVersion: number + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/credentials' +} + +export type PutKnowledgeSpacesByIdSourcesBySourceIdCredentialsErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type PutKnowledgeSpacesByIdSourcesBySourceIdCredentialsError = + PutKnowledgeSpacesByIdSourcesBySourceIdCredentialsErrors[keyof PutKnowledgeSpacesByIdSourcesBySourceIdCredentialsErrors] + +export type PutKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponses = { + 200: Source +} + +export type PutKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponse = + PutKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponses[keyof PutKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponses] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdSyncData = { + body?: never + headers: { + 'Idempotency-Key': string + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/sync' +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdSyncErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdSyncError = + PostKnowledgeSpacesByIdSourcesBySourceIdSyncErrors[keyof PostKnowledgeSpacesByIdSourcesBySourceIdSyncErrors] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdSyncResponses = { + 202: SourceWorkflowRun +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdSyncResponse = + PostKnowledgeSpacesByIdSourcesBySourceIdSyncResponses[keyof PostKnowledgeSpacesByIdSourcesBySourceIdSyncResponses] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewData = { + body?: never + headers: { + 'Idempotency-Key': string + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/crawl-preview' +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewError = + PostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewErrors[keyof PostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewErrors] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewResponses = { + 202: SourceWorkflowRun +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewResponse = + PostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewResponses[keyof PostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewResponses] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsData = { + body: + | { + items: Array<{ + etag?: string + lastEditedTime?: string + name?: string + pageId: string + providerItemId: string + type: string + workspaceId: string + }> + kind: 'online-document-import' + } + | { + items: Array<{ + bucket?: string + etag?: string + id: string + mimeType?: string + name: string + providerItemId: string + }> + kind: 'online-drive-import' + } + headers: { + 'Idempotency-Key': string + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/workflow-imports' +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsError = + PostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsErrors[keyof PostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsErrors] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsResponses = { + 202: SourceWorkflowRun +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsResponse = + PostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsResponses[keyof PostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsResponses] + +export type GetKnowledgeSpacesByIdSourcesBySourceIdPagesData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/pages' +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdPagesErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 501: ErrorResponse + 502: ErrorResponse | ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdPagesError = + GetKnowledgeSpacesByIdSourcesBySourceIdPagesErrors[keyof GetKnowledgeSpacesByIdSourcesBySourceIdPagesErrors] + +export type GetKnowledgeSpacesByIdSourcesBySourceIdPagesResponses = { + 200: OnlineDocumentPages +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdPagesResponse = + GetKnowledgeSpacesByIdSourcesBySourceIdPagesResponses[keyof GetKnowledgeSpacesByIdSourcesBySourceIdPagesResponses] + +export type GetKnowledgeSpacesByIdSourcesBySourceIdFilesData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: { + bucket?: string + continuationToken?: string + maxKeys?: number + prefix?: string + } + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/files' +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdFilesErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 501: ErrorResponse + 502: ErrorResponse | ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdFilesError = + GetKnowledgeSpacesByIdSourcesBySourceIdFilesErrors[keyof GetKnowledgeSpacesByIdSourcesBySourceIdFilesErrors] + +export type GetKnowledgeSpacesByIdSourcesBySourceIdFilesResponses = { + 200: OnlineDriveFiles +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdFilesResponse = + GetKnowledgeSpacesByIdSourcesBySourceIdFilesResponses[keyof GetKnowledgeSpacesByIdSourcesBySourceIdFilesResponses] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/crawl' +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 501: ErrorResponse + 502: ErrorResponse | ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlError = + PostKnowledgeSpacesByIdSourcesBySourceIdCrawlErrors[keyof PostKnowledgeSpacesByIdSourcesBySourceIdCrawlErrors] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlResponses = { + 200: WebsiteCrawlResult +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdCrawlResponse = + PostKnowledgeSpacesByIdSourcesBySourceIdCrawlResponses[keyof PostKnowledgeSpacesByIdSourcesBySourceIdCrawlResponses] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportData = { + body: { + pages: Array<{ + lastEditedTime?: string + name?: string + pageId: string + type: string + workspaceId: string + }> + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/import' +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 501: ErrorResponse + 502: ErrorResponse | ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportError = + PostKnowledgeSpacesByIdSourcesBySourceIdImportErrors[keyof PostKnowledgeSpacesByIdSourcesBySourceIdImportErrors] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportResponses = { + 200: SourceImportResult +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportResponse = + PostKnowledgeSpacesByIdSourcesBySourceIdImportResponses[keyof PostKnowledgeSpacesByIdSourcesBySourceIdImportResponses] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdTestData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/test' +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdTestErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 501: ErrorResponse + 502: ErrorResponse | ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdTestError = + PostKnowledgeSpacesByIdSourcesBySourceIdTestErrors[keyof PostKnowledgeSpacesByIdSourcesBySourceIdTestErrors] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdTestResponses = { + 200: SourceCredentialTest +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdTestResponse = + PostKnowledgeSpacesByIdSourcesBySourceIdTestResponses[keyof PostKnowledgeSpacesByIdSourcesBySourceIdTestResponses] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportFilesData = { + body: { + files: Array<{ + bucket?: string + id: string + mimeType?: string + name: string + }> + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/import-files' +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportFilesErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 501: ErrorResponse + 502: ErrorResponse | ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportFilesError = + PostKnowledgeSpacesByIdSourcesBySourceIdImportFilesErrors[keyof PostKnowledgeSpacesByIdSourcesBySourceIdImportFilesErrors] + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportFilesResponses = { + 200: SourceImportResult +} + +export type PostKnowledgeSpacesByIdSourcesBySourceIdImportFilesResponse = + PostKnowledgeSpacesByIdSourcesBySourceIdImportFilesResponses[keyof PostKnowledgeSpacesByIdSourcesBySourceIdImportFilesResponses] + +export type PostKnowledgeSpacesByIdSourcesBulkData = { + body: { + action: 'sync' | 'disable' | 'remove' + sourceIds: Array + } + headers: { + 'Idempotency-Key': string + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/bulk' +} + +export type PostKnowledgeSpacesByIdSourcesBulkErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourcesBulkError = + PostKnowledgeSpacesByIdSourcesBulkErrors[keyof PostKnowledgeSpacesByIdSourcesBulkErrors] + +export type PostKnowledgeSpacesByIdSourcesBulkResponses = { + 202: SourceWorkflowRun +} + +export type PostKnowledgeSpacesByIdSourcesBulkResponse = + PostKnowledgeSpacesByIdSourcesBulkResponses[keyof PostKnowledgeSpacesByIdSourcesBulkResponses] + +export type GetKnowledgeSpacesByIdSourceWorkflowsData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: { + cursor?: string + limit?: number + sourceId?: string + } + url: '/knowledge-fs/knowledge-spaces/{id}/source-workflows' +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsError = + GetKnowledgeSpacesByIdSourceWorkflowsErrors[keyof GetKnowledgeSpacesByIdSourceWorkflowsErrors] + +export type GetKnowledgeSpacesByIdSourceWorkflowsResponses = { + 200: { + items: Array + nextCursor?: string + } +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsResponse = + GetKnowledgeSpacesByIdSourceWorkflowsResponses[keyof GetKnowledgeSpacesByIdSourceWorkflowsResponses] + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + runId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}' +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdError = + GetKnowledgeSpacesByIdSourceWorkflowsByRunIdErrors[keyof GetKnowledgeSpacesByIdSourceWorkflowsByRunIdErrors] + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdResponses = { + 200: SourceWorkflowRun +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdResponse = + GetKnowledgeSpacesByIdSourceWorkflowsByRunIdResponses[keyof GetKnowledgeSpacesByIdSourceWorkflowsByRunIdResponses] + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + runId: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/bulk-items' +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsError = + GetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsErrors[keyof GetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsErrors] + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsResponses = { + 200: { + items: Array<{ + action: 'sync' | 'disable' | 'remove' + errorCode?: string + id: string + reason?: string + sourceId: string + status: 'eligible' | 'running' | 'skipped' | 'failed' | 'completed' + updatedAt: string + }> + nextCursor?: string + } +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsResponse = + GetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsResponses[keyof GetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsResponses] + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + runId: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/pages' +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesError = + GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesErrors[keyof GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesErrors] + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponses = { + 200: { + items: Array<{ + description?: string + etag?: string + pageId: string + sourceUrl: string + title?: string + }> + nextCursor?: string + } +} + +export type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse = + GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponses[keyof GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponses] + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelData = { + body: { + reason?: string + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + runId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/cancel' +} + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelError = + PostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelErrors[keyof PostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelErrors] + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelResponses = { + 200: SourceWorkflowRun +} + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelResponse = + PostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelResponses[keyof PostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelResponses] + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + runId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/retry' +} + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryError = + PostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryErrors[keyof PostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryErrors] + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryResponses = { + 200: SourceWorkflowRun +} + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryResponse = + PostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryResponses[keyof PostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryResponses] + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionData = { + body: { + pageIds: Array + } + headers: { + 'Idempotency-Key': string + 'x-trace-id'?: string + } + path: { + id: string + runId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/source-workflows/{runId}/selection' +} + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionError = + PostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionErrors[keyof PostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionErrors] + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionResponses = { + 202: SourceWorkflowRun +} + +export type PostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionResponse = + PostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionResponses[keyof PostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionResponses] + +export type GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/sync-policy' +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyError = + GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyErrors[keyof GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyErrors] + +export type GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponses = { + 200: { + createdAt: string + customIntervalSeconds?: number + enabled: boolean + expectedSourceVersion: number + id: string + knowledgeSpaceId: string + mode: 'provider' | 'manual' | 'interval' | 'custom' + nextRunAt?: string + revision: number + sourceId: string + updatedAt: string + } +} + +export type GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse = + GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponses[keyof GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponses] + +export type PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyData = { + body: { + customIntervalSeconds?: number + enabled: boolean + expectedRevision: number + expectedSourceVersion: number + mode: 'provider' | 'manual' | 'interval' | 'custom' + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + sourceId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/sources/{sourceId}/sync-policy' +} + +export type PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyError = + PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyErrors[keyof PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyErrors] + +export type PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponses = { + 200: { + createdAt: string + customIntervalSeconds?: number + enabled: boolean + expectedSourceVersion: number + id: string + knowledgeSpaceId: string + mode: 'provider' | 'manual' | 'interval' | 'custom' + nextRunAt?: string + revision: number + sourceId: string + updatedAt: string + } +} + +export type PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse = + PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponses[keyof PutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponses] + +export type GetKnowledgeSpacesByIdDocumentsData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/documents' +} + +export type GetKnowledgeSpacesByIdDocumentsErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError + 503: { + code: 'CANDIDATE_VISIBILITY_SCAN_BUDGET_EXCEEDED' + error: 'Candidate visibility scan budget exceeded' + } +} + +export type GetKnowledgeSpacesByIdDocumentsError = + GetKnowledgeSpacesByIdDocumentsErrors[keyof GetKnowledgeSpacesByIdDocumentsErrors] + +export type GetKnowledgeSpacesByIdDocumentsResponses = { + 200: DocumentAssetList +} + +export type GetKnowledgeSpacesByIdDocumentsResponse = + GetKnowledgeSpacesByIdDocumentsResponses[keyof GetKnowledgeSpacesByIdDocumentsResponses] + +export type PostKnowledgeSpacesByIdDocumentsData = { + body: { + documentId?: string + expectedActiveRevision?: number | 'null' + expectedDocumentRowVersion?: number | null + file: Blob | File + sourceId?: string + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents' +} + +export type PostKnowledgeSpacesByIdDocumentsErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 413: ErrorResponse + 429: ErrorResponse + 500: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type PostKnowledgeSpacesByIdDocumentsError = + PostKnowledgeSpacesByIdDocumentsErrors[keyof PostKnowledgeSpacesByIdDocumentsErrors] + +export type PostKnowledgeSpacesByIdDocumentsResponses = { + 201: DocumentAsset + 202: DocumentUploadAccepted +} + +export type PostKnowledgeSpacesByIdDocumentsResponse = + PostKnowledgeSpacesByIdDocumentsResponses[keyof PostKnowledgeSpacesByIdDocumentsResponses] + +export type DeleteKnowledgeSpacesByIdDocumentsBulkData = { + body: { + documents: Array<{ + documentId: string + expectedRevision: number + }> + } + headers: { + 'idempotency-key': string + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/bulk' +} + +export type DeleteKnowledgeSpacesByIdDocumentsBulkErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type DeleteKnowledgeSpacesByIdDocumentsBulkError = + DeleteKnowledgeSpacesByIdDocumentsBulkErrors[keyof DeleteKnowledgeSpacesByIdDocumentsBulkErrors] + +export type DeleteKnowledgeSpacesByIdDocumentsBulkResponses = { + 202: DurableBulkDeletionAccepted +} + +export type DeleteKnowledgeSpacesByIdDocumentsBulkResponse = + DeleteKnowledgeSpacesByIdDocumentsBulkResponses[keyof DeleteKnowledgeSpacesByIdDocumentsBulkResponses] + +export type PostKnowledgeSpacesByIdDocumentsBulkData = { + body: { + files: Array + targets?: string + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/bulk' +} + +export type PostKnowledgeSpacesByIdDocumentsBulkErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 413: ErrorResponse + 429: ErrorResponse + 500: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type PostKnowledgeSpacesByIdDocumentsBulkError = + PostKnowledgeSpacesByIdDocumentsBulkErrors[keyof PostKnowledgeSpacesByIdDocumentsBulkErrors] + +export type PostKnowledgeSpacesByIdDocumentsBulkResponses = { + 202: BulkDocumentUploadAccepted +} + +export type PostKnowledgeSpacesByIdDocumentsBulkResponse = + PostKnowledgeSpacesByIdDocumentsBulkResponses[keyof PostKnowledgeSpacesByIdDocumentsBulkResponses] + +export type PostKnowledgeSpacesByIdDocumentsBulkReindexData = { + body: { + all?: boolean + documentIds?: Array + } + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/bulk/reindex' +} + +export type PostKnowledgeSpacesByIdDocumentsBulkReindexErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 413: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type PostKnowledgeSpacesByIdDocumentsBulkReindexError = + PostKnowledgeSpacesByIdDocumentsBulkReindexErrors[keyof PostKnowledgeSpacesByIdDocumentsBulkReindexErrors] + +export type PostKnowledgeSpacesByIdDocumentsBulkReindexResponses = { + 202: BulkDocumentReindexResult +} + +export type PostKnowledgeSpacesByIdDocumentsBulkReindexResponse = + PostKnowledgeSpacesByIdDocumentsBulkReindexResponses[keyof PostKnowledgeSpacesByIdDocumentsBulkReindexResponses] + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdData = { + body: { + expectedRevision: number + } + headers: { + 'idempotency-key': string + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}' +} + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdError = + DeleteKnowledgeSpacesByIdDocumentsByDocumentIdErrors[keyof DeleteKnowledgeSpacesByIdDocumentsByDocumentIdErrors] + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdResponses = { + 202: DurableDeletionAccepted +} + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdResponse = + DeleteKnowledgeSpacesByIdDocumentsByDocumentIdResponses[keyof DeleteKnowledgeSpacesByIdDocumentsByDocumentIdResponses] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}' +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdError = + GetKnowledgeSpacesByIdDocumentsByDocumentIdErrors[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdErrors] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdResponses = { + 200: DocumentAsset +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdResponse = + GetKnowledgeSpacesByIdDocumentsByDocumentIdResponses[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdResponses] + +export type GetKnowledgeSpacesByIdLogicalDocumentsData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/logical-documents' +} + +export type GetKnowledgeSpacesByIdLogicalDocumentsErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdLogicalDocumentsError = + GetKnowledgeSpacesByIdLogicalDocumentsErrors[keyof GetKnowledgeSpacesByIdLogicalDocumentsErrors] + +export type GetKnowledgeSpacesByIdLogicalDocumentsResponses = { + 200: LogicalDocumentList +} + +export type GetKnowledgeSpacesByIdLogicalDocumentsResponse = + GetKnowledgeSpacesByIdLogicalDocumentsResponses[keyof GetKnowledgeSpacesByIdLogicalDocumentsResponses] + +export type DeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdData = { + body: { + expectedRevision: number + } + headers: { + 'idempotency-key': string + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/logical-documents/{documentId}' +} + +export type DeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type DeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdError = + DeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdErrors[keyof DeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdErrors] + +export type DeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponses = { + 202: DurableDeletionAccepted +} + +export type DeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponse = + DeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponses[keyof DeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponses] + +export type GetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/logical-documents/{documentId}' +} + +export type GetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdError = + GetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdErrors[keyof GetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdErrors] + +export type GetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponses = { + 200: LogicalDocument +} + +export type GetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponse = + GetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponses[keyof GetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponses] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/outline' +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineError = + GetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineErrors[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineErrors] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineResponses = { + 200: DocumentOutline +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineResponse = + GetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineResponses[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineResponses] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions' +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsError = + GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsErrors[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsErrors] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsResponses = { + 200: DocumentRevisionList +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsResponse = + GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsResponses[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsResponses] + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackData = { + body: { + expectedActiveRevision: number + expectedRowVersion: number + } + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + revision: number + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/rollback' +} + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackError = + PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackErrors[keyof PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackErrors] + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackResponses = { + 202: DocumentProcessingTask +} + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackResponse = + PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackResponses[keyof PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackResponses] + +export type PatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataData = { + body: { + expectedRowVersion: number + patch: { + [key: string]: unknown + } + } + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/metadata' +} + +export type PatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataError = + PatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataErrors[keyof PatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataErrors] + +export type PatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataResponses = { + 200: LogicalDocument +} + +export type PatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataResponse = + PatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataResponses[keyof PatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataResponses] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + revision: number + } + query?: { + cursor?: string + limit?: number + query?: string + } + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/chunks' +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksError = + GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksErrors[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksErrors] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksResponses = { + 200: DocumentChunkList +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksResponse = + GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksResponses[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksResponses] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + revision: number + chunkId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/chunks/{chunkId}' +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdError = + GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdErrors[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdErrors] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdResponses = + { + 200: DocumentRevisionChunk + } + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdResponse = + GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdResponses[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdResponses] + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateData = + { + body: { + enabled: boolean + } + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + revision: number + chunkId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/revisions/{revision}/chunks/{chunkId}/state' + } + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateErrors = + { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse + } + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateError = + PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateErrors[keyof PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateErrors] + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateResponses = + { + 202: DocumentChunkStateChangeAccepted + } + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateResponse = + PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateResponses[keyof PostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateResponses] + +export type GetKnowledgeSpacesByIdProcessingTasksData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/processing-tasks' +} + +export type GetKnowledgeSpacesByIdProcessingTasksErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdProcessingTasksError = + GetKnowledgeSpacesByIdProcessingTasksErrors[keyof GetKnowledgeSpacesByIdProcessingTasksErrors] + +export type GetKnowledgeSpacesByIdProcessingTasksResponses = { + 200: DocumentProcessingTaskList +} + +export type GetKnowledgeSpacesByIdProcessingTasksResponse = + GetKnowledgeSpacesByIdProcessingTasksResponses[keyof GetKnowledgeSpacesByIdProcessingTasksResponses] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: { + cursor?: string + limit?: number + } + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks' +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksErrors = { + 400: ErrorResponse + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksError = + GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksErrors[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksErrors] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksResponses = { + 200: DocumentProcessingTaskList +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksResponse = + GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksResponses[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksResponses] + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + taskId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}' +} + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdError = + DeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdErrors[keyof DeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdErrors] + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponses = { + 200: DocumentProcessingTask +} + +export type DeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponse = + DeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponses[keyof DeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponses] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + taskId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}' +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdError = + GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdErrors[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdErrors] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponses = { + 200: DocumentProcessingTask +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponse = + GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponses[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponses] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsData = { + body?: never + headers?: { + 'last-event-id'?: string + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + taskId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}/events' +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsError = + GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsErrors[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsErrors] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsResponses = { + 200: DocumentProcessingTaskEvent +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsResponse = + GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsResponses[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsResponses] + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + taskId: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/processing-tasks/{taskId}/retry' +} + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError +} + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryError = + PostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryErrors[keyof PostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryErrors] + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryResponses = { + 200: DocumentProcessingTask +} + +export type PostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryResponse = + PostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryResponses[keyof PostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryResponses] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/settings' +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsError = + GetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsErrors[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsErrors] + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponses = { + 200: DocumentSettingsHead +} + +export type GetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponse = + GetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponses[keyof GetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponses] + +export type PutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsData = { + body: { + expectedSettingsHeadRevision: number | null + settings: { + chunkOverlap: number + chunkSize: number + enableGraph: boolean + enablePageIndex: boolean + language?: string + } + } + headers?: { + 'x-trace-id'?: string + } + path: { + documentId: string + id: string + } + query?: never + url: '/knowledge-fs/knowledge-spaces/{id}/documents/{documentId}/settings' +} + +export type PutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type PutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsError = + PutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsErrors[keyof PutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsErrors] + +export type PutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponses = { + 202: DocumentReindexAccepted +} + +export type PutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponse = + PutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponses[keyof PutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponses] + +export type DeleteJobsByIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/jobs/{id}' +} + +export type DeleteJobsByIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type DeleteJobsByIdError = DeleteJobsByIdErrors[keyof DeleteJobsByIdErrors] + +export type DeleteJobsByIdResponses = { + 200: DocumentCompilationJob +} + +export type DeleteJobsByIdResponse = DeleteJobsByIdResponses[keyof DeleteJobsByIdResponses] + +export type GetJobsByIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/jobs/{id}' +} + +export type GetJobsByIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type GetJobsByIdError = GetJobsByIdErrors[keyof GetJobsByIdErrors] + +export type GetJobsByIdResponses = { + 200: DocumentCompilationJob +} + +export type GetJobsByIdResponse = GetJobsByIdResponses[keyof GetJobsByIdResponses] + +export type PostJobsByIdRetryData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/jobs/{id}/retry' +} + +export type PostJobsByIdRetryErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type PostJobsByIdRetryError = PostJobsByIdRetryErrors[keyof PostJobsByIdRetryErrors] + +export type PostJobsByIdRetryResponses = { + 200: DocumentCompilationJob +} + +export type PostJobsByIdRetryResponse = PostJobsByIdRetryResponses[keyof PostJobsByIdRetryResponses] + +export type GetDeletionJobsByJobIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + jobId: string + } + query?: never + url: '/knowledge-fs/deletion-jobs/{jobId}' +} + +export type GetDeletionJobsByJobIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError +} + +export type GetDeletionJobsByJobIdError = + GetDeletionJobsByJobIdErrors[keyof GetDeletionJobsByJobIdErrors] + +export type GetDeletionJobsByJobIdResponses = { + 200: DurableDeletionJob +} + +export type GetDeletionJobsByJobIdResponse = + GetDeletionJobsByJobIdResponses[keyof GetDeletionJobsByJobIdResponses] + +export type PostDeletionJobsByJobIdRetryData = { + body?: never + headers: { + 'idempotency-key': string + 'x-trace-id'?: string + } + path: { + jobId: string + } + query?: never + url: '/knowledge-fs/deletion-jobs/{jobId}/retry' +} + +export type PostDeletionJobsByJobIdRetryErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 409: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type PostDeletionJobsByJobIdRetryError = + PostDeletionJobsByJobIdRetryErrors[keyof PostDeletionJobsByJobIdRetryErrors] + +export type PostDeletionJobsByJobIdRetryResponses = { + 202: DurableDeletionAccepted +} + +export type PostDeletionJobsByJobIdRetryResponse = + PostDeletionJobsByJobIdRetryResponses[keyof PostDeletionJobsByJobIdRetryResponses] + +export type GetBulkJobsByIdData = { + body?: never + headers?: { + 'x-trace-id'?: string + } + path: { + id: string + } + query?: never + url: '/knowledge-fs/bulk-jobs/{id}' +} + +export type GetBulkJobsByIdErrors = { + 403: ConsoleProxyError + 404: ErrorResponse + 502: ConsoleProxyError + 503: ErrorResponse +} + +export type GetBulkJobsByIdError = GetBulkJobsByIdErrors[keyof GetBulkJobsByIdErrors] + +export type GetBulkJobsByIdResponses = { + 200: BulkOperationProgress +} + +export type GetBulkJobsByIdResponse = GetBulkJobsByIdResponses[keyof GetBulkJobsByIdResponses] diff --git a/packages/contracts/generated/knowledge-fs/zod.gen.ts b/packages/contracts/generated/knowledge-fs/zod.gen.ts new file mode 100644 index 00000000000..a4ef693c8b0 --- /dev/null +++ b/packages/contracts/generated/knowledge-fs/zod.gen.ts @@ -0,0 +1,2257 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod' + +export const zKnowledgeSpaceCreationResponse = z.object({ + createdAt: z.iso.datetime(), + description: z.string().max(2000).optional(), + iconRef: z + .string() + .max(72) + .regex(/^builtin:[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/) + .optional(), + id: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + name: z.string().min(1).max(160), + revision: z.int().gt(0), + slug: z + .string() + .max(160) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + tenantId: z.string().min(1).max(255), + updatedAt: z.iso.datetime(), + configurationStatus: z.enum([ + 'pending-validation', + 'ready', + 'setup-required', + 'validation-failed', + ]), +}) + +export const zErrorResponse = z.object({ + code: z.string().optional(), + error: z.string(), +}) + +export const zCreateKnowledgeSpace = z.object({ + description: z.string().max(2000).optional(), + embeddingProfile: z + .object({ + model: z.string().min(1).max(256), + pluginId: z.string().min(1).max(256), + provider: z.string().min(1).max(256), + }) + .optional(), + iconRef: z + .string() + .max(72) + .regex(/^builtin:[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/) + .optional(), + idempotencyKey: z.string().min(1).max(255).optional(), + name: z.string().min(1).max(160), + retrievalProfile: z + .object({ + defaultMode: z.enum(['fast', 'research', 'deep']), + reasoningModel: z.object({ + model: z.string().min(1).max(256), + pluginId: z.string().min(1).max(256), + provider: z.string().min(1).max(256), + }), + rerank: z.object({ + enabled: z.boolean(), + model: z + .object({ + model: z.string().min(1).max(256), + pluginId: z.string().min(1).max(256), + provider: z.string().min(1).max(256), + }) + .optional(), + }), + scoreThreshold: z.object({ + enabled: z.boolean(), + stage: z.enum(['mode-final', 'rerank']), + value: z.number().gte(0).lte(1).optional(), + }), + topK: z.int().gte(1).lte(100), + }) + .optional(), + slug: z + .string() + .max(160) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + .optional(), +}) + +export const zKnowledgeSpace = z.object({ + createdAt: z.iso.datetime(), + description: z.string().max(2000).optional(), + iconRef: z + .string() + .max(72) + .regex(/^builtin:[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/) + .optional(), + id: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + name: z.string().min(1).max(160), + revision: z.int().gt(0), + slug: z + .string() + .max(160) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + tenantId: z.string().min(1).max(255), + updatedAt: z.iso.datetime(), +}) + +export const zKnowledgeSpaceList = z.object({ + items: z.array(zKnowledgeSpace), + nextCursor: z.string().optional(), +}) + +export const zKnowledgeSpaceStats = z.object({ + cache: z.object({ + available: z.boolean(), + entries: z.int().gte(0), + totalBytes: z.int().gte(0), + }), + commits: z.object({ + failedRetryable: z.int().gte(0), + failedTerminal: z.int().gte(0), + sampled: z.int().gte(0), + truncated: z.boolean(), + }), + generatedAt: z.iso.datetime(), + knowledgeSpaceId: z.uuid(), + metrics: z.object({ + available: z.boolean(), + reason: z.string().optional(), + }), + projections: z.object({ + denseVector: z.object({ + building: z.int().gte(0), + failed: z.int().gte(0), + ready: z.int().gte(0), + stale: z.int().gte(0), + total: z.int().gte(0), + }), + fts: z.object({ + building: z.int().gte(0), + failed: z.int().gte(0), + ready: z.int().gte(0), + stale: z.int().gte(0), + total: z.int().gte(0), + }), + graph: z.object({ + building: z.int().gte(0), + failed: z.int().gte(0), + ready: z.int().gte(0), + stale: z.int().gte(0), + total: z.int().gte(0), + }), + metadata: z.object({ + building: z.int().gte(0), + failed: z.int().gte(0), + ready: z.int().gte(0), + stale: z.int().gte(0), + total: z.int().gte(0), + }), + projectionVersion: z.int().gt(0), + }), + runtime: z.object({ + activeLeaseSampleCount: z.int().gte(0), + activeSessionSampleCount: z.int().gte(0), + truncated: z.boolean(), + }), + storage: z.object({ + documentCount: z.int().gte(0), + rawDocumentBytes: z.int().gte(0), + }), + tenantId: z.string(), + window: z.object({ + end: z.iso.datetime(), + minutes: z.int().gt(0).lte(1440), + start: z.iso.datetime(), + }), +}) + +export const zDurableDeletionJob = z.object({ + checkpoint: z.enum([ + 'requested', + 'quiescing', + 'deleting_objects', + 'deleting_derived_data', + 'deleting_primary_data', + 'completed', + ]), + completedAt: z.iso.datetime().optional(), + createdAt: z.iso.datetime(), + error: z + .object({ + code: z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/), + message: z.string().min(1).max(256), + retryable: z.boolean(), + }) + .optional(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + mode: z.enum(['cascade', 'keep']).optional(), + progress: z + .object({ + completedItems: z.int().gte(0), + currentItemKind: z.string().min(1).optional(), + totalItems: z.int().gte(0).optional(), + }) + .optional(), + retryAt: z.iso.datetime().optional(), + runState: z.enum([ + 'dispatch_pending', + 'queued', + 'running', + 'retry_wait', + 'completed', + 'failed', + 'canceled', + ]), + targetId: z.uuid(), + targetType: z.enum(['knowledge_space', 'source', 'document', 'logical_document']), + updatedAt: z.iso.datetime(), +}) + +export const zDurableDeletionAccepted = z.object({ + job: zDurableDeletionJob, + statusUrl: z.string().min(1), +}) + +export const zDurableBulkDeletionAccepted = z.object({ + items: z.array( + z.object({ + documentId: z.uuid(), + job: zDurableDeletionJob, + statusUrl: z.string().min(1), + }), + ), + total: z.int().gt(0), +}) + +export const zDocumentAsset = z.object({ + createdAt: z.iso.datetime(), + filename: z.string().min(1).max(512), + id: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + knowledgeSpaceId: z + .string() + .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + metadata: z.record(z.string(), z.unknown()).optional().default({}), + mimeType: z.string().min(1), + objectKey: z.string().min(1), + parserStatus: z.enum(['pending', 'parsed', 'failed']), + sha256: z.string().regex(/^[0-9a-f]{64}$/), + sizeBytes: z.int().gte(0), + sourceId: z + .string() + .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + .optional(), + updatedAt: z.iso.datetime().optional(), + version: z.int().gt(0), +}) + +export const zDocumentAssetList = z.object({ + items: z.array(zDocumentAsset), + nextCursor: z.uuid().optional(), +}) + +export const zDocumentOutlineNode = z.object({ + childNodeIds: z.array(z.string()).optional().default([]), + children: z.array(z.record(z.string(), z.unknown())).optional().default([]), + endOffset: z.int().gte(0).optional(), + endPage: z.int().gt(0).optional(), + id: z.string(), + level: z.int().gt(0), + metadata: z.record(z.string(), z.unknown()), + sectionPath: z.array(z.string()).optional().default([]), + sourceElementIds: z.array(z.string()).optional().default([]), + sourceNodeIds: z.array(z.string()).optional().default([]), + startOffset: z.int().gte(0).optional(), + startPage: z.int().gt(0).optional(), + summary: z.string().optional(), + title: z.string(), + titleLocation: z.record(z.string(), z.unknown()).optional(), + tocSource: z.string(), +}) + +export const zDocumentOutline = z.object({ + artifactHash: z.string(), + createdAt: z.string(), + documentAssetId: z.uuid(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + metadata: z.record(z.string(), z.unknown()), + nodes: z.array(zDocumentOutlineNode), + outlineVersion: z.string(), + parseArtifactId: z.uuid(), + updatedAt: z.string().optional(), + version: z.int().gt(0), +}) + +export const zLogicalDocumentRevision = z + .object({ + activatedAt: z.string().optional(), + contentHash: z.string().length(64), + createdAt: z.string(), + documentAssetId: z.uuid(), + documentAssetVersion: z.int().gt(0), + documentId: z.uuid(), + knowledgeSpaceId: z.uuid(), + mimeType: z.string(), + revision: z.int().gt(0), + sizeBytes: z.int().gte(0), + state: z.enum(['candidate', 'active', 'superseded', 'failed']), + }) + .nullable() + +export const zLogicalDocument = z.object({ + active: zLogicalDocumentRevision, + activeRevision: z.int().gt(0).optional(), + createdAt: z.string(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + providerItemId: z.string().optional(), + rowVersion: z.int().gte(0), + sourceId: z.uuid().optional(), + status: z.enum(['pending', 'ready', 'failed', 'deleting']), + title: z.string(), + updatedAt: z.string(), + userMetadata: z.record(z.string(), z.unknown()), +}) + +export const zLogicalDocumentList = z.object({ + items: z.array(zLogicalDocument), + nextCursor: z.string().optional(), +}) + +export const zDocumentRevisionList = z.object({ + items: z.array(zLogicalDocumentRevision.and(z.record(z.string(), z.unknown()))), + nextCursor: z.string().optional(), +}) + +export const zDocumentProcessingTask = z.object({ + completedAt: z.string().optional(), + createdAt: z.string(), + documentId: z.uuid(), + documentRevision: z.int().gt(0), + errorCode: z.string().optional(), + errorMessage: z.string().optional(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + progressPercent: z.int().gte(0).lte(100), + retryAt: z.string().optional(), + stage: z.enum([ + 'queued', + 'parsed', + 'outline_built', + 'nodes_generated', + 'projection_built', + 'smoke_eval_passed', + 'published', + ]), + state: z.enum([ + 'dispatch_pending', + 'queued', + 'running', + 'retry_wait', + 'succeeded', + 'failed', + 'canceled', + 'superseded', + ]), + updatedAt: z.string(), +}) + +export const zDocumentRevisionChunk = z.object({ + createdAt: z.string(), + documentId: z.uuid(), + documentRevision: z.int().gt(0), + enabled: z.boolean(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + ordinal: z.int().gte(0), + parentChunkId: z.uuid().optional(), + text: z.string(), + tokenCount: z.int().gte(0), + userMetadata: z.record(z.string(), z.unknown()), +}) + +export const zDocumentChunkList = z.object({ + items: z.array(zDocumentRevisionChunk), + nextCursor: z.string().optional(), +}) + +export const zDocumentChunkStateChangeAccepted = z.object({ + candidateFingerprint: z.string().optional(), + candidatePublicationId: z.uuid().optional(), + chunkId: z.uuid(), + compilationAttemptId: z.uuid(), + createdAt: z.string(), + documentId: z.uuid(), + documentRevision: z.int().gt(0), + enabled: z.boolean(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + state: z.enum(['candidate']), + statusUrl: z.string().min(1), +}) + +export const zDocumentProcessingTaskList = z.object({ + items: z.array(zDocumentProcessingTask), + nextCursor: z.string().optional(), +}) + +export const zDocumentProcessingTaskEvent = z.union([ + z.object({ + data: z.object({ + progressPercent: z.int().gte(0).lte(100), + stage: z.enum([ + 'queued', + 'parsed', + 'outline_built', + 'nodes_generated', + 'projection_built', + 'smoke_eval_passed', + 'published', + ]), + state: z.enum([ + 'dispatch_pending', + 'queued', + 'running', + 'retry_wait', + 'succeeded', + 'failed', + 'canceled', + 'superseded', + ]), + updatedAt: z.string(), + }), + event: z.enum(['progress']), + }), + z.object({ + data: z.object({ + errorCode: z.string().optional(), + state: z.enum(['succeeded', 'failed', 'canceled', 'superseded']), + }), + event: z.enum(['terminal']), + }), +]) + +export const zDocumentSettingsHead = z.object({ + activeRevision: z.int().gt(0), + profile: z.object({ + activatedAt: z.string().optional(), + createdAt: z.string(), + revision: z.int().gt(0), + settings: z.object({ + chunkOverlap: z.int().gte(0).lte(8191), + chunkSize: z.int().gte(128).lte(8192), + enableGraph: z.boolean(), + enablePageIndex: z.boolean(), + language: z.string().min(2).max(64).optional(), + }), + state: z.enum(['active']), + }), + rowVersion: z.int().gte(0), + updatedAt: z.string(), +}) + +export const zDocumentReindexAccepted = z.object({ + attemptId: z.uuid(), + compilationAttemptId: z.uuid(), + settingsRevision: z.int().gt(0), + state: z.enum(['running']), + statusUrl: z.string(), +}) + +export const zDocumentCompilationJob = z.object({ + baseHeadRevision: z.int().gte(0).optional(), + candidateFingerprint: z.string().min(1).optional(), + candidatePublicationId: z.uuid().optional(), + completedAt: z.number().optional(), + createdAt: z.number(), + documentAssetId: z.string().min(1), + error: z.string().optional(), + executionAttempts: z.int().gte(0).optional(), + id: z.string().min(1), + knowledgeSpaceId: z.string().min(1), + leaseExpiresAt: z.number().optional(), + maxExecutionAttempts: z.int().gt(0).optional(), + publicationGenerationId: z + .string() + .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + .optional(), + queueJobId: z.string().min(1).optional(), + retryAt: z.number().optional(), + runState: z + .enum([ + 'dispatch_pending', + 'queued', + 'running', + 'retry_wait', + 'succeeded', + 'failed', + 'canceled', + 'superseded', + ]) + .optional(), + stage: z.enum([ + 'queued', + 'parsed', + 'outline_built', + 'nodes_generated', + 'projection_built', + 'smoke_eval_passed', + 'published', + 'failed', + 'canceled', + ]), + tenantId: z.string().min(1).max(255), + updatedAt: z.number(), + version: z.int().gt(0), +}) + +export const zBulkOperationProgress = z.object({ + completedItems: z.int().gte(0), + createdAt: z.string(), + failedItemIds: z.array(z.string().min(1)), + failedItems: z.int().gte(0), + id: z.string().min(1), + knowledgeSpaceId: z.string().min(1), + status: z.enum(['running', 'completed', 'failed']), + totalItems: z.int().gte(0), + type: z.enum(['document_upload', 'document_delete', 'document_reindex']), + updatedAt: z.string(), +}) + +export const zBulkDocumentReindexResult = z.object({ + bulkJobId: z.string().min(1), + items: z.array( + z.union([ + z.object({ + asset: zDocumentAsset, + compilationJob: z.object({ + id: z.string().min(1), + stage: z.enum(['queued']), + }), + status: z.enum(['queued']), + statusUrl: z.string().min(1), + }), + z.object({ + documentId: z.uuid(), + status: z.enum(['not_found']), + }), + ]), + ), + total: z.int().gte(0), +}) + +export const zDocumentUploadAccepted = z.object({ + asset: zDocumentAsset, + assetStatusUrl: z.string().min(1).optional(), + compilationJob: z.object({ + id: z.string().min(1), + stage: z.enum(['queued']), + }), + logicalDocument: z.object({ + id: z.uuid(), + revision: z.int().gt(0), + }), + logicalDocumentId: z.uuid(), + documentRevision: z.int().gt(0), + statusUrl: z.string().min(1), + status: z.enum(['accepted']).optional(), +}) + +export const zBulkDocumentUploadAccepted = z.object({ + accepted: z.int().gte(0), + bulkJobId: z.string().min(1), + excluded: z.int().gte(0), + items: z.array( + z.union([ + zDocumentUploadAccepted, + z.object({ + filename: z.string(), + index: z.int().gte(0), + mimeType: z.string(), + reason: z.enum([ + 'batch_byte_limit_exceeded', + 'document_not_found', + 'file_count_limit_exceeded', + 'file_too_large', + 'invalid_file', + 'invalid_target', + 'processing_failed', + 'quota_exceeded', + 'revision_conflict', + 'unsupported_mime_type', + ]), + sizeBytes: z.int().gte(0), + status: z.enum(['excluded']), + }), + ]), + ), + total: z.int().gte(0), +}) + +export const zSourceWorkflowRun = z.object({ + canceledAt: z.string().optional(), + checkpoint: z.string(), + completedAt: z.string().optional(), + createdAt: z.string(), + cursor: z.string().optional(), + executionAttempts: z.int(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + kind: z.string(), + lastErrorCode: z.string().optional(), + maxExecutionAttempts: z.int(), + progressCompleted: z.int(), + progressFailed: z.int(), + progressSkipped: z.int(), + progressTotal: z.int().optional(), + sourceId: z.uuid().optional(), + state: z.string(), + updatedAt: z.string(), +}) + +export const zSource = z.object({ + connectionId: z + .string() + .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + .optional(), + createdAt: z.iso.datetime(), + id: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + knowledgeSpaceId: z + .string() + .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + metadata: z.record(z.string(), z.unknown()), + name: z.string().min(1).max(200), + permissionScope: z.array(z.string().min(1)).optional().default([]), + status: z.enum(['active', 'syncing', 'error', 'disabled']), + type: z.enum(['upload', 'object-storage', 'connector', 'web']), + updatedAt: z.iso.datetime(), + uri: z.string().min(1), + version: z.int().gte(1).optional().default(1), + credentialConfigured: z.boolean().optional(), +}) + +export const zWebsiteCrawlResult = z.object({ + completed: z.number().optional(), + failed: z.number().optional(), + imported: z.number().optional(), + pages: z.array( + z.object({ + content: z.string(), + description: z.string().optional(), + sourceUrl: z.string(), + title: z.string().optional(), + }), + ), + replaced: z.number().optional(), + skipped: z.number().optional(), + status: z.string().optional(), + total: z.number().optional(), +}) + +export const zOnlineDocumentPages = z.object({ + nextCursor: z.string().optional(), + workspaces: z.array( + z.object({ + pages: z.array( + z.object({ + lastEditedTime: z.string().optional(), + pageId: z.string(), + pageName: z.string(), + parentId: z.string().optional(), + type: z.string(), + }), + ), + total: z.number().optional(), + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), + }), + ), +}) + +export const zSourceImportResult = z.object({ + documents: z.array( + z.object({ + documentAssetId: z.string(), + filename: z.string(), + }), + ), + failed: z.array( + z.object({ + code: z.string(), + error: z.string(), + filename: z.string(), + }), + ), + skipped: z.array(z.string()), +}) + +export const zSourceCredentialTest = z.object({ + code: z.string().optional(), + error: z.string().optional(), + valid: z.boolean(), +}) + +export const zOnlineDriveFiles = z.object({ + buckets: z.array( + z.object({ + bucket: z.string().optional(), + continuationToken: z.string().optional(), + files: z.array( + z.object({ + id: z.string(), + name: z.string(), + size: z.number().optional(), + type: z.string(), + }), + ), + isTruncated: z.boolean().optional(), + }), + ), +}) + +export const zConsoleProxyError = z.object({ + code: z.string(), + message: z.string(), + status: z.int(), +}) + +export const zListKnowledgeSpacesHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zListKnowledgeSpacesQuery = z.object({ + cursor: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(100), +}) + +/** + * Tenant knowledge spaces + */ +export const zListKnowledgeSpacesResponse = zKnowledgeSpaceList + +export const zCreateKnowledgeSpaceBody = zCreateKnowledgeSpace + +export const zCreateKnowledgeSpaceHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +/** + * Created knowledge space + */ +export const zCreateKnowledgeSpaceResponse = zKnowledgeSpaceCreationResponse + +export const zDeleteKnowledgeSpacesByIdBody = z.object({ + challenge: z.string().min(1).max(160), + expectedRevision: z.int().gt(0), +}) + +export const zDeleteKnowledgeSpacesByIdHeaders = z.object({ + 'idempotency-key': z.string().min(8).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zDeleteKnowledgeSpacesByIdPath = z.object({ + id: z.uuid(), +}) + +/** + * Durable deletion accepted + */ +export const zDeleteKnowledgeSpacesByIdResponse = zDurableDeletionAccepted + +export const zGetKnowledgeSpacesByIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdPath = z.object({ + id: z.uuid(), +}) + +/** + * Knowledge space + */ +export const zGetKnowledgeSpacesByIdResponse = zKnowledgeSpace + +export const zPatchKnowledgeSpacesByIdBody = z.object({ + description: z.string().max(2000).optional(), + expectedRevision: z.int().gt(0), + iconRef: z + .string() + .max(72) + .regex(/^builtin:[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/) + .nullish(), + name: z.string().min(1).max(160).optional(), + slug: z + .string() + .max(160) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + .optional(), +}) + +export const zPatchKnowledgeSpacesByIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPatchKnowledgeSpacesByIdPath = z.object({ + id: z.uuid(), +}) + +/** + * Updated knowledge space + */ +export const zPatchKnowledgeSpacesByIdResponse = zKnowledgeSpace + +export const zGetKnowledgeSpacesByIdStatsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdStatsPath = z.object({ + id: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdStatsQuery = z.object({ + windowMinutes: z.int().gte(1).lte(1440).optional(), +}) + +/** + * Low-cardinality KnowledgeSpace statistics + */ +export const zGetKnowledgeSpacesByIdStatsResponse = zKnowledgeSpaceStats + +export const zGetKnowledgeSpacesByIdAccessPolicyHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdAccessPolicyPath = z.object({ + id: z.uuid(), +}) + +/** + * Knowledge space visibility policy + */ +export const zGetKnowledgeSpacesByIdAccessPolicyResponse = z.object({ + id: z.string().min(1), + ownerSubjectId: z.string().min(1).max(255), + partialMemberSubjectIds: z.array(z.string().min(1).max(255)), + revision: z.int().gt(0), + visibility: z.enum(['only_me', 'all_members', 'partial_members']), +}) + +export const zPatchKnowledgeSpacesByIdAccessPolicyBody = z.object({ + expectedRevision: z.int().gt(0), + partialMemberSubjectIds: z.array(z.string().min(1).max(255)).max(500).optional().default([]), + visibility: z.enum(['only_me', 'all_members', 'partial_members']), +}) + +export const zPatchKnowledgeSpacesByIdAccessPolicyHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPatchKnowledgeSpacesByIdAccessPolicyPath = z.object({ + id: z.uuid(), +}) + +/** + * Updated knowledge space visibility policy + */ +export const zPatchKnowledgeSpacesByIdAccessPolicyResponse = z.object({ + id: z.string().min(1), + ownerSubjectId: z.string().min(1).max(255), + partialMemberSubjectIds: z.array(z.string().min(1).max(255)), + revision: z.int().gt(0), + visibility: z.enum(['only_me', 'all_members', 'partial_members']), +}) + +export const zGetSourceProvidersHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +/** + * Source provider capability catalog + */ +export const zGetSourceProvidersResponse = z.object({ + items: z.array( + z.object({ + authKinds: z.array(z.enum(['api-key', 'endpoint', 'oauth2'])), + available: z.boolean(), + capabilities: z.array(z.enum(['website-crawl', 'online-document', 'online-drive'])), + configuration: z.array( + z.object({ + description: z.string().optional(), + format: z.enum(['password', 'uri']).optional(), + name: z.string(), + required: z.boolean(), + secret: z.boolean(), + type: z.enum(['boolean', 'integer', 'string']), + }), + ), + displayName: z.string(), + id: z.string(), + unavailableReason: z.string().optional(), + }), + ), +}) + +export const zGetKnowledgeSpacesByIdSourceConnectionsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourceConnectionsPath = z.object({ + id: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdSourceConnectionsQuery = z.object({ + cursor: z.string().max(4096).optional(), + limit: z.int().gte(1).lte(200).optional().default(50), +}) + +/** + * Source connections + */ +export const zGetKnowledgeSpacesByIdSourceConnectionsResponse = z.object({ + items: z.array( + z.object({ + authKind: z.enum(['api-key', 'endpoint', 'oauth2']), + configuration: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])), + createdAt: z.string(), + errorCode: z.string().optional(), + expiresAt: z.string().optional(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + name: z.string(), + providerId: z.string(), + scopes: z.array(z.string()), + status: z.enum(['provisioning', 'active', 'expired', 'error', 'revoked']), + updatedAt: z.string(), + version: z.int(), + }), + ), + nextCursor: z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourceConnectionsBody = z.object({ + authKind: z.enum(['api-key', 'endpoint']), + configuration: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])).optional(), + credentials: z.record(z.string(), z.unknown()), + name: z.string().min(1).max(160), + providerId: z.string().min(1).max(128), +}) + +export const zPostKnowledgeSpacesByIdSourceConnectionsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourceConnectionsPath = z.object({ + id: z.uuid(), +}) + +/** + * Source connection created + */ +export const zPostKnowledgeSpacesByIdSourceConnectionsResponse = z.object({ + authKind: z.enum(['api-key', 'endpoint', 'oauth2']), + configuration: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])), + createdAt: z.string(), + errorCode: z.string().optional(), + expiresAt: z.string().optional(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + name: z.string(), + providerId: z.string(), + scopes: z.array(z.string()), + status: z.enum(['provisioning', 'active', 'expired', 'error', 'revoked']), + updatedAt: z.string(), + version: z.int(), +}) + +export const zPostKnowledgeSpacesByIdSourceConnectionsOauthBody = z.object({ + configuration: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])).optional(), + name: z.string().min(1).max(160), + providerId: z.string().min(1).max(128), + redirectUri: z.string().min(1).max(2048), + scopes: z.array(z.string().min(1).max(255)).max(100).optional().default([]), +}) + +export const zPostKnowledgeSpacesByIdSourceConnectionsOauthHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourceConnectionsOauthPath = z.object({ + id: z.uuid(), +}) + +/** + * OAuth authorization started + */ +export const zPostKnowledgeSpacesByIdSourceConnectionsOauthResponse = z.object({ + authorizationUrl: z.string(), + connection: z.object({ + authKind: z.enum(['api-key', 'endpoint', 'oauth2']), + configuration: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])), + createdAt: z.string(), + errorCode: z.string().optional(), + expiresAt: z.string().optional(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + name: z.string(), + providerId: z.string(), + scopes: z.array(z.string()), + status: z.enum(['provisioning', 'active', 'expired', 'error', 'revoked']), + updatedAt: z.string(), + version: z.int(), + }), +}) + +export const zPostSourceOauthCallbackBody = z.object({ + code: z.string().min(1).max(8192), + state: z.string().min(32).max(256), +}) + +export const zPostSourceOauthCallbackHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +/** + * OAuth connection activated + */ +export const zPostSourceOauthCallbackResponse = z.object({ + authKind: z.enum(['api-key', 'endpoint', 'oauth2']), + configuration: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])), + createdAt: z.string(), + errorCode: z.string().optional(), + expiresAt: z.string().optional(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + name: z.string(), + providerId: z.string(), + scopes: z.array(z.string()), + status: z.enum(['provisioning', 'active', 'expired', 'error', 'revoked']), + updatedAt: z.string(), + version: z.int(), +}) + +export const zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdPath = z.object({ + id: z.uuid(), + connectionId: z.uuid(), +}) + +export const zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdQuery = z.object({ + expectedVersion: z.int().gte(1), +}) + +/** + * Source connection locally revoked + */ +export const zDeleteKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponse = z.object({ + authKind: z.enum(['api-key', 'endpoint', 'oauth2']), + configuration: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])), + createdAt: z.string(), + errorCode: z.string().optional(), + expiresAt: z.string().optional(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + name: z.string(), + providerId: z.string(), + scopes: z.array(z.string()), + status: z.enum(['provisioning', 'active', 'expired', 'error', 'revoked']), + updatedAt: z.string(), + version: z.int(), +}) + +export const zGetKnowledgeSpacesByIdSourceConnectionsByConnectionIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourceConnectionsByConnectionIdPath = z.object({ + id: z.uuid(), + connectionId: z.uuid(), +}) + +/** + * Source connection + */ +export const zGetKnowledgeSpacesByIdSourceConnectionsByConnectionIdResponse = z.object({ + authKind: z.enum(['api-key', 'endpoint', 'oauth2']), + configuration: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])), + createdAt: z.string(), + errorCode: z.string().optional(), + expiresAt: z.string().optional(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + name: z.string(), + providerId: z.string(), + scopes: z.array(z.string()), + status: z.enum(['provisioning', 'active', 'expired', 'error', 'revoked']), + updatedAt: z.string(), + version: z.int(), +}) + +export const zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshBody = z.object({ + expectedVersion: z.int().gte(1), +}) + +export const zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshPath = z.object({ + id: z.uuid(), + connectionId: z.uuid(), +}) + +/** + * Source connection refreshed + */ +export const zPostKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefreshResponse = z.object({ + authKind: z.enum(['api-key', 'endpoint', 'oauth2']), + configuration: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])), + createdAt: z.string(), + errorCode: z.string().optional(), + expiresAt: z.string().optional(), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + name: z.string(), + providerId: z.string(), + scopes: z.array(z.string()), + status: z.enum(['provisioning', 'active', 'expired', 'error', 'revoked']), + updatedAt: z.string(), + version: z.int(), +}) + +export const zGetKnowledgeSpacesByIdSourcesHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourcesPath = z.object({ + id: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdSourcesQuery = z.object({ + cursor: z.string().optional(), + limit: z.int().gte(1).lte(200).optional(), +}) + +/** + * Knowledge space sources + */ +export const zGetKnowledgeSpacesByIdSourcesResponse = z.object({ + items: z.array(zSource), + nextCursor: z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesBody = z.object({ + connectionId: z.uuid().optional(), + credentials: z.record(z.string(), z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + name: z.string().min(1).max(200), + permissionScope: z.array(z.string().min(1)).optional(), + status: z.enum(['active', 'syncing', 'error', 'disabled']).optional(), + type: z.enum(['upload', 'object-storage', 'connector', 'web']), + uri: z.string().min(1), +}) + +export const zPostKnowledgeSpacesByIdSourcesHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesPath = z.object({ + id: z.uuid(), +}) + +/** + * Created source + */ +export const zPostKnowledgeSpacesByIdSourcesResponse = zSource + +export const zDeleteKnowledgeSpacesByIdSourcesBySourceIdBody = z.object({ + expectedRevision: z.int().gt(0), +}) + +export const zDeleteKnowledgeSpacesByIdSourcesBySourceIdHeaders = z.object({ + 'idempotency-key': z.string().min(8).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zDeleteKnowledgeSpacesByIdSourcesBySourceIdPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +export const zDeleteKnowledgeSpacesByIdSourcesBySourceIdQuery = z.object({ + documents: z.enum(['cascade', 'keep']).optional().default('cascade'), +}) + +/** + * Durable deletion accepted + */ +export const zDeleteKnowledgeSpacesByIdSourcesBySourceIdResponse = zDurableDeletionAccepted + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Source + */ +export const zGetKnowledgeSpacesByIdSourcesBySourceIdResponse = zSource + +export const zPatchKnowledgeSpacesByIdSourcesBySourceIdBody = z.object({ + expectedVersion: z.int().gte(1).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + name: z.string().min(1).max(200).optional(), + status: z.enum(['active', 'syncing', 'error', 'disabled']).optional(), +}) + +export const zPatchKnowledgeSpacesByIdSourcesBySourceIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPatchKnowledgeSpacesByIdSourcesBySourceIdPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Updated source + */ +export const zPatchKnowledgeSpacesByIdSourcesBySourceIdResponse = zSource + +export const zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +export const zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsQuery = z.object({ + expectedVersion: z.int().gte(1), +}) + +/** + * Revoked source credentials + */ +export const zDeleteKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponse = zSource + +export const zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsBody = z.object({ + credentials: z.record(z.string(), z.unknown()), + expectedVersion: z.int().gte(1), +}) + +export const zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Rotated source credentials; secret bytes are returned neither here nor later + */ +export const zPutKnowledgeSpacesByIdSourcesBySourceIdCredentialsResponse = zSource + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdSyncHeaders = z.object({ + 'Idempotency-Key': z.string().min(1).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdSyncPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Durable source sync accepted + */ +export const zPostKnowledgeSpacesByIdSourcesBySourceIdSyncResponse = zSourceWorkflowRun + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewHeaders = z.object({ + 'Idempotency-Key': z.string().min(1).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Durable crawl preview accepted + */ +export const zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPreviewResponse = zSourceWorkflowRun + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsBody = z.union([ + z.object({ + items: z + .array( + z.object({ + etag: z.string().max(2048).optional(), + lastEditedTime: z.string().max(2048).optional(), + name: z.string().max(500).optional(), + pageId: z.string().min(1).max(2048), + providerItemId: z.string().min(1).max(2048), + type: z.string().min(1).max(128), + workspaceId: z.string().min(1).max(2048), + }), + ) + .min(1) + .max(200), + kind: z.enum(['online-document-import']), + }), + z.object({ + items: z + .array( + z.object({ + bucket: z.string().max(2048).optional(), + etag: z.string().max(2048).optional(), + id: z.string().min(1).max(2048), + mimeType: z.string().max(255).optional(), + name: z.string().min(1).max(500), + providerItemId: z.string().min(1).max(2048), + }), + ) + .min(1) + .max(200), + kind: z.enum(['online-drive-import']), + }), +]) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsHeaders = z.object({ + 'Idempotency-Key': z.string().min(1).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Durable provider import accepted + */ +export const zPostKnowledgeSpacesByIdSourcesBySourceIdWorkflowImportsResponse = zSourceWorkflowRun + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdPagesHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdPagesPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdPagesQuery = z.object({ + cursor: z.string().min(1).max(4096).optional(), + limit: z.int().gte(1).lte(200).optional().default(50), +}) + +/** + * Authorized online-document pages + */ +export const zGetKnowledgeSpacesByIdSourcesBySourceIdPagesResponse = zOnlineDocumentPages + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdFilesHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdFilesPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdFilesQuery = z.object({ + bucket: z.string().optional(), + continuationToken: z.string().min(1).max(4096).optional(), + maxKeys: z.int().gte(1).lte(1000).optional(), + prefix: z.string().optional(), +}) + +/** + * Online-drive files + */ +export const zGetKnowledgeSpacesByIdSourcesBySourceIdFilesResponse = zOnlineDriveFiles + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Website crawl result + */ +export const zPostKnowledgeSpacesByIdSourcesBySourceIdCrawlResponse = zWebsiteCrawlResult + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdImportBody = z.object({ + pages: z + .array( + z.object({ + lastEditedTime: z.string().min(1).optional(), + name: z.string().min(1).max(200).optional(), + pageId: z.string().min(1), + type: z.string().min(1), + workspaceId: z.string().min(1), + }), + ) + .min(1) + .max(200), +}) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdImportHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdImportPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Imported online-document pages + */ +export const zPostKnowledgeSpacesByIdSourcesBySourceIdImportResponse = zSourceImportResult + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdTestHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdTestPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Source credential validation result + */ +export const zPostKnowledgeSpacesByIdSourcesBySourceIdTestResponse = zSourceCredentialTest + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesBody = z.object({ + files: z + .array( + z.object({ + bucket: z.string().optional(), + id: z.string().min(1), + mimeType: z.string().optional(), + name: z.string().min(1).max(255), + }), + ) + .min(1) + .max(200), +}) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Imported online-drive files + */ +export const zPostKnowledgeSpacesByIdSourcesBySourceIdImportFilesResponse = zSourceImportResult + +export const zPostKnowledgeSpacesByIdSourcesBulkBody = z.object({ + action: z.enum(['sync', 'disable', 'remove']), + sourceIds: z.array(z.uuid()).min(1).max(200), +}) + +export const zPostKnowledgeSpacesByIdSourcesBulkHeaders = z.object({ + 'Idempotency-Key': z.string().min(1).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourcesBulkPath = z.object({ + id: z.uuid(), +}) + +/** + * Durable bulk source workflow accepted + */ +export const zPostKnowledgeSpacesByIdSourcesBulkResponse = zSourceWorkflowRun + +export const zGetKnowledgeSpacesByIdSourceWorkflowsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourceWorkflowsPath = z.object({ + id: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdSourceWorkflowsQuery = z.object({ + cursor: z.string().max(4096).optional(), + limit: z.int().gte(1).lte(200).optional().default(50), + sourceId: z.uuid().optional(), +}) + +/** + * Source workflow history + */ +export const zGetKnowledgeSpacesByIdSourceWorkflowsResponse = z.object({ + items: z.array(zSourceWorkflowRun), + nextCursor: z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPath = z.object({ + id: z.uuid(), + runId: z.uuid(), +}) + +/** + * Source workflow + */ +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdResponse = zSourceWorkflowRun + +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsPath = z.object({ + id: z.uuid(), + runId: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsQuery = z.object({ + cursor: z.string().max(4096).optional(), + limit: z.int().gte(1).lte(200).optional().default(50), +}) + +/** + * Per-source bulk workflow results + */ +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItemsResponse = z.object({ + items: z.array( + z.object({ + action: z.enum(['sync', 'disable', 'remove']), + errorCode: z.string().optional(), + id: z.uuid(), + reason: z.string().optional(), + sourceId: z.uuid(), + status: z.enum(['eligible', 'running', 'skipped', 'failed', 'completed']), + updatedAt: z.string(), + }), + ), + nextCursor: z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesPath = z.object({ + id: z.uuid(), + runId: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesQuery = z.object({ + cursor: z.string().max(4096).optional(), + limit: z.int().gte(1).lte(200).optional().default(50), +}) + +/** + * Crawl preview pages (content excluded) + */ +export const zGetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse = z.object({ + items: z.array( + z.object({ + description: z.string().optional(), + etag: z.string().optional(), + pageId: z.string(), + sourceUrl: z.string(), + title: z.string().optional(), + }), + ), + nextCursor: z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelBody = z.object({ + reason: z.string().max(1000).optional(), +}) + +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelPath = z.object({ + id: z.uuid(), + runId: z.uuid(), +}) + +/** + * Source workflow canceled + */ +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdCancelResponse = zSourceWorkflowRun + +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryPath = z.object({ + id: z.uuid(), + runId: z.uuid(), +}) + +/** + * Source workflow retried + */ +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdRetryResponse = zSourceWorkflowRun + +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionBody = z.object({ + pageIds: z.array(z.string().min(1).max(128)).min(1).max(200), +}) + +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionHeaders = z.object({ + 'Idempotency-Key': z.string().min(1).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionPath = z.object({ + id: z.uuid(), + runId: z.uuid(), +}) + +/** + * Crawl import selection accepted + */ +export const zPostKnowledgeSpacesByIdSourceWorkflowsByRunIdSelectionResponse = zSourceWorkflowRun + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Source sync policy + */ +export const zGetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse = z.object({ + createdAt: z.string(), + customIntervalSeconds: z.int().optional(), + enabled: z.boolean(), + expectedSourceVersion: z.int().gte(1), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + mode: z.enum(['provider', 'manual', 'interval', 'custom']), + nextRunAt: z.string().optional(), + revision: z.int().gte(1), + sourceId: z.uuid(), + updatedAt: z.string(), +}) + +export const zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyBody = z.object({ + customIntervalSeconds: z.int().gte(3600).lte(2592000).optional(), + enabled: z.boolean(), + expectedRevision: z.int().gte(0), + expectedSourceVersion: z.int().gte(1), + mode: z.enum(['provider', 'manual', 'interval', 'custom']), +}) + +export const zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyPath = z.object({ + id: z.uuid(), + sourceId: z.uuid(), +}) + +/** + * Source sync policy updated + */ +export const zPutKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse = z.object({ + createdAt: z.string(), + customIntervalSeconds: z.int().optional(), + enabled: z.boolean(), + expectedSourceVersion: z.int().gte(1), + id: z.uuid(), + knowledgeSpaceId: z.uuid(), + mode: z.enum(['provider', 'manual', 'interval', 'custom']), + nextRunAt: z.string().optional(), + revision: z.int().gte(1), + sourceId: z.uuid(), + updatedAt: z.string(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsPath = z.object({ + id: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsQuery = z.object({ + cursor: z.uuid().optional(), + limit: z.int().gte(1).lte(100).optional(), +}) + +/** + * Document assets + */ +export const zGetKnowledgeSpacesByIdDocumentsResponse = zDocumentAssetList + +export const zPostKnowledgeSpacesByIdDocumentsBody = z.object({ + documentId: z.uuid().optional(), + expectedActiveRevision: z.union([z.int().gt(0), z.enum(['null'])]).optional(), + expectedDocumentRowVersion: z.int().gte(0).nullish(), + file: z.string(), + sourceId: z.uuid().optional(), +}) + +export const zPostKnowledgeSpacesByIdDocumentsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdDocumentsPath = z.object({ + id: z.uuid(), +}) + +export const zPostKnowledgeSpacesByIdDocumentsResponse = z.union([ + zDocumentAsset, + zDocumentUploadAccepted, +]) + +export const zDeleteKnowledgeSpacesByIdDocumentsBulkBody = z.object({ + documents: z + .array( + z.object({ + documentId: z.uuid(), + expectedRevision: z.int().gt(0), + }), + ) + .min(1), +}) + +export const zDeleteKnowledgeSpacesByIdDocumentsBulkHeaders = z.object({ + 'idempotency-key': z.string().min(8).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zDeleteKnowledgeSpacesByIdDocumentsBulkPath = z.object({ + id: z.uuid(), +}) + +/** + * Per-document durable deletions accepted + */ +export const zDeleteKnowledgeSpacesByIdDocumentsBulkResponse = zDurableBulkDeletionAccepted + +export const zPostKnowledgeSpacesByIdDocumentsBulkBody = z.object({ + files: z.array(z.string()).min(1), + targets: z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdDocumentsBulkHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdDocumentsBulkPath = z.object({ + id: z.uuid(), +}) + +/** + * Accepted bulk document upload for durable compilation + */ +export const zPostKnowledgeSpacesByIdDocumentsBulkResponse = zBulkDocumentUploadAccepted + +export const zPostKnowledgeSpacesByIdDocumentsBulkReindexBody = z.object({ + all: z.boolean().optional(), + documentIds: z.array(z.uuid()).min(1).optional(), +}) + +export const zPostKnowledgeSpacesByIdDocumentsBulkReindexHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostKnowledgeSpacesByIdDocumentsBulkReindexPath = z.object({ + id: z.uuid(), +}) + +/** + * Accepted bulk document reindex + */ +export const zPostKnowledgeSpacesByIdDocumentsBulkReindexResponse = zBulkDocumentReindexResult + +export const zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdBody = z.object({ + expectedRevision: z.int().gt(0), +}) + +export const zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdHeaders = z.object({ + 'idempotency-key': z.string().min(8).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +/** + * Durable deletion accepted + */ +export const zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdResponse = zDurableDeletionAccepted + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +/** + * Document asset + */ +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdResponse = zDocumentAsset + +export const zGetKnowledgeSpacesByIdLogicalDocumentsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdLogicalDocumentsPath = z.object({ + id: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdLogicalDocumentsQuery = z.object({ + cursor: z.string().min(1).max(2048).optional(), + limit: z.int().gte(1).lte(100).optional(), +}) + +/** + * Logical documents + */ +export const zGetKnowledgeSpacesByIdLogicalDocumentsResponse = zLogicalDocumentList + +export const zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdBody = z.object({ + expectedRevision: z.int().gt(0), +}) + +export const zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdHeaders = z.object({ + 'idempotency-key': z.string().min(8).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +/** + * Durable deletion accepted + */ +export const zDeleteKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponse = + zDurableDeletionAccepted + +export const zGetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +/** + * Logical document + */ +export const zGetKnowledgeSpacesByIdLogicalDocumentsByDocumentIdResponse = zLogicalDocument + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdOutlinePath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +/** + * Document outline + */ +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdOutlineResponse = zDocumentOutline + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsQuery = z.object({ + cursor: z.string().min(1).max(2048).optional(), + limit: z.int().gte(1).lte(100).optional(), +}) + +/** + * Immutable document revision history + */ +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsResponse = zDocumentRevisionList + +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackBody = + z.object({ + expectedActiveRevision: z.int().gt(0), + expectedRowVersion: z.int().gte(0), + }) + +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackHeaders = + z.object({ + 'x-trace-id': z.string().optional(), + }) + +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackPath = + z.object({ + documentId: z.uuid(), + id: z.uuid(), + revision: z.int().gt(0), + }) + +/** + * Rollback candidate compilation accepted + */ +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollbackResponse = + zDocumentProcessingTask + +export const zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataBody = z.object({ + expectedRowVersion: z.int().gte(0), + patch: z.record(z.string(), z.unknown()), +}) + +export const zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +/** + * Updated user metadata + */ +export const zPatchKnowledgeSpacesByIdDocumentsByDocumentIdMetadataResponse = zLogicalDocument + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksHeaders = + z.object({ + 'x-trace-id': z.string().optional(), + }) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), + revision: z.int().gt(0), +}) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksQuery = z.object({ + cursor: z.string().min(1).max(2048).optional(), + limit: z.int().gte(1).lte(100).optional(), + query: z.string().min(1).max(512).optional(), +}) + +/** + * Revision-scoped chunks + */ +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksResponse = + zDocumentChunkList + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdHeaders = + z.object({ + 'x-trace-id': z.string().optional(), + }) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdPath = + z.object({ + documentId: z.uuid(), + id: z.uuid(), + revision: z.int().gt(0), + chunkId: z.uuid(), + }) + +/** + * Revision-scoped chunk + */ +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdResponse = + zDocumentRevisionChunk + +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateBody = + z.object({ + enabled: z.boolean(), + }) + +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateHeaders = + z.object({ + 'x-trace-id': z.string().optional(), + }) + +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStatePath = + z.object({ + documentId: z.uuid(), + id: z.uuid(), + revision: z.int().gt(0), + chunkId: z.uuid(), + }) + +/** + * Candidate publication accepted + */ +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdStateResponse = + zDocumentChunkStateChangeAccepted + +export const zGetKnowledgeSpacesByIdProcessingTasksHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdProcessingTasksPath = z.object({ + id: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdProcessingTasksQuery = z.object({ + cursor: z.string().min(1).max(2048).optional(), + limit: z.int().gte(1).lte(100).optional(), +}) + +/** + * Space processing tasks + */ +export const zGetKnowledgeSpacesByIdProcessingTasksResponse = zDocumentProcessingTaskList + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksQuery = z.object({ + cursor: z.string().min(1).max(2048).optional(), + limit: z.int().gte(1).lte(100).optional(), +}) + +/** + * Document processing tasks + */ +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksResponse = + zDocumentProcessingTaskList + +export const zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdHeaders = + z.object({ + 'x-trace-id': z.string().optional(), + }) + +export const zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), + taskId: z.uuid(), +}) + +/** + * Canceled processing task + */ +export const zDeleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponse = + zDocumentProcessingTask + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), + taskId: z.uuid(), +}) + +/** + * Processing task polling snapshot + */ +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdResponse = + zDocumentProcessingTask + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsHeaders = + z.object({ + 'last-event-id': z.string().optional(), + 'x-trace-id': z.string().optional(), + }) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsPath = + z.object({ + documentId: z.uuid(), + id: z.uuid(), + taskId: z.uuid(), + }) + +/** + * Progress SSE snapshot; reconnect using polling or Last-Event-ID + */ +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEventsResponse = + zDocumentProcessingTaskEvent + +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryHeaders = + z.object({ + 'x-trace-id': z.string().optional(), + }) + +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryPath = + z.object({ + documentId: z.uuid(), + id: z.uuid(), + taskId: z.uuid(), + }) + +/** + * Retried processing task + */ +export const zPostKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetryResponse = + zDocumentProcessingTask + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +/** + * Active document index settings + */ +export const zGetKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponse = zDocumentSettingsHead + +export const zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsBody = z.object({ + expectedSettingsHeadRevision: z.int().gt(0).nullable(), + settings: z.object({ + chunkOverlap: z.int().gte(0).lte(8191), + chunkSize: z.int().gte(128).lte(8192), + enableGraph: z.boolean(), + enablePageIndex: z.boolean(), + language: z.string().min(2).max(64).optional(), + }), +}) + +export const zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsPath = z.object({ + documentId: z.uuid(), + id: z.uuid(), +}) + +/** + * Versioned settings reindex accepted + */ +export const zPutKnowledgeSpacesByIdDocumentsByDocumentIdSettingsResponse = zDocumentReindexAccepted + +export const zDeleteJobsByIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zDeleteJobsByIdPath = z.object({ + id: z.string().min(1), +}) + +/** + * Canceled document compilation job + */ +export const zDeleteJobsByIdResponse = zDocumentCompilationJob + +export const zGetJobsByIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetJobsByIdPath = z.object({ + id: z.string().min(1), +}) + +/** + * Document compilation job status + */ +export const zGetJobsByIdResponse = zDocumentCompilationJob + +export const zPostJobsByIdRetryHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zPostJobsByIdRetryPath = z.object({ + id: z.string().min(1), +}) + +/** + * Reactivated document compilation attempt + */ +export const zPostJobsByIdRetryResponse = zDocumentCompilationJob + +export const zGetDeletionJobsByJobIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetDeletionJobsByJobIdPath = z.object({ + jobId: z.uuid(), +}) + +/** + * Durable deletion status + */ +export const zGetDeletionJobsByJobIdResponse = zDurableDeletionJob + +export const zPostDeletionJobsByJobIdRetryHeaders = z.object({ + 'idempotency-key': z.string().min(8).max(255), + 'x-trace-id': z.string().optional(), +}) + +export const zPostDeletionJobsByJobIdRetryPath = z.object({ + jobId: z.uuid(), +}) + +/** + * Durable deletion accepted + */ +export const zPostDeletionJobsByJobIdRetryResponse = zDurableDeletionAccepted + +export const zGetBulkJobsByIdHeaders = z.object({ + 'x-trace-id': z.string().optional(), +}) + +export const zGetBulkJobsByIdPath = z.object({ + id: z.string().min(1), +}) + +/** + * Bulk operation progress + */ +export const zGetBulkJobsByIdResponse = zBulkOperationProgress diff --git a/packages/contracts/knowledge-fs-contract.test.mjs b/packages/contracts/knowledge-fs-contract.test.mjs new file mode 100644 index 00000000000..f88da6b1eb8 --- /dev/null +++ b/packages/contracts/knowledge-fs-contract.test.mjs @@ -0,0 +1,47 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + knowledgeFsGeneratedArtifactSha256, + knowledgeFsSourceOpenapiSha256, +} from './generated/knowledge-fs/metadata.gen' +import { getStreamingOperationIds } from './scripts/knowledge-fs-contract-utils.mjs' + +const packageRoot = dirname(fileURLToPath(import.meta.url)) + +describe('KnowledgeFS contract generation', () => { + it.each(['200', '2XX'])('detects an SSE response declared with %s', (status) => { + expect( + getStreamingOperationIds({ + paths: { + '/tasks/{id}/events': { + get: { + operationId: 'streamTaskEvents', + responses: { + [status]: { + content: { + 'text/event-stream': {}, + }, + }, + }, + }, + }, + }, + }), + ).toEqual(['streamTaskEvents']) + }) + + it('matches the pinned source contract and committed generated artifacts', async () => { + const lock = JSON.parse( + await readFile(join(packageRoot, '../../api/knowledge-fs-contract.lock.json'), 'utf8'), + ) + + expect(knowledgeFsSourceOpenapiSha256).toBe(lock.openapiSha256) + for (const [fileName, expectedSha256] of Object.entries(knowledgeFsGeneratedArtifactSha256)) { + const content = await readFile(join(packageRoot, 'generated/knowledge-fs', fileName)) + expect(createHash('sha256').update(content).digest('hex'), fileName).toBe(expectedSha256) + } + }) +}) diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 7d362f856d3..d1251df3657 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -3,6 +3,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { $, defineConfig } from '@hey-api/openapi-ts' +import ts from 'typescript' type JsonObject = Record @@ -497,7 +498,15 @@ const createApiConfig = (job: ApiJob): UserConfig => ({ if (ctx.schema.format === 'binary') return $(ctx.symbols.z) .attr('custom') - .call() + .call( + $.func((predicate) => { + const value = $.id('value') + const isBlob = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('Blob')) + const isFile = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('File')) + predicate.param('value') + predicate.do($.return($.binary(isBlob, '||', isFile))) + }), + ) .generic($.type.or($.type('Blob'), $.type('File'))) if (ctx.schema.pattern === pydanticDecimalStringPattern) { diff --git a/packages/contracts/openapi-ts.knowledge-fs.config.ts b/packages/contracts/openapi-ts.knowledge-fs.config.ts new file mode 100644 index 00000000000..603267c39d1 --- /dev/null +++ b/packages/contracts/openapi-ts.knowledge-fs.config.ts @@ -0,0 +1,50 @@ +import { defineConfig } from '@hey-api/openapi-ts' + +const input = process.env.KNOWLEDGE_FS_OPENAPI +const outputPath = process.env.KNOWLEDGE_FS_OUTPUT ?? 'generated/knowledge-fs' + +if (!input) throw new Error('KNOWLEDGE_FS_OPENAPI must point to the filtered pinned export') + +export default defineConfig({ + input, + logs: { + file: false, + }, + output: { + clean: true, + entryFile: false, + fileName: { + suffix: '.gen', + }, + path: outputPath, + }, + parser: { + patch: { + input: (spec) => { + const paths = spec.paths as Record | undefined + if (!paths) return + + for (const [path, pathItem] of Object.entries(paths)) { + delete paths[path] + paths[`/knowledge-fs${path}`] = pathItem + } + }, + }, + }, + plugins: [ + { + comments: false, + name: '@hey-api/typescript', + }, + { + name: 'zod', + }, + { + contracts: { + strategy: 'single', + }, + name: 'orpc', + validator: 'zod', + }, + ], +}) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 79f6c4e23a9..ac0c28e7911 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -8,6 +8,10 @@ "types": "./marketplace.ts", "import": "./marketplace.ts" }, + "./console": { + "types": "./console.ts", + "import": "./console.ts" + }, "./api/*": { "types": "./generated/api/*.ts", "import": "./generated/api/*.ts" @@ -15,12 +19,17 @@ "./enterprise/*": { "types": "./generated/enterprise/*.ts", "import": "./generated/enterprise/*.ts" + }, + "./knowledge-fs/*": { + "types": "./generated/knowledge-fs/*.ts", + "import": "./generated/knowledge-fs/*.ts" } }, "scripts": { "gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api", "gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts", - "test": "vp test openapi-yaml.test.ts", + "gen-knowledge-fs-contract": "node scripts/generate-knowledge-fs-contract.mjs", + "test": "vp test", "type-check": "tsc" }, "dependencies": { diff --git a/packages/contracts/sandbox-contract.smoke.test.ts b/packages/contracts/sandbox-contract.smoke.test.ts index 84e721f14a7..c1e20b62905 100644 --- a/packages/contracts/sandbox-contract.smoke.test.ts +++ b/packages/contracts/sandbox-contract.smoke.test.ts @@ -1,26 +1,14 @@ -import assert from 'node:assert/strict' -import { registerHooks } from 'node:module' -import { dirname, resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { sandbox as agentSandbox } from './generated/api/console/agent/orpc.gen' +import { sandbox as appSandbox } from './generated/api/console/apps/orpc.gen' -const thisDir = dirname(fileURLToPath(import.meta.url)) -const sourcePath = resolve(thisDir, './generated/api/console/apps/orpc.gen.ts') - -registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier === './zod.gen' || specifier.endsWith('/zod.gen')) - return nextResolve(`${specifier}.ts`, context) - - return nextResolve(specifier, context) - }, +describe('generated sandbox contracts', () => { + it.each([ + ['Agent sandbox', agentSandbox], + ['App sandbox', appSandbox], + ])('exposes the %s file operations', (_, sandbox) => { + expect(sandbox.files.get).toBeDefined() + expect(sandbox.files.read.get).toBeDefined() + expect(sandbox.files.upload.post).toBeDefined() + }) }) - -const { agentSandbox, sandbox } = await import(pathToFileURL(sourcePath).href) - -assert.ok(agentSandbox.files.get) -assert.ok(agentSandbox.files.read.get) -assert.ok(agentSandbox.files.upload.post) - -assert.ok(sandbox.files.get) -assert.ok(sandbox.files.read.get) -assert.ok(sandbox.files.upload.post) diff --git a/packages/contracts/scripts/generate-knowledge-fs-contract.mjs b/packages/contracts/scripts/generate-knowledge-fs-contract.mjs new file mode 100644 index 00000000000..962f51c6c0a --- /dev/null +++ b/packages/contracts/scripts/generate-knowledge-fs-contract.mjs @@ -0,0 +1,119 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { getStreamingOperationIds } from './knowledge-fs-contract-utils.mjs' + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const workspaceRoot = resolve(packageRoot, '../..') +const repository = resolve( + process.env.KNOWLEDGE_FS_REPO ?? resolve(workspaceRoot, '../knowledge-fs'), +) +const temporaryDirectory = await mkdtemp(join(tmpdir(), 'dify-knowledge-fs-types-')) + +try { + const openapiPath = join(temporaryDirectory, 'knowledge-fs.console.json') + run( + 'uv', + [ + 'run', + '--project', + resolve(workspaceRoot, 'api'), + resolve(workspaceRoot, 'api/dev/generate_knowledge_fs_contract.py'), + '--repository', + repository, + '--check', + '--output-openapi', + openapiPath, + ], + workspaceRoot, + ) + run('pnpm', ['exec', 'openapi-ts', '-f', 'openapi-ts.knowledge-fs.config.ts'], packageRoot, { + KNOWLEDGE_FS_OPENAPI: openapiPath, + }) + await patchStreamingContracts(openapiPath) + run('pnpm', ['exec', 'vp', 'fmt', 'generated/knowledge-fs'], packageRoot) + await writeContractMetadata(openapiPath, await generatedArtifactSha256()) + run('pnpm', ['exec', 'vp', 'fmt', 'generated/knowledge-fs/metadata.gen.ts'], packageRoot) +} finally { + await rm(temporaryDirectory, { force: true, recursive: true }) +} + +async function patchStreamingContracts(openapiPath) { + const document = JSON.parse(await readFile(openapiPath, 'utf8')) + const streamingOperationIds = getStreamingOperationIds(document) + if (streamingOperationIds.length === 0) return + + const outputPath = join(packageRoot, 'generated/knowledge-fs/orpc.gen.ts') + let source = await readFile(outputPath, 'utf8') + source = replaceOnce( + source, + "import { oc } from '@orpc/contract'", + "import { eventIterator, oc } from '@orpc/contract'", + ) + + for (const operationId of streamingOperationIds) { + const responseSchema = `z${capitalize(operationId)}Response` + source = replaceOnce( + source, + `.output(${responseSchema})`, + `.output(eventIterator(${responseSchema}))`, + ) + } + + await writeFile(outputPath, source) +} + +function capitalize(value) { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +function replaceOnce(source, target, replacement) { + const firstIndex = source.indexOf(target) + if (firstIndex === -1 || source.indexOf(target, firstIndex + target.length) !== -1) + throw new Error(`Expected exactly one generated occurrence of ${target}`) + + return source.slice(0, firstIndex) + replacement + source.slice(firstIndex + target.length) +} + +async function generatedArtifactSha256() { + const generatedDirectory = join(packageRoot, 'generated/knowledge-fs') + const fileNames = (await readdir(generatedDirectory)) + .filter((fileName) => fileName.endsWith('.gen.ts') && fileName !== 'metadata.gen.ts') + .sort() + + return Object.fromEntries( + await Promise.all( + fileNames.map(async (fileName) => [ + fileName, + createHash('sha256') + .update(await readFile(join(generatedDirectory, fileName))) + .digest('hex'), + ]), + ), + ) +} + +async function writeContractMetadata(openapiPath, artifactSha256) { + const document = JSON.parse(await readFile(openapiPath, 'utf8')) + const source = [ + '// This file is auto-generated by scripts/generate-knowledge-fs-contract.mjs.', + '// Do not edit it manually.', + '', + `export const knowledgeFsSourceOpenapiSha256 = ${JSON.stringify(document['x-dify-source-openapi-sha256'])}`, + `export const knowledgeFsConsoleDeclarationsSha256 = ${JSON.stringify(document['x-dify-console-declarations-sha256'])}`, + `export const knowledgeFsGeneratedArtifactSha256 = ${JSON.stringify(artifactSha256, null, 2)} as const`, + '', + ].join('\n') + await writeFile(join(packageRoot, 'generated/knowledge-fs/metadata.gen.ts'), source) +} + +function run(command, args, cwd, extraEnv = {}) { + execFileSync(command, args, { + cwd, + env: { ...process.env, ...extraEnv }, + stdio: 'inherit', + }) +} diff --git a/packages/contracts/scripts/knowledge-fs-contract-utils.mjs b/packages/contracts/scripts/knowledge-fs-contract-utils.mjs new file mode 100644 index 00000000000..3f607516f25 --- /dev/null +++ b/packages/contracts/scripts/knowledge-fs-contract-utils.mjs @@ -0,0 +1,19 @@ +export function getStreamingOperationIds(document) { + return Object.values(document.paths ?? {}) + .flatMap((pathItem) => + Object.values(pathItem).flatMap((operation) => { + if (typeof operation !== 'object' || operation === null) return [] + const isEventStream = Object.entries(operation.responses ?? {}).some( + ([status, response]) => + (status === '2XX' || /^2\d\d$/.test(status)) && + typeof response === 'object' && + response !== null && + 'text/event-stream' in (response.content ?? {}), + ) + return isEventStream && typeof operation.operationId === 'string' + ? [operation.operationId] + : [] + }), + ) + .sort() +} diff --git a/packages/dify-ui/src/button/index.stories.tsx b/packages/dify-ui/src/button/index.stories.tsx index dec3a3aaf6f..be16f4cd36b 100644 --- a/packages/dify-ui/src/button/index.stories.tsx +++ b/packages/dify-ui/src/button/index.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { expect, fn } from 'storybook/test' -import { Button } from '.' +import { Button, buttonVariants } from '.' const meta = { title: 'Base/UI/Button', @@ -151,11 +151,24 @@ export const LargeSize: Story = { }, } -export const AsLink: Story = { - args: { - variant: 'ghost-accent', - render: , - nativeButton: false, - children: 'Link Button', +export const StyledLink: Story = { + render: () => ( + + Link styled as a button + + ), + play: async ({ canvas }) => { + await expect(canvas.getByRole('link', { name: 'Link styled as a button' })).toHaveAttribute( + 'href', + 'https://example.com', + ) + }, + parameters: { + docs: { + description: { + story: + 'Rendering an anchor through `Button` is an anti-pattern because Base UI enforces button semantics. Keep the native link and apply `buttonVariants` directly when a link needs button styling. See the [Base UI Button usage guidelines](https://base-ui.com/react/components/button#rendering-links-as-buttons).', + }, + }, }, } diff --git a/packages/dify-ui/src/button/index.tsx b/packages/dify-ui/src/button/index.tsx index 631680fa950..cd8d0a8b7bb 100644 --- a/packages/dify-ui/src/button/index.tsx +++ b/packages/dify-ui/src/button/index.tsx @@ -6,7 +6,7 @@ import { Button as BaseButton } from '@base-ui/react/button' import { cva } from 'class-variance-authority' import { cn } from '../cn' -const buttonVariants = cva( +export const buttonVariants = cva( 'inline-flex cursor-pointer items-center justify-center whitespace-nowrap outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid data-[disabled]:cursor-not-allowed', { variants: { diff --git a/packages/dify-ui/src/toast/index.stories.tsx b/packages/dify-ui/src/toast/index.stories.tsx index 301252bbc38..8997874324f 100644 --- a/packages/dify-ui/src/toast/index.stories.tsx +++ b/packages/dify-ui/src/toast/index.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { expect, within } from 'storybook/test' import { toast, ToastHost } from '.' +import { Button } from '../button' const longToastTitle = 'operation error S3: PutObject, exceeded maximum number of attempts, 3, StatusCode: 0, RequestID: , HostID: , request send failed' @@ -155,58 +156,62 @@ const StackExamples = () => { } const PromiseExamples = () => { - const createPromiseToast = () => { - const request = new Promise((resolve) => { - window.setTimeout(() => resolve('The deployment is now available in production.'), 1400) + const [pendingExample, setPendingExample] = React.useState<'success' | 'error' | null>(null) + + const exportDsl = async (outcome: 'success' | 'error') => { + if (pendingExample) return + + setPendingExample(outcome) + const request = new Promise((resolve, reject) => { + window.setTimeout(() => { + if (outcome === 'success') resolve('customer-support-agent.yml') + else reject(new Error('The DSL could not be generated.')) + }, 1400) }) - void toast.promise(request, { - loading: { - type: 'info', - title: 'Deploying workflow', - description: 'Provisioning runtime and publishing the latest version.', - }, - success: (result) => ({ - type: 'success', - title: 'Deployment complete', - description: result, - }), - error: () => ({ - type: 'error', - title: 'Deployment failed', - description: 'The release could not be completed.', - }), - }) - } + await toast + .promise(request, { + loading: { + title: 'Preparing DSL export', + description: 'Collecting the app configuration and generating a YAML file.', + }, + success: (fileName) => ({ + title: 'Download started', + description: `${fileName} was sent to your browser.`, + timeout: 3000, + }), + error: () => ({ + title: 'Export failed', + description: 'The DSL could not be generated. Try again.', + }), + }) + .catch(() => undefined) - const createRejectingPromiseToast = () => { - const request = new Promise((_, reject) => { - window.setTimeout(() => reject(new Error('intentional story failure')), 1200) - }) - - void toast.promise(request, { - loading: 'Validating model credentials…', - success: 'Credentials verified', - error: () => ({ - type: 'error', - title: 'Credentials rejected', - description: 'The model provider returned an authentication error.', - }), - }) + setPendingExample(null) } return ( - - + + ) } diff --git a/packages/dify-ui/src/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx index 9aad5890c17..3fa8d3b9f04 100644 --- a/packages/dify-ui/src/toast/index.tsx +++ b/packages/dify-ui/src/toast/index.tsx @@ -16,6 +16,11 @@ type ToastToneStyle = { } const TOAST_TONE_STYLES = { + loading: { + iconClassName: 'i-ri-loader-2-line animate-spin text-text-accent motion-reduce:animate-none', + gradientClassName: + 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent', + }, success: { iconClassName: 'i-ri-checkbox-circle-fill text-text-success', gradientClassName: @@ -41,7 +46,8 @@ const TOAST_TONE_STYLES = { const toastCloseLabel = 'Close notification' const toastViewportLabel = 'Notifications' -type ToastType = keyof typeof TOAST_TONE_STYLES +type ToastRenderType = keyof typeof TOAST_TONE_STYLES +type ToastType = Exclude type ToastAddOptions = Omit< ToastManagerAddOptions, @@ -96,12 +102,12 @@ type ToastApi = { const toastManager = BaseToast.createToastManager() -function isToastType(type: string): type is ToastType { +function isToastRenderType(type: string): type is ToastRenderType { return Object.prototype.hasOwnProperty.call(TOAST_TONE_STYLES, type) } -function getToastType(type?: string): ToastType | undefined { - return type && isToastType(type) ? type : undefined +function getToastRenderType(type?: string): ToastRenderType | undefined { + return type && isToastRenderType(type) ? type : undefined } function addToast(options: ToastAddOptions) { @@ -145,19 +151,19 @@ export const toast: ToastApi = Object.assign(showToast, { promise: promiseToast, }) -function ToastIcon({ type }: { type?: ToastType }) { +function ToastIcon({ type }: { type?: ToastRenderType }) { return type ? (